rapport spec as attachment

This commit is contained in:
felix
2026-09-26 16:54:49 +02:00
parent d120237521
commit 4c81c21261
12 changed files with 141 additions and 58 deletions

View File

@@ -1,14 +1,13 @@
{{! A loader while the file is being made, a download link when it is ready }}
<div class="chat-attachment au-u-margin-top-tiny">
{{#if @document.url}}
<a href={{@document.url}} class="au-c-link" download={{@document.name}}>
{{@document.name}}
</a>
{{else if @late}}
<AuAlert @skin="warning" @size="small" @title={{@document.name}}>
Dit duurt langer dan verwacht. Vernieuw de pagina om opnieuw te kijken.
</AuAlert>
{{else}}
<AuLoader @inline={{true}}>{{@document.name}} wordt gemaakt</AuLoader>
{{/if}}
{{! One row of the bijlagen overview: its name, what it holds, and how to
get it. The service is synchronous per turn: a bijlage already has its
file when the message is served. The file service serves
/files/<uuid>/download. }}
<div class="chat-attachment chat-attachment--row au-u-margin-top-tiny">
<div class="chat-attachment__info">
<strong>{{@document.filename}}</strong>
<span class="chat-attachment__what">{{this.what}}</span>
</div>
<a href={{this.href}} class="au-c-link" download={{@document.filename}}>
Download
</a>
</div>

View File

@@ -0,0 +1,21 @@
import Component from '@glimmer/component';
// One row of the bijlagen overview: what it is, and where to get it.
export default class ChatAttachment extends Component {
// The file service serves every bijlage at /files/<uuid>/download.
get href() {
return `/files/${this.uuid}/download`;
}
get uuid() {
return String(this.args.document.uri || '').split('/').pop();
}
// What the document holds, in Dutch, so the overview explains itself.
get what() {
const format = this.args.document.format || '';
if (format.includes('turtle')) return 'De specificatie van het rapport als Turtle-bestand';
if (format.includes('csv')) return 'De resultaten van het rapport als CSV-bestand';
return 'Bestand';
}
}

View File

@@ -1,6 +1,31 @@
{{! Rendered markdown. Assistant content only; the user's own text stays
plain. The value is sanitized (DOMPurify) and marked safe in the js class. }}
<div class="chat-md">{{this.html}}</div>
{{#each @message.attachments as |document|}}
<Chat::Attachment @document={{document}} @late={{this.late}} />
{{/each}}
plain. The value is sanitized (DOMPurify) and marked safe in the js class.
Attachments get their own tab, "bijlagen"; the text refers the user there. }}
{{#if this.attachments.length}}
<div class="chat-bijlagen-tabs">
<button
type="button"
class="chat-bijlagen-tab {{unless this.showBijlagen 'chat-bijlagen-tab--active'}}"
{{on "click" this.showText}}
>Bericht</button>
<button
type="button"
class="chat-bijlagen-tab {{if this.showBijlagen 'chat-bijlagen-tab--active'}}"
{{on "click" this.openBijlagen}}
>Bijlagen ({{this.attachments.length}})</button>
</div>
{{/if}}
{{#if this.showBijlagen}}
{{#each this.attachments as |document|}}
<Chat::Attachment @document={{document}} />
{{/each}}
{{else}}
<div class="chat-md">{{this.html}}</div>
{{#if this.attachments.length}}
<AuAlert @skin="info" @size="small" @title="Bijlagen beschikbaar" {{on "click" this.openBijlagen}}>
Dit antwoord bevat {{this.attachments.length}} bijlage(n), zoals de specifieke
specificatie van het rapport of de resultaten. Klik op Bijlagen voor de
uitleg per bijlage en om ze te downloaden.
</AuAlert>
{{/if}}
{{/if}}

View File

@@ -1,11 +1,30 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { htmlSafe } from '@ember/template';
import { renderMarkdown } from 'frontend-lblod-chat/utils/markdown';
export default class AssistantMessage extends Component {
@tracked showBijlagen = false;
// The string is DOMPurify-sanitized in utils/markdown; htmlSafe only marks
// it as rendered HTML.
get html() {
return htmlSafe(renderMarkdown(this.args.message.content));
}
get attachments() {
return this.args.message.attachments || [];
}
@action
openBijlagen(event) {
event.preventDefault();
this.showBijlagen = true;
}
@action
showText() {
this.showBijlagen = false;
}
}

View File

@@ -1,4 +1,4 @@
{{@message.content}}
{{#each @message.attachments as |document|}}
<Chat::Attachment @document={{document}} @late={{this.late}} />
<Chat::Attachment @document={{document}} />
{{/each}}

View File

@@ -1,10 +0,0 @@
import Component from '@glimmer/component';
import { FILE_CEILING_MS } from 'frontend-lblod-chat/utils/ceilings';
export default class InstantMessage extends Component {
get late() {
return (
this.args.now - this.args.message.created.getTime() >= FILE_CEILING_MS
);
}
}

View File

@@ -5,10 +5,7 @@ import { action } from '@ember/object';
import { task, restartableTask, timeout } from 'ember-concurrency';
import ENV from 'frontend-lblod-chat/config/environment';
import { setting } from 'frontend-lblod-chat/utils/setting';
import {
ANSWER_CEILING_MS,
FILE_CEILING_MS,
} from 'frontend-lblod-chat/utils/ceilings';
import { ANSWER_CEILING_MS } from 'frontend-lblod-chat/utils/ceilings';
const POLL_MS = 3000;
@@ -60,17 +57,8 @@ export default class ChatWindow extends Component {
);
}
// Waiting for a file: an attachment has no url yet.
get waitingForFile() {
return this.messages.some(
(m) =>
this.age(m.created) < FILE_CEILING_MS &&
m.attachments.some((a) => !a.url),
);
}
get waiting() {
return this.waitingForAnswer || this.waitingForFile;
return this.waitingForAnswer;
}
// One loop. Runs while there is something to wait for, and not otherwise.

View File

@@ -1,8 +0,0 @@
import Model, { attr } from '@warp-drive/legacy/model';
export default class ChatDocument extends Model {
@attr uri;
@attr name;
@attr mediaType;
@attr url;
}

View File

@@ -12,5 +12,5 @@ export default class ChatMessage extends Model {
as: 'chat-message',
})
conversation;
@hasMany('chat-document', { async: false, inverse: null }) attachments;
@hasMany('file', { async: false, inverse: null }) attachments;
}

13
app/models/file.js Normal file
View File

@@ -0,0 +1,13 @@
import Model, { attr } from '@warp-drive/legacy/model';
// A bijlage of the report assistant: a real file in the semantic.works
// file data object model (logical nfo:FileDataObject over a physical
// share:// part), served by the file service at /files/<uuid>/download.
// dct:type marks what it holds.
export default class File extends Model {
@attr uri;
@attr filename;
@attr format;
@attr size;
@attr('date') created;
}

View File

@@ -130,3 +130,41 @@
color: var(--au-gray-700);
font-size: 0.9em;
}
/* The attachment tab inside an assistant message */
.chat-bijlagen-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.chat-bijlagen-tab {
border: 1px solid var(--au-gray-300);
background: transparent;
border-radius: 0.25rem 0.25rem 0 0;
padding: 0.15rem 0.6rem;
font-size: 0.85em;
cursor: pointer;
color: var(--au-gray-700);
}
.chat-bijlagen-tab--active {
background: var(--au-gray-100);
font-weight: 600;
}
.chat-attachment--row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.375rem 0;
border-top: 1px solid var(--au-gray-300);
}
.chat-attachment__info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.chat-attachment__what,
.chat-attachment__pending {
color: var(--au-gray-500);
font-size: 0.85em;
}

View File

@@ -1,4 +1,2 @@
// The two ceilings, in one place. The window stops polling past them; the
// file card shows its warning past its own.
// The answer ceiling, in one place. The window stops polling past it.
export const ANSWER_CEILING_MS = 10 * 60 * 1000;
export const FILE_CEILING_MS = 30 * 60 * 1000;