initial commit
This commit is contained in:
14
app/components/chat/attachment.hbs
Normal file
14
app/components/chat/attachment.hbs
Normal 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>
|
||||
18
app/components/chat/composer.hbs
Normal file
18
app/components/chat/composer.hbs
Normal 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>
|
||||
25
app/components/chat/composer.js
Normal file
25
app/components/chat/composer.js
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
10
app/components/chat/message.hbs
Normal file
10
app/components/chat/message.hbs
Normal 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>
|
||||
11
app/components/chat/message.js
Normal file
11
app/components/chat/message.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
6
app/components/chat/types/assistant-message.hbs
Normal file
6
app/components/chat/types/assistant-message.hbs
Normal 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}}
|
||||
11
app/components/chat/types/assistant-message.js
Normal file
11
app/components/chat/types/assistant-message.js
Normal 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));
|
||||
}
|
||||
}
|
||||
4
app/components/chat/types/instant-message.hbs
Normal file
4
app/components/chat/types/instant-message.hbs
Normal file
@@ -0,0 +1,4 @@
|
||||
{{@message.content}}
|
||||
{{#each @message.attachments as |document|}}
|
||||
<Chat::Attachment @document={{document}} @late={{this.late}} />
|
||||
{{/each}}
|
||||
10
app/components/chat/types/instant-message.js
Normal file
10
app/components/chat/types/instant-message.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/components/chat/window.hbs
Normal file
39
app/components/chat/window.hbs
Normal 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>
|
||||
123
app/components/chat/window.js
Normal file
123
app/components/chat/window.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user