initial commit

This commit is contained in:
felix
2026-09-16 17:16:19 +02:00
commit d120237521
80 changed files with 26582 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
{{! 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}}
</div>

View File

@@ -0,0 +1,18 @@
<div class="chat-composer au-u-flex au-u-flex--between">
<AuTextarea
@width="block"
@disabled={{@disabled}}
rows="2"
placeholder="Stel een vraag"
value={{this.draft}}
{{on "input" this.update}}
{{on "keydown" this.keydown}}
/>
<AuButton
@disabled={{@disabled}}
class="au-u-margin-left-small"
{{on "click" this.submit}}
>
Verstuur
</AuButton>
</div>

View File

@@ -0,0 +1,25 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ChatComposer extends Component {
@tracked draft = '';
@action update(event) {
this.draft = event.target.value;
}
@action keydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.submit();
}
}
@action submit() {
const content = this.draft.trim();
if (!content || this.args.disabled) return;
this.args.onSend(content);
this.draft = '';
}
}

View File

@@ -0,0 +1,10 @@
<div
class="chat-message
{{if @isAssistant 'chat-message--assistant' 'chat-message--user'}}"
>
<this.body
@message={{@message}}
@isAssistant={{@isAssistant}}
@now={{@now}}
/>
</div>

View File

@@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import { componentFor } from 'frontend-lblod-chat/utils/message-types';
export default class ChatMessage extends Component {
get body() {
return componentFor(
this.args.message.constructor.modelName,
this.args.isAssistant,
);
}
}

View File

@@ -0,0 +1,6 @@
{{! 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}}

View File

@@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import { renderMarkdown } from 'frontend-lblod-chat/utils/markdown';
export default class AssistantMessage extends Component {
// The string is DOMPurify-sanitized in utils/markdown; htmlSafe only marks
// it as rendered HTML.
get html() {
return htmlSafe(renderMarkdown(this.args.message.content));
}
}

View File

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

View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,39 @@
{{! The list, the composer, the poll loop }}
<div class="chat-window">
<div class="chat-window__messages">
{{#unless this.messages.length}}
<p class="au-u-muted">{{@assistant.description}}</p>
{{/unless}}
{{#each this.messages key="id" as |message|}}
<Chat::Message
@message={{message}}
@isAssistant={{this.isAssistant message}}
@now={{this.now}}
/>
{{/each}}
{{#if this.waitingForAnswer}}
<AuLoader @inline={{true}}>{{@assistant.name}} denkt na</AuLoader>
{{/if}}
{{#if this.answerTimedOut}}
<AuAlert @skin="warning" @title="Geen antwoord">
De assistent heeft niet geantwoord.
<AuButton @skin="link" {{on "click" this.refresh}}>Vernieuwen</AuButton>
</AuAlert>
{{/if}}
{{#if this.send.last.isError}}
<AuAlert
@skin="error"
@title="Niet verstuurd"
>{{this.send.last.error.message}}</AuAlert>
{{/if}}
</div>
<Chat::Composer
@onSend={{perform this.send}}
@disabled={{this.waitingForAnswer}}
/>
</div>

View File

@@ -0,0 +1,123 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
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';
const POLL_MS = 3000;
export default class ChatWindow extends Component {
@service store;
@tracked now = Date.now();
assistantPath = setting(ENV.assistantPath, '/assistant');
constructor() {
super(...arguments);
this.poll.perform();
}
get conversation() {
return this.args.conversation;
}
get messages() {
return this.conversation.messages
.slice()
.sort((a, b) => a.created - b.created);
}
get last() {
return this.messages.at(-1);
}
isAssistant = (message) => message.maker === this.args.assistant?.uri;
age(date) {
return this.now - date.getTime();
}
// Waiting for an answer: the newest message is the user's.
get waitingForAnswer() {
return (
!!this.last &&
!this.isAssistant(this.last) &&
this.age(this.last.created) < ANSWER_CEILING_MS
);
}
get answerTimedOut() {
return (
!!this.last &&
!this.isAssistant(this.last) &&
this.age(this.last.created) >= ANSWER_CEILING_MS
);
}
// 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;
}
// One loop. Runs while there is something to wait for, and not otherwise.
poll = restartableTask(async () => {
while (this.waiting) {
await timeout(POLL_MS);
await this.reload();
}
});
async reload() {
await this.store.findRecord('chat-conversation', this.conversation.id, {
reload: true,
include: 'messages.attachments',
});
this.now = Date.now();
}
send = task(async (content) => {
const res = await fetch(
`${this.assistantPath}/conversations/${this.conversation.id}/turns`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ content }),
},
);
if (!res.ok)
throw new Error(`De vraag kon niet verstuurd worden (${res.status}).`);
// The 202 answers with the message id, but fetching that message alone
// does not link it into the conversation: the resource sends relationships
// as links, without data. Reload the conversation instead, so the question
// shows now and `waiting` turns true for the poll loop.
try {
await this.reload();
} catch {
// the poll loop retries the reload
}
this.poll.perform();
});
@action
async refresh() {
await this.reload();
this.poll.perform();
}
}