initial commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
dist
|
||||
tmp
|
||||
.eslintcache
|
||||
19
.editorconfig
Normal file
19
.editorconfig
Normal file
@@ -0,0 +1,19 @@
|
||||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.hbs]
|
||||
insert_final_newline = false
|
||||
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
19
.ember-cli
Normal file
19
.ember-cli
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
/**
|
||||
Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript
|
||||
rather than JavaScript by default, when a TypeScript version of a given blueprint is available.
|
||||
*/
|
||||
"isTypeScriptProject": false,
|
||||
|
||||
/**
|
||||
Setting `componentAuthoringFormat` to "strict" will force the blueprint generators to generate GJS
|
||||
or GTS files for the component and the component rendering test. "loose" is the default.
|
||||
*/
|
||||
"componentAuthoringFormat": "strict",
|
||||
|
||||
/**
|
||||
Setting `routeAuthoringFormat` to "strict" will force the blueprint generators to generate GJS
|
||||
or GTS templates for routes. "loose" is the default
|
||||
*/
|
||||
"routeAuthoringFormat": "strict"
|
||||
}
|
||||
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
||||
PROXY=http://localhost:90
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# compiled output
|
||||
/dist/
|
||||
/tmp/
|
||||
|
||||
# dependencies
|
||||
/node_modules/
|
||||
|
||||
# misc
|
||||
*.local
|
||||
/.pnp*
|
||||
/.eslintcache
|
||||
/coverage/
|
||||
/npm-debug.log*
|
||||
/testem.log
|
||||
/yarn-error.log
|
||||
|
||||
# broccoli-debug
|
||||
/DEBUG/
|
||||
14
.prettierignore
Normal file
14
.prettierignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# unconventional js
|
||||
/blueprints/*/files/
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
|
||||
# misc
|
||||
/coverage/
|
||||
!.*
|
||||
.*/
|
||||
/pnpm-lock.yaml
|
||||
ember-cli-update.json
|
||||
*.html
|
||||
CHANGELOG.md
|
||||
12
.prettierrc.mjs
Normal file
12
.prettierrc.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
plugins: ['prettier-plugin-ember-template-tag'],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.{js,gjs,ts,gts,mjs,mts,cjs,cts}',
|
||||
options: {
|
||||
singleQuote: true,
|
||||
templateSingleQuote: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
5
.stylelintignore
Normal file
5
.stylelintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
# unconventional files
|
||||
/blueprints/*/files/
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
3
.stylelintrc.mjs
Normal file
3
.stylelintrc.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
extends: ['stylelint-config-standard'],
|
||||
};
|
||||
3
.template-lintrc.mjs
Normal file
3
.template-lintrc.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
extends: 'recommended',
|
||||
};
|
||||
3
.watchmanconfig
Normal file
3
.watchmanconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore_dirs": ["dist"]
|
||||
}
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:24 AS builder
|
||||
|
||||
LABEL maintainer="info@redpencil.io"
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM semtech/static-file-service:0.2.0
|
||||
|
||||
COPY --from=builder /app/dist /data
|
||||
28
app/adapters/application.js
Normal file
28
app/adapters/application.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { JSONAPIAdapter } from '@warp-drive/legacy/adapter/json-api';
|
||||
|
||||
export default class ApplicationAdapter extends JSONAPIAdapter {
|
||||
ajax(url, method) {
|
||||
if (method !== 'GET') return super.ajax(...arguments);
|
||||
|
||||
return retryOnError(super.ajax.bind(this), arguments);
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOnError(ajax, ajaxArgs, retryCount = 0) {
|
||||
const MAX_RETRIES = 6;
|
||||
|
||||
try {
|
||||
return await ajax(...ajaxArgs);
|
||||
} catch (error) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
await sleep(250 * (retryCount + 1));
|
||||
return retryOnError(ajax, ajaxArgs, retryCount + 1);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(time) {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(true), time));
|
||||
}
|
||||
23
app/app.js
Normal file
23
app/app.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import './styles/app.scss';
|
||||
import '@warp-drive/ember/install';
|
||||
import Application from '@ember/application';
|
||||
import compatModules from '@embroider/virtual/compat-modules';
|
||||
import Resolver from 'ember-resolver';
|
||||
import loadInitializers from 'ember-load-initializers';
|
||||
import './config/custom-inflector-rules';
|
||||
import config from 'frontend-lblod-chat/config/environment';
|
||||
import { importSync, isDevelopingApp, macroCondition } from '@embroider/macros';
|
||||
import setupInspector from '@embroider/legacy-inspector-support/ember-source-4.12';
|
||||
|
||||
if (macroCondition(isDevelopingApp())) {
|
||||
importSync('./deprecation-workflow');
|
||||
}
|
||||
|
||||
export default class App extends Application {
|
||||
modulePrefix = config.modulePrefix;
|
||||
podModulePrefix = config.podModulePrefix;
|
||||
Resolver = Resolver.withModules(compatModules);
|
||||
inspector = setupInspector(this);
|
||||
}
|
||||
|
||||
loadInitializers(App, config.modulePrefix, compatModules);
|
||||
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();
|
||||
}
|
||||
}
|
||||
10
app/config/custom-inflector-rules.js
Normal file
10
app/config/custom-inflector-rules.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { irregular } from '@warp-drive/utilities/string';
|
||||
|
||||
irregular('bestuurseenheid', 'bestuurseenheden');
|
||||
irregular('bestuursorgaan', 'bestuursorganen');
|
||||
irregular('werkingsgebied', 'werkingsgebieden');
|
||||
irregular('form-data', 'form-data');
|
||||
|
||||
// TODO: find out why we do it like this instead of renaming the model?
|
||||
irregular('address', 'adressen');
|
||||
irregular('site', 'vestigingen');
|
||||
27
app/config/environment.js
Normal file
27
app/config/environment.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import loadConfigFromMeta from '@embroider/config-meta-loader';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
const config = loadConfigFromMeta('frontend-lblod-chat');
|
||||
|
||||
assert(
|
||||
'config is not an object',
|
||||
typeof config === 'object' && config !== null,
|
||||
);
|
||||
assert(
|
||||
'modulePrefix was not detected on your config',
|
||||
'modulePrefix' in config && typeof config.modulePrefix === 'string',
|
||||
);
|
||||
assert(
|
||||
'locationType was not detected on your config',
|
||||
'locationType' in config && typeof config.locationType === 'string',
|
||||
);
|
||||
assert(
|
||||
'rootURL was not detected on your config',
|
||||
'rootURL' in config && typeof config.rootURL === 'string',
|
||||
);
|
||||
assert(
|
||||
'APP was not detected on your config',
|
||||
'APP' in config && typeof config.APP === 'object',
|
||||
);
|
||||
|
||||
export default config;
|
||||
11
app/controllers/application.js
Normal file
11
app/controllers/application.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class ApplicationController extends Controller {
|
||||
@service session;
|
||||
|
||||
@action logout() {
|
||||
this.session.invalidate();
|
||||
}
|
||||
}
|
||||
20
app/controllers/conversations.js
Normal file
20
app/controllers/conversations.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ConversationsController extends Controller {
|
||||
@service store;
|
||||
@service router;
|
||||
@service currentSession;
|
||||
|
||||
create = task(async () => {
|
||||
const now = new Date();
|
||||
const conversation = this.store.createRecord('chat-conversation', {
|
||||
created: now,
|
||||
lastActivity: now,
|
||||
creator: this.currentSession.user,
|
||||
});
|
||||
await conversation.save();
|
||||
this.router.transitionTo('conversation', conversation.id);
|
||||
});
|
||||
}
|
||||
31
app/controllers/mock-login.js
Normal file
31
app/controllers/mock-login.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { restartableTask, task, timeout } from 'ember-concurrency';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MockLoginController extends Controller {
|
||||
queryParams = ['gemeente', 'page'];
|
||||
@service store;
|
||||
@tracked gemeente = '';
|
||||
@tracked page = 0;
|
||||
size = 10;
|
||||
|
||||
queryStore = task(async () => {
|
||||
const filter = { provider: 'https://github.com/lblod/mock-login-service' };
|
||||
if (this.gemeente) filter.gebruiker = { achternaam: this.gemeente };
|
||||
const accounts = await this.store.query('account', {
|
||||
include: 'gebruiker,gebruiker.bestuurseenheden',
|
||||
filter: filter,
|
||||
page: { size: this.size, number: this.page },
|
||||
sort: 'gebruiker.achternaam',
|
||||
});
|
||||
return accounts;
|
||||
});
|
||||
|
||||
updateSearch = restartableTask(async (value) => {
|
||||
await timeout(500);
|
||||
this.page = 0;
|
||||
this.gemeente = value;
|
||||
this.model = await this.queryStore.perform();
|
||||
});
|
||||
}
|
||||
42
app/deprecation-workflow.js
Normal file
42
app/deprecation-workflow.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow';
|
||||
|
||||
/**
|
||||
* Docs: https://github.com/ember-cli/ember-cli-deprecation-workflow
|
||||
*/
|
||||
setupDeprecationWorkflow({
|
||||
/**
|
||||
false by default, but if a developer / team wants to be more aggressive about being proactive with
|
||||
handling their deprecations, this should be set to "true"
|
||||
*/
|
||||
throwOnUnhandled: false,
|
||||
workflow: [
|
||||
/* ... handlers ... */
|
||||
/* to generate this list, run your app for a while (or run the test suite),
|
||||
* and then run in the browser console:
|
||||
*
|
||||
* deprecationWorkflow.flushDeprecations()
|
||||
*
|
||||
* And copy the handlers here
|
||||
*/
|
||||
/* example: */
|
||||
/* { handler: 'silence', matchId: 'template-action' }, */
|
||||
// TODO: resolve these before updating to WarpDrive v6
|
||||
{
|
||||
handler: 'silence',
|
||||
matchId: 'warp-drive:deprecate-legacy-request-methods',
|
||||
},
|
||||
// TODO: resolve these before updating to ember-power-select v9
|
||||
{
|
||||
handler: 'silence',
|
||||
matchId: 'ember-power-select.deprecate-power-select-multiple',
|
||||
},
|
||||
{
|
||||
handler: 'silence',
|
||||
matchId: 'ember-power-select.deprecate-power-select-multiple-trigger',
|
||||
},
|
||||
{
|
||||
handler: 'silence',
|
||||
matchId: 'ember-power-select.deprecate-power-select-multiple-input',
|
||||
},
|
||||
],
|
||||
});
|
||||
5
app/helpers/app-name.js
Normal file
5
app/helpers/app-name.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import ENV from 'frontend-lblod-chat/config/environment';
|
||||
import { setting } from 'frontend-lblod-chat/utils/setting';
|
||||
|
||||
export default helper(() => setting(ENV.appName, 'Assistent'));
|
||||
12
app/models/account.js
Normal file
12
app/models/account.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Model, { attr, belongsTo } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class Account extends Model {
|
||||
@attr() voId;
|
||||
@attr() provider;
|
||||
|
||||
@belongsTo('gebruiker', {
|
||||
async: true,
|
||||
inverse: null,
|
||||
})
|
||||
gebruiker;
|
||||
}
|
||||
7
app/models/bestuurseenheid-classificatie-code.js
Normal file
7
app/models/bestuurseenheid-classificatie-code.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Model, { attr } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class BestuurseenheidClassificatieCode extends Model {
|
||||
@attr() label;
|
||||
@attr() scopeNote;
|
||||
@attr() uri;
|
||||
}
|
||||
15
app/models/bestuurseenheid.js
Normal file
15
app/models/bestuurseenheid.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import Model, { attr, belongsTo } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class Bestuurseenheid extends Model {
|
||||
@attr() naam;
|
||||
|
||||
@belongsTo('bestuurseenheid-classificatie-code', {
|
||||
async: true,
|
||||
inverse: null,
|
||||
})
|
||||
classificatie;
|
||||
|
||||
get fullName() {
|
||||
return `${this.classificatie.get('label')} ${this.naam}`.trim();
|
||||
}
|
||||
}
|
||||
7
app/models/chat-agent.js
Normal file
7
app/models/chat-agent.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Model, { attr } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class ChatAgent extends Model {
|
||||
@attr uri;
|
||||
@attr name;
|
||||
@attr description;
|
||||
}
|
||||
16
app/models/chat-conversation.js
Normal file
16
app/models/chat-conversation.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import Model, { attr, belongsTo, hasMany } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class ChatConversation extends Model {
|
||||
@attr uri;
|
||||
@attr title;
|
||||
@attr('date') created;
|
||||
@attr('date') lastActivity;
|
||||
|
||||
@belongsTo('gebruiker', { async: true, inverse: null }) creator;
|
||||
@hasMany('chat-message', {
|
||||
async: false,
|
||||
inverse: 'conversation',
|
||||
polymorphic: true,
|
||||
})
|
||||
messages;
|
||||
}
|
||||
8
app/models/chat-document.js
Normal file
8
app/models/chat-document.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import Model, { attr } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class ChatDocument extends Model {
|
||||
@attr uri;
|
||||
@attr name;
|
||||
@attr mediaType;
|
||||
@attr url;
|
||||
}
|
||||
3
app/models/chat-instant-message.js
Normal file
3
app/models/chat-instant-message.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import ChatMessage from './chat-message';
|
||||
|
||||
export default class ChatInstantMessage extends ChatMessage {}
|
||||
16
app/models/chat-message.js
Normal file
16
app/models/chat-message.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import Model, { attr, belongsTo, hasMany } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class ChatMessage extends Model {
|
||||
@attr uri;
|
||||
@attr content;
|
||||
@attr('date') created;
|
||||
@attr maker;
|
||||
|
||||
@belongsTo('chat-conversation', {
|
||||
async: false,
|
||||
inverse: 'messages',
|
||||
as: 'chat-message',
|
||||
})
|
||||
conversation;
|
||||
@hasMany('chat-document', { async: false, inverse: null }) attachments;
|
||||
}
|
||||
27
app/models/gebruiker.js
Normal file
27
app/models/gebruiker.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import Model, { attr, hasMany } from '@warp-drive/legacy/model';
|
||||
|
||||
export default class Gebruiker extends Model {
|
||||
@attr uri;
|
||||
|
||||
@attr() voornaam;
|
||||
@attr() achternaam;
|
||||
|
||||
@hasMany('account', {
|
||||
async: true,
|
||||
inverse: null,
|
||||
})
|
||||
account;
|
||||
@hasMany('bestuurseenheid', {
|
||||
async: true,
|
||||
inverse: null,
|
||||
})
|
||||
bestuurseenheden;
|
||||
|
||||
get group() {
|
||||
return this.hasMany('bestuurseenheden').value()[0];
|
||||
}
|
||||
|
||||
get fullName() {
|
||||
return `${this.voornaam} ${this.achternaam}`.trim();
|
||||
}
|
||||
}
|
||||
16
app/router.js
Normal file
16
app/router.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import EmberRouter from '@embroider/router';
|
||||
import config from 'frontend-lblod-chat/config/environment';
|
||||
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
|
||||
Router.map(function () {
|
||||
this.route('login');
|
||||
this.route('mock-login');
|
||||
this.route('authorization-callback', { path: '/authorization/callback' });
|
||||
this.route('conversations');
|
||||
this.route('conversation', { path: '/conversations/:id' });
|
||||
this.route('route-not-found', { path: '/*wildcard' });
|
||||
});
|
||||
21
app/routes/application.js
Normal file
21
app/routes/application.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { warn } from '@ember/debug';
|
||||
|
||||
export default class ApplicationRoute extends Route {
|
||||
@service router;
|
||||
@service session;
|
||||
@service currentSession;
|
||||
|
||||
async beforeModel() {
|
||||
await this.session.setup();
|
||||
return this._loadCurrentSession();
|
||||
}
|
||||
|
||||
_loadCurrentSession() {
|
||||
return this.currentSession.load().catch((e) => {
|
||||
warn(e, { id: 'session-load-failure' });
|
||||
this.session.invalidate();
|
||||
});
|
||||
}
|
||||
}
|
||||
14
app/routes/authorization-callback.js
Normal file
14
app/routes/authorization-callback.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class AuthorizationCallbackRoute extends Route {
|
||||
@service session;
|
||||
|
||||
beforeModel() {
|
||||
this.session.prohibitAuthentication('index');
|
||||
}
|
||||
|
||||
model(params) {
|
||||
return this.session.authenticate('authenticator:acm-idm', params.code);
|
||||
}
|
||||
}
|
||||
22
app/routes/conversation.js
Normal file
22
app/routes/conversation.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ConversationRoute extends Route {
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
beforeModel(transition) {
|
||||
this.session.requireAuthentication(transition, 'login');
|
||||
}
|
||||
|
||||
async model({ id }) {
|
||||
const [conversation, agents] = await Promise.all([
|
||||
this.store.findRecord('chat-conversation', id, {
|
||||
include: 'messages.attachments',
|
||||
reload: true,
|
||||
}),
|
||||
this.store.query('chat-agent', { 'page[size]': 1 }),
|
||||
]);
|
||||
return { conversation, assistant: agents[0] };
|
||||
}
|
||||
}
|
||||
18
app/routes/conversations.js
Normal file
18
app/routes/conversations.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ConversationsRoute extends Route {
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
beforeModel(transition) {
|
||||
this.session.requireAuthentication(transition, 'login');
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.query('chat-conversation', {
|
||||
sort: '-last-activity',
|
||||
'page[size]': 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
app/routes/index.js
Normal file
13
app/routes/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class IndexRoute extends Route {
|
||||
@service router;
|
||||
@service session;
|
||||
|
||||
beforeModel(transition) {
|
||||
this.session.requireAuthentication(transition, 'login');
|
||||
this.router.transitionTo('conversations');
|
||||
super.beforeModel(...arguments);
|
||||
}
|
||||
}
|
||||
10
app/routes/login.js
Normal file
10
app/routes/login.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class LoginRoute extends Route {
|
||||
@service session;
|
||||
|
||||
beforeModel() {
|
||||
this.session.prohibitAuthentication('index');
|
||||
}
|
||||
}
|
||||
28
app/routes/mock-login.js
Normal file
28
app/routes/mock-login.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class MockLoginRoute extends Route {
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
page: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeModel() {
|
||||
this.session.prohibitAuthentication('index');
|
||||
}
|
||||
|
||||
model(params) {
|
||||
const filter = { provider: 'https://github.com/lblod/mock-login-service' };
|
||||
if (params.gemeente) filter.gebruiker = { achternaam: params.gemeente };
|
||||
return this.store.query('account', {
|
||||
include: 'gebruiker.bestuurseenheden',
|
||||
filter: filter,
|
||||
page: { size: 10, number: params.page },
|
||||
sort: 'gebruiker.achternaam',
|
||||
});
|
||||
}
|
||||
}
|
||||
62
app/serializers/application.js
Normal file
62
app/serializers/application.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { JSONAPISerializer } from '@warp-drive/legacy/serializer/json-api';
|
||||
|
||||
export default class ApplicationSerializer extends JSONAPISerializer {
|
||||
/**
|
||||
Parse the links in the JSONAPI response and convert to a meta-object.
|
||||
Inlined from the ember-data-table package.
|
||||
*/
|
||||
normalizeQueryResponse(store, clazz, payload) {
|
||||
const result = super.normalizeQueryResponse(...arguments);
|
||||
result.meta = result.meta || {};
|
||||
|
||||
if (payload.links) {
|
||||
result.meta.pagination = createPageMeta(payload.links);
|
||||
}
|
||||
if (payload.meta) {
|
||||
result.meta.count = payload.meta.count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Transforms link URLs to objects containing metadata
|
||||
E.g.
|
||||
{
|
||||
previous: '/streets?page[number]=1&page[size]=10&sort=name
|
||||
next: '/streets?page[number]=3&page[size]=10&sort=name
|
||||
}
|
||||
|
||||
will be converted to
|
||||
|
||||
{
|
||||
previous: { number: 1, size: 10 },
|
||||
next: { number: 3, size: 10 }
|
||||
}
|
||||
*/
|
||||
function createPageMeta(data) {
|
||||
let meta = {};
|
||||
|
||||
Object.keys(data).forEach((type) => {
|
||||
const link = data[type];
|
||||
meta[type] = {};
|
||||
|
||||
if (link) {
|
||||
//extracts from '/path?foo=bar&baz=foo' the string: foo=bar&baz=foo
|
||||
const query = link.split(/\?(.+)/)[1] || '';
|
||||
|
||||
query.split('&').forEach((pairs) => {
|
||||
const [param, value] = pairs.split('=');
|
||||
|
||||
if (decodeURIComponent(param) === 'page[number]') {
|
||||
meta[type].number = parseInt(value);
|
||||
} else if (decodeURIComponent(param) === 'page[size]') {
|
||||
meta[type].size = parseInt(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return meta;
|
||||
}
|
||||
23
app/services/current-session.js
Normal file
23
app/services/current-session.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class CurrentSessionService extends Service {
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
@tracked account;
|
||||
|
||||
get user() {
|
||||
return this.account?.belongsTo('gebruiker').value();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.session.isAuthenticated) {
|
||||
const accountId =
|
||||
this.session.data.authenticated.relationships.account.data.id;
|
||||
this.account = await this.store.findRecord('account', accountId, {
|
||||
include: 'gebruiker',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
83
app/services/session.js
Normal file
83
app/services/session.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { service } from '@ember/service';
|
||||
import BaseSessionService from 'ember-simple-auth/services/session';
|
||||
import ENV from 'frontend-lblod-chat/config/environment';
|
||||
|
||||
export default class SessionService extends BaseSessionService {
|
||||
@service currentSession;
|
||||
@service router;
|
||||
|
||||
get isAcmIdmSession() {
|
||||
if (!this.isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.data.authenticated.authenticator === 'authenticator:acm-idm';
|
||||
}
|
||||
|
||||
requireAuthentication(transition, routeOrCallback) {
|
||||
const isAuthenticated = super.requireAuthentication(
|
||||
transition,
|
||||
routeOrCallback,
|
||||
);
|
||||
|
||||
// The user isn't authenticated. We store the url in a cookie so we can retry it after logging in.
|
||||
if (!isAuthenticated) {
|
||||
// By default ESA only supports retrying transitions by looking at the `attemptedTransition` property.
|
||||
// However, this property isn't persisted, so after a page reload it is gone, which is an issue for our redirect-based ACM/IDM setup.
|
||||
// ESA also has a cookie-based redirect setup to support FastBoot. With a little bit of code we can hook into this system
|
||||
// so the redirect also survives a page reload.
|
||||
const COOKIE_NAME = 'ember_simple_auth-redirectTarget'; // This is the name that ESA uses: https://github.com/mainmatter/ember-simple-auth/blob/1404c501c2ba9c8c6c071b05f38d42a057522318/packages/ember-simple-auth/src/-internals/routing.js#L37
|
||||
const redirectUrl = routeInfoUrl(transition.to, this.router);
|
||||
document.cookie = `${COOKIE_NAME}=${redirectUrl};path=/;samesite=strict`;
|
||||
}
|
||||
|
||||
return isAuthenticated;
|
||||
}
|
||||
|
||||
handleAuthentication(routeAfterAuthentication) {
|
||||
super.handleAuthentication(routeAfterAuthentication);
|
||||
this.currentSession.load();
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
// We store the flag here since the data is cleared before the handleInvalidation method is called.
|
||||
this.wasAcmIdmSession = this.isAcmIdmSession;
|
||||
super.invalidate(...arguments);
|
||||
}
|
||||
|
||||
handleInvalidation() {
|
||||
let logoutUrl;
|
||||
if (this.wasAcmIdmSession) {
|
||||
logoutUrl = ENV.acmidm.logoutUrl;
|
||||
} else {
|
||||
logoutUrl = this.router.urlFor('mock-login');
|
||||
}
|
||||
|
||||
super.handleInvalidation(logoutUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// This converts a RouteInfo instance to the corresponding url by collecting all
|
||||
// the route params and query params and using the router.urlFor method.
|
||||
// This is a public API reimplementation of the `transition.intent.url` property.
|
||||
// More info: https://discord.com/channels/480462759797063690/624403666585124895/897832626616926218
|
||||
function routeInfoUrl(routeInfo, routerService) {
|
||||
let targetRoute = routeInfo.name;
|
||||
let allRouteParamValues = [];
|
||||
let allRouteQueryParams = {};
|
||||
|
||||
routeInfo.find((routeInfo) => {
|
||||
routeInfo.paramNames.forEach((paramName) => {
|
||||
let paramValue = routeInfo.params[paramName];
|
||||
allRouteParamValues.push(paramValue);
|
||||
allRouteQueryParams = {
|
||||
...allRouteQueryParams,
|
||||
...routeInfo.queryParams,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return routerService.urlFor(targetRoute, ...allRouteParamValues, {
|
||||
queryParams: allRouteQueryParams,
|
||||
});
|
||||
}
|
||||
1
app/session-stores/application.js
Normal file
1
app/session-stores/application.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'ember-simple-auth/session-stores/adaptive';
|
||||
132
app/styles/app.scss
Normal file
132
app/styles/app.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
/* ==================================
|
||||
# Chat
|
||||
================================== */
|
||||
|
||||
// SETTINGS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-settings";
|
||||
|
||||
// TOOLS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-tools";
|
||||
|
||||
// GENERIC
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-generic";
|
||||
|
||||
// ELEMENTS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-elements";
|
||||
|
||||
// OBJECTS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-objects";
|
||||
|
||||
// COMPONENTS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-components";
|
||||
|
||||
// PLUGINS
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-plugins";
|
||||
|
||||
// UTILITIES
|
||||
@import "@appuniversum/ember-appuniversum/styles/a-utilities";
|
||||
|
||||
// CHAT
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
.chat-window__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.chat-message {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-message--user {
|
||||
background: var(--au-blue-100);
|
||||
margin-left: 8rem;
|
||||
}
|
||||
.chat-message--assistant {
|
||||
background: var(--au-gray-100);
|
||||
margin-right: 8rem;
|
||||
}
|
||||
.chat-composer {
|
||||
border-top: 1px solid var(--au-gray-200);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Rendered markdown (assistant messages) */
|
||||
.chat-md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.chat-md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.chat-md p,
|
||||
.chat-md ul,
|
||||
.chat-md ol,
|
||||
.chat-md blockquote,
|
||||
.chat-md table {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.chat-md ul,
|
||||
.chat-md ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.chat-md code {
|
||||
background: var(--au-gray-200);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-md pre {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--au-gray-200);
|
||||
border-radius: 0.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.chat-md pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.chat-md table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.chat-md th,
|
||||
.chat-md td {
|
||||
border: 1px solid var(--au-gray-300);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.chat-md blockquote {
|
||||
border-left: 3px solid var(--au-gray-300);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--au-gray-500);
|
||||
}
|
||||
|
||||
/* The turtle spec, hidden behind a disclosure */
|
||||
.chat-md__spec {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.chat-md__spec summary {
|
||||
cursor: pointer;
|
||||
color: var(--au-gray-700);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-md__spec summary:hover {
|
||||
color: var(--au-gray-600);
|
||||
}
|
||||
/* the SPARQL translation, inside the same disclosure */
|
||||
.chat-md__spec-queries {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--au-gray-300, #ccc);
|
||||
}
|
||||
.chat-md__spec-queries .chat-md__queries-label {
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--au-gray-700);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
24
app/templates/application.hbs
Normal file
24
app/templates/application.hbs
Normal file
@@ -0,0 +1,24 @@
|
||||
{{page-title (app-name)}}
|
||||
|
||||
<AuApp>
|
||||
<AuMainHeader @homeRoute="conversations" @appTitle={{(app-name)}}>
|
||||
{{#if this.session.isAuthenticated}}
|
||||
<li class="au-c-list-horizontal__item">
|
||||
<AuButton
|
||||
@skin="link"
|
||||
@icon="logout"
|
||||
@iconAlignment="left"
|
||||
{{on "click" this.logout}}
|
||||
>
|
||||
Afmelden
|
||||
</AuButton>
|
||||
</li>
|
||||
{{/if}}
|
||||
</AuMainHeader>
|
||||
|
||||
<main id="main" class="au-c-main-container">
|
||||
{{outlet}}
|
||||
</main>
|
||||
</AuApp>
|
||||
|
||||
<AuModalContainer />
|
||||
6
app/templates/conversation.hbs
Normal file
6
app/templates/conversation.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
<AuBodyContainer>
|
||||
<Chat::Window
|
||||
@conversation={{@model.conversation}}
|
||||
@assistant={{@model.assistant}}
|
||||
/>
|
||||
</AuBodyContainer>
|
||||
22
app/templates/conversations.hbs
Normal file
22
app/templates/conversations.hbs
Normal file
@@ -0,0 +1,22 @@
|
||||
<AuBodyContainer @scroll={{true}}>
|
||||
<div class="au-o-region au-o-layout au-o-layout--small">
|
||||
<AuHeading @level="1" @skin="2">Gesprekken</AuHeading>
|
||||
<AuButton
|
||||
@loading={{this.create.isRunning}}
|
||||
{{on "click" (perform this.create)}}
|
||||
>
|
||||
Nieuw gesprek
|
||||
</AuButton>
|
||||
<AuList class="au-u-margin-top" as |Item|>
|
||||
{{#each @model as |conversation|}}
|
||||
<Item>
|
||||
<AuLink @route="conversation" @model={{conversation.id}}>
|
||||
{{if conversation.title conversation.title "Nieuw gesprek"}}
|
||||
</AuLink>
|
||||
</Item>
|
||||
{{else}}
|
||||
<p class="au-u-muted">Nog geen gesprekken.</p>
|
||||
{{/each}}
|
||||
</AuList>
|
||||
</div>
|
||||
</AuBodyContainer>
|
||||
1
app/templates/index.hbs
Normal file
1
app/templates/index.hbs
Normal file
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
7
app/templates/login.hbs
Normal file
7
app/templates/login.hbs
Normal file
@@ -0,0 +1,7 @@
|
||||
<AuBodyContainer @scroll={{true}}>
|
||||
<div class="au-o-region-large au-o-layout au-o-layout--small">
|
||||
<AuHeading @level="1" @skin="1">{{(app-name)}}</AuHeading>
|
||||
<p class="au-u-margin-bottom">Meld je aan om een gesprek te beginnen.</p>
|
||||
<AuLink @route="mock-login" @skin="button">Meld je aan</AuLink>
|
||||
</div>
|
||||
</AuBodyContainer>
|
||||
56
app/templates/mock-login.hbs
Normal file
56
app/templates/mock-login.hbs
Normal file
@@ -0,0 +1,56 @@
|
||||
<AuBodyContainer @scroll={{true}} itemprop="mainContentOfPage">
|
||||
<div id="content">
|
||||
<div class="au-o-region-large">
|
||||
<div class="au-o-layout">
|
||||
<AuHeading @skin="4">Kies een bestuurseenheid om mee in te loggen.</AuHeading>
|
||||
<p class="au-u-margin-top-small au-u-margin-bottom">
|
||||
<AuLabel for="bestuurseenheid-searchbox">Bestuurseenheid zoeken</AuLabel>
|
||||
<input
|
||||
id="bestuurseenheid-searchbox"
|
||||
class="au-c-input au-c-input--block"
|
||||
placeholder="Aalst, Berchem, …"
|
||||
value={{this.gemeente}}
|
||||
{{on "input" (perform this.updateSearch value="target.value")}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<MockLogin as |login|>
|
||||
{{#if this.queryStore.isRunning}}
|
||||
<AuLoader @padding="small" />
|
||||
{{else}}
|
||||
{{#if login.errorMessage}}
|
||||
<AuAlert
|
||||
@icon="alert-triangle"
|
||||
@title={{login.errorMessage}}
|
||||
@skin="warning"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#each this.model as |account|}}
|
||||
<AuButton
|
||||
class="au-u-flex au-u-flex--row au-u-flex--start au-c-card au-u-margin-bottom-small"
|
||||
@skin="secondary"
|
||||
@width="block"
|
||||
@size="large"
|
||||
type="button"
|
||||
{{on
|
||||
"click"
|
||||
(fn login.login account.id account.gebruiker.group.id)
|
||||
}}
|
||||
>
|
||||
{{account.gebruiker.voornaam}}
|
||||
{{account.gebruiker.achternaam}}
|
||||
</AuButton>
|
||||
{{/each}}
|
||||
<AuDataTableNumberPagination
|
||||
@page={{this.page}}
|
||||
@size={{this.size}}
|
||||
@nbOfItems={{this.model.length}}
|
||||
@total={{this.model.meta.count}}
|
||||
@links={{this.model.meta.pagination}}
|
||||
/>
|
||||
{{/if}}
|
||||
</MockLogin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuBodyContainer>
|
||||
1
app/templates/route-not-found.hbs
Normal file
1
app/templates/route-not-found.hbs
Normal file
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
4
app/utils/ceilings.js
Normal file
4
app/utils/ceilings.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// The two ceilings, in one place. The window stops polling past them; the
|
||||
// file card shows its warning past its own.
|
||||
export const ANSWER_CEILING_MS = 10 * 60 * 1000;
|
||||
export const FILE_CEILING_MS = 30 * 60 * 1000;
|
||||
76
app/utils/markdown.js
Normal file
76
app/utils/markdown.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Assistant messages are markdown. markdown-it renders, DOMPurify strips
|
||||
// what should not reach the DOM (html: false already drops raw html in the
|
||||
// source; sanitize is the second layer), and every link it leaves opens in
|
||||
// a new tab.
|
||||
//
|
||||
// The spec is hidden behind a "Technische details" disclosure (task 10): a
|
||||
// ```turtle (or any other language-tagged) block becomes
|
||||
// <details class="chat-md__spec"> instead of a plain code block.
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
// Every link opens in a new tab (task 10 subtask 1). DOMPurify drops a
|
||||
// target it did not set, so the attribute is added on sanitized output in
|
||||
// the hook; rel protects any opened page from switching this tab's
|
||||
// location (window.opener attacks).
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
const SPEC_LABEL = 'Technische details';
|
||||
const QUERIES_LABEL = 'De SPARQL-query\'s die het rapport ophaalt';
|
||||
const QUERIES_CLASS = 'chat-md__spec-queries';
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
|
||||
|
||||
// One renderer for both fenced and indented code: the class hooks the spec
|
||||
// styles and keeps every block consistent. Indented blocks have no language,
|
||||
// and an indented spec is not shown as technical details.
|
||||
md.renderer.rules.fence = (tokens, idx) => {
|
||||
const token = tokens[idx];
|
||||
const lang = token.info.trim();
|
||||
if (!lang) return codeBlock(token.content);
|
||||
if (lang === 'sparql') return queriesBlock(token.content);
|
||||
return specBlock(token.content);
|
||||
};
|
||||
md.renderer.rules.code_block = (tokens, idx) => codeBlock(tokens[idx].content);
|
||||
|
||||
function codeBlock(content) {
|
||||
return `<pre><code class="chat-md__code">${md.utils.escapeHtml(
|
||||
content,
|
||||
)}</code></pre>\n`;
|
||||
}
|
||||
|
||||
function specBlock(content) {
|
||||
return `<details class="chat-md__spec"><summary>${SPEC_LABEL}</summary>` +
|
||||
`<pre><code class="chat-md__code">${md.utils.escapeHtml(
|
||||
content,
|
||||
)}</code></pre></details>\n`;
|
||||
}
|
||||
|
||||
// The SPARQL translation of the spec (task 10 subtask 4). Rendered first as
|
||||
// a marker div after the details; renderMarkdown() then moves it inside the
|
||||
// preceding details block, so spec and queries stay in one disclosure.
|
||||
function queriesBlock(content) {
|
||||
return `<div class="${QUERIES_CLASS}"><p class="chat-md__queries-label">${QUERIES_LABEL}</p>` +
|
||||
`<pre><code class="chat-md__code">${md.utils.escapeHtml(
|
||||
content,
|
||||
)}</code></pre></div>\n`;
|
||||
}
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
let html = md.render(String(text ?? ''));
|
||||
// A ```sparql block that follows the spec disclosure joins it: the div is
|
||||
// moved inside <details>, before its closing tag. html: false means the
|
||||
// only details in the output are the ones this renderer emits, so the
|
||||
// match is unambiguous.
|
||||
html = html.replace(
|
||||
/<\/details>\s*(<div class="chat-md__spec-queries">[\s\S]*?<\/div>)/,
|
||||
'$1</details>\n',
|
||||
);
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
13
app/utils/message-types.js
Normal file
13
app/utils/message-types.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// The type is the ember-data model name, which is the SIOC class, which is
|
||||
// rdf:type. No field exists whose only job is to say which component to draw.
|
||||
// The assistant's messages are markdown; the user's own stay plain.
|
||||
import InstantMessage from 'frontend-lblod-chat/components/chat/types/instant-message';
|
||||
import AssistantMessage from 'frontend-lblod-chat/components/chat/types/assistant-message';
|
||||
|
||||
const TYPES = {
|
||||
'chat-instant-message': InstantMessage,
|
||||
};
|
||||
|
||||
export function componentFor(modelName, isAssistant) {
|
||||
return isAssistant ? AssistantMessage : (TYPES[modelName] ?? InstantMessage);
|
||||
}
|
||||
7
app/utils/setting.js
Normal file
7
app/utils/setting.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// A {{PLACEHOLDER}} that static-file-service did not fill in still starts
|
||||
// with "{{". In development nothing fills them in.
|
||||
export function setting(value, fallback) {
|
||||
return typeof value === 'string' && value.trim() && !value.startsWith('{{')
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
46
babel.config.mjs
Normal file
46
babel.config.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
babelCompatSupport,
|
||||
templateCompatSupport,
|
||||
} from '@embroider/compat/babel';
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
[
|
||||
'babel-plugin-ember-template-compilation',
|
||||
{
|
||||
enableLegacyModules: [
|
||||
'ember-cli-htmlbars',
|
||||
'ember-cli-htmlbars-inline-precompile',
|
||||
'htmlbars-inline-precompile',
|
||||
],
|
||||
transforms: [...templateCompatSupport()],
|
||||
},
|
||||
],
|
||||
[
|
||||
'module:decorator-transforms',
|
||||
{
|
||||
runtime: {
|
||||
import: fileURLToPath(
|
||||
import.meta.resolve('decorator-transforms/runtime-esm'),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'@babel/plugin-transform-runtime',
|
||||
{
|
||||
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
||||
useESModules: true,
|
||||
regenerator: false,
|
||||
},
|
||||
],
|
||||
'ember-concurrency/async-arrow-task-transform',
|
||||
...babelCompatSupport(),
|
||||
],
|
||||
|
||||
generatorOpts: {
|
||||
compact: false,
|
||||
},
|
||||
};
|
||||
60
config/environment.js
Normal file
60
config/environment.js
Normal file
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = function (environment) {
|
||||
const ENV = {
|
||||
modulePrefix: 'frontend-lblod-chat',
|
||||
environment,
|
||||
rootURL: '/',
|
||||
locationType: 'history',
|
||||
EmberENV: {
|
||||
EXTEND_PROTOTYPES: false,
|
||||
FEATURES: {
|
||||
// Here you can enable experimental features on an ember canary build
|
||||
// e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
|
||||
},
|
||||
},
|
||||
|
||||
APP: {
|
||||
// Here you can pass flags/options to your application instance
|
||||
// when it is created
|
||||
},
|
||||
// {{PLACEHOLDER}} strings, filled in at container start by the
|
||||
// static-file-service from EMBER_* variables. Unfilled ones still start
|
||||
// with "{{"; app/utils/setting.js falls back to the default then.
|
||||
appName: '{{APP_NAME}}',
|
||||
assistantPath: '{{ASSISTANT_PATH}}',
|
||||
acmidm: {
|
||||
clientId: '{{ACMIDM_CLIENT_ID}}',
|
||||
baseUrl: '{{ACMIDM_BASE_URL}}',
|
||||
scope: 'openid vo profile abb_loketLB',
|
||||
redirectUrl: '{{ACMIDM_REDIRECT_URL}}',
|
||||
logoutUrl: '{{ACMIDM_LOGOUT_URL}}',
|
||||
},
|
||||
};
|
||||
|
||||
if (environment === 'development') {
|
||||
// ENV.APP.LOG_RESOLVER = true;
|
||||
// ENV.APP.LOG_ACTIVE_GENERATION = true;
|
||||
// ENV.APP.LOG_TRANSITIONS = true;
|
||||
// ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
|
||||
// ENV.APP.LOG_VIEW_LOOKUPS = true;
|
||||
}
|
||||
|
||||
if (environment === 'test') {
|
||||
// Testem prefers this...
|
||||
ENV.locationType = 'none';
|
||||
|
||||
// keep test console output quieter
|
||||
ENV.APP.LOG_ACTIVE_GENERATION = false;
|
||||
ENV.APP.LOG_VIEW_LOOKUPS = false;
|
||||
|
||||
ENV.APP.rootElement = '#ember-testing';
|
||||
ENV.APP.autoboot = false;
|
||||
}
|
||||
|
||||
if (environment === 'production') {
|
||||
// here you can do production-specific overrides
|
||||
}
|
||||
|
||||
return ENV;
|
||||
};
|
||||
26
ember-cli-build.mjs
Normal file
26
ember-cli-build.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
// import { dirname } from 'node:path';
|
||||
// import { fileURLToPath } from 'node:url';
|
||||
import EmberApp from 'ember-cli/lib/broccoli/ember-app.js';
|
||||
import { compatBuild } from '@embroider/compat';
|
||||
|
||||
export default async function (defaults) {
|
||||
// const { setConfig } = await import('@warp-drive/core/build-config');
|
||||
const { buildOnce } = await import('@embroider/vite');
|
||||
|
||||
const app = new EmberApp(defaults, {
|
||||
// Add options here
|
||||
});
|
||||
|
||||
// TODO: uncomment once we have started resolving WarpDrive deprecations
|
||||
// setConfig(app, dirname(fileURLToPath(import.meta.url)), {
|
||||
// // this should be the most recent <major>.<minor> version for
|
||||
// // which all deprecations have been fully resolved
|
||||
// // and should be updated when that changes
|
||||
// compatWith: '5.8',
|
||||
// deprecations: {
|
||||
// // ... list individual deprecations that have been resolved here
|
||||
// },
|
||||
// });
|
||||
|
||||
return compatBuild(app, buildOnce);
|
||||
}
|
||||
110
eslint.config.mjs
Normal file
110
eslint.config.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Debugging:
|
||||
* https://eslint.org/docs/latest/use/configure/debug
|
||||
* ----------------------------------------------------
|
||||
*
|
||||
* Print a file's calculated configuration
|
||||
*
|
||||
* npx eslint --print-config path/to/file.js
|
||||
*
|
||||
* Inspecting the config
|
||||
*
|
||||
* npx eslint --inspect-config
|
||||
*
|
||||
*/
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
import ember from 'eslint-plugin-ember/recommended';
|
||||
import WarpDrive from 'eslint-plugin-warp-drive/recommended';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import qunit from 'eslint-plugin-qunit';
|
||||
import n from 'eslint-plugin-n';
|
||||
|
||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
||||
|
||||
const esmParserOptions = {
|
||||
ecmaFeatures: { modules: true },
|
||||
ecmaVersion: 'latest',
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist/', 'coverage/', '!**/.*']),
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
ember.configs.base,
|
||||
ember.configs.gjs,
|
||||
...WarpDrive,
|
||||
/**
|
||||
* https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options
|
||||
*/
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
parser: babelParser,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,gjs}'],
|
||||
languageOptions: {
|
||||
parserOptions: esmParserOptions,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'warp-drive/no-external-request-patterns': 'off', // Not sure if we want to replace _all_ regular fetch calls..
|
||||
'warp-drive/no-legacy-request-patterns': 'off', // TODO: remove this once we refactored all the code to the new patterns
|
||||
},
|
||||
},
|
||||
{
|
||||
...qunit.configs.recommended,
|
||||
files: ['tests/**/*-test.{js,gjs}'],
|
||||
plugins: {
|
||||
qunit,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* CJS node files
|
||||
*/
|
||||
{
|
||||
...n.configs['flat/recommended-script'],
|
||||
files: ['**/*.cjs', 'config/**/*.js'],
|
||||
plugins: {
|
||||
n,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
sourceType: 'script',
|
||||
ecmaVersion: 'latest',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
/**
|
||||
* ESM node files
|
||||
*/
|
||||
{
|
||||
...n.configs['flat/recommended-module'],
|
||||
files: ['**/*.mjs'],
|
||||
plugins: {
|
||||
n,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
parserOptions: esmParserOptions,
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
30
index.html
Normal file
30
index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Assistent</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{content-for "head"}}
|
||||
|
||||
<link integrity="" rel="stylesheet" href="/@embroider/virtual/vendor.css">
|
||||
<link rel="icon" href="/assets/images/favicon.svg">
|
||||
|
||||
{{content-for "head-footer"}}
|
||||
</head>
|
||||
<body>
|
||||
{{content-for "body"}}
|
||||
|
||||
<script src="/@embroider/virtual/vendor.js"></script>
|
||||
<script type="module">
|
||||
import Application from './app/app';
|
||||
import environment from './app/config/environment';
|
||||
|
||||
Application.create(environment.APP);
|
||||
</script>
|
||||
|
||||
{{content-for "body-footer"}}
|
||||
</body>
|
||||
</html>
|
||||
6
jsconfig.json
Normal file
6
jsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
||||
87
lib/vite-plugin-api-proxy.mjs
Normal file
87
lib/vite-plugin-api-proxy.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
export default function apiProxy(options = {}) {
|
||||
const { envKey = 'PROXY' } = options;
|
||||
|
||||
let { target } = options;
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-api-proxy',
|
||||
config(config, { mode }) {
|
||||
if (!target) {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
target = env[envKey];
|
||||
}
|
||||
|
||||
if (target) {
|
||||
console.info(`[api-proxy] Proxy to ${target} enabled.`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[api-proxy] No target found for ${envKey}. Proxy disabled.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
server: {
|
||||
proxy: {
|
||||
'^/.*': {
|
||||
target,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass: (request) => {
|
||||
return shouldProxy(request) ? null : request.url;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProxy(request) {
|
||||
if (
|
||||
request.headers.upgrade === 'websocket' &&
|
||||
request.headers['sec-websocket-protocol'] === 'vite-hmr'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We assume all non-GET requests are API requests, which need to be proxied
|
||||
if (request.method !== 'GET') return true;
|
||||
|
||||
// Known local paths that match most Ember + Vite apps
|
||||
const knownLocalPaths = [
|
||||
/^\/(\?.*)?$/,
|
||||
/^\/index\.html/,
|
||||
/^\/@vite/,
|
||||
/^\/vite/,
|
||||
/\/@-?embroider/,
|
||||
/^\/app\//,
|
||||
/^\/assets/,
|
||||
/^\/node_modules\//,
|
||||
/^\/vendor\//,
|
||||
];
|
||||
|
||||
if (knownLocalPaths.some((path) => path.test(request.url))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLocalAsset =
|
||||
/\.(js|ts|hbs|css|less|sass|scss|png|jpg|jpeg|gif|svg|ico|woff|woff2|json|map|webmanifest)$/i.test(
|
||||
request.url,
|
||||
);
|
||||
|
||||
if (isLocalAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accept = request.headers.accept || '';
|
||||
if (accept.includes('text/html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Everything else is assumed to be an API call
|
||||
return true;
|
||||
}
|
||||
24604
package-lock.json
generated
Normal file
24604
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
package.json
Normal file
110
package.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "frontend-lblod-chat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Chat frontend for the natural language report assistant",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/lblod/frontend-lblod-chat.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "info@redpencil.io",
|
||||
"exports": {
|
||||
"./tests/*": "./tests/*",
|
||||
"./*": "./app/*"
|
||||
},
|
||||
"directories": {
|
||||
"doc": "doc",
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"format": "prettier . --cache --write",
|
||||
"lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||
"lint:css": "stylelint \"**/*.css\" --allow-empty-input",
|
||||
"lint:css:fix": "concurrently \"npm:lint:css -- --fix\"",
|
||||
"lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\" --prefixColors auto && npm run format",
|
||||
"lint:format": "prettier . --cache --check",
|
||||
"lint:hbs": "ember-template-lint .",
|
||||
"lint:hbs:fix": "ember-template-lint . --fix",
|
||||
"lint:js": "eslint . --cache",
|
||||
"lint:js:fix": "eslint . --fix",
|
||||
"lint:deps": "ember dependency-lint",
|
||||
"start": "vite",
|
||||
"test": "vite build --mode development && ember test --path dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@appuniversum/ember-appuniversum": "^4.3.1",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@ember/optional-features": "^3.0.0",
|
||||
"@ember/render-modifiers": "^4.0.0",
|
||||
"@ember/string": "^4.0.1",
|
||||
"@ember/test-helpers": "^5.4.1",
|
||||
"@ember/test-waiters": "^4.1.1",
|
||||
"@embroider/compat": "^4.1.17",
|
||||
"@embroider/config-meta-loader": "^1.0.0",
|
||||
"@embroider/core": "^4.4.7",
|
||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||
"@embroider/macros": "^1.20.2",
|
||||
"@embroider/router": "^3.0.6",
|
||||
"@embroider/vite": "^1.7.2",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@lblod/ember-acmidm-login": "^2.3.0",
|
||||
"@lblod/ember-mock-login": "^0.14.0",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.1",
|
||||
"@warp-drive/ember": "~5.8.1",
|
||||
"@warp-drive/json-api": "~5.8.1",
|
||||
"@warp-drive/legacy": "~5.8.1",
|
||||
"@warp-drive/utilities": "~5.8.1",
|
||||
"babel-plugin-ember-template-compilation": "^3.1.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"decorator-transforms": "^2.3.1",
|
||||
"ember-cli": "~6.12.0",
|
||||
"ember-cli-babel": "^8.3.1",
|
||||
"ember-cli-dependency-lint": "^2.0.1",
|
||||
"ember-cli-deprecation-workflow": "^3.4.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-data": "~5.8.2",
|
||||
"ember-load-initializers": "^3.0.1",
|
||||
"ember-modifier": "^4.3.0",
|
||||
"ember-page-title": "^9.0.3",
|
||||
"ember-qunit": "^9.0.4",
|
||||
"ember-resolver": "^13.2.0",
|
||||
"ember-simple-auth": "^8.3.0",
|
||||
"ember-source": "~6.12.0",
|
||||
"ember-template-lint": "^7.9.3",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-ember": "^12.7.5",
|
||||
"eslint-plugin-n": "^17.24.0",
|
||||
"eslint-plugin-qunit": "^8.2.6",
|
||||
"eslint-plugin-warp-drive": "^5.8.1",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.3",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"release-it": "^15.4.1",
|
||||
"sass": "^1.48.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"testem": "^3.19.1",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.15",
|
||||
"markdown-it": "^15.0.2"
|
||||
}
|
||||
}
|
||||
6
public/assets/images/favicon.svg
Normal file
6
public/assets/images/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#FFE500" points="0 .391 99.398 .391 161.529 180.391 0 180.391"/>
|
||||
<path fill="#333332" fill-rule="nonzero" d="M99.3982111,89.0926718 C95.4094557,86.0641724 93.5258768,89.0926718 91.0144383,89.0926718 C88.5029997,89.0926718 86.8779512,85.3993798 85.2529028,86.3227028 C82.1136046,87.9108183 86.508622,93.7092868 88.4291339,94.7064756 C89.8424913,95.5089699 91.2972085,96.2363285 92.7872184,96.8855179 C95.0239484,97.9164265 96.4632281,100.14731 96.4805104,102.61012 C96.5963802,103.529808 96.5963802,104.460402 96.4805104,105.380089 C95.3355899,110.255235 86.7302196,114.687185 81.7073425,111.32629 C79.0146086,109.708417 77.1352081,107.023559 76.5367337,103.939706 C75.2071486,97.5503104 70.5536007,92.8598296 69.1501497,86.5812332 C68.2268267,82.8879412 67.5620341,78.7514542 66.5648453,74.9473635 C65.5676565,71.1432727 64.5335347,66.9329199 63.6840776,63.2396279 C62.7505298,59.383155 61.5913026,55.5848362 60.2123831,51.8642886 C55.4480364,39.6394921 53.34286,40.5258822 53.34286,40.5258822 C53.34286,40.5258822 55.0417743,43.9237108 61.2834378,73.76551 C62.3235811,80.177615 63.7168401,86.5274234 65.4568577,92.7859638 C66.0477844,94.3371464 67.0819062,97.6611092 67.7466987,99.138426 C69.7410764,103.422645 75.1332827,109.922839 75.3179473,115.795173 C75.5026119,119.193001 75.908874,121.999903 75.9827399,124.548275 C76.1110379,126.29633 76.4078795,128.027906 76.86913,129.718884 C78.1617822,133.412176 88.4660668,144.824448 99.3982111,144.824448 L99.3982111,135.923614 C91.776862,135.756786 84.3601993,133.425469 78.0140505,129.201823 C78.1564559,127.059782 78.5913762,124.947312 79.3067027,122.923226 C80.5375765,118.975189 84.0168987,116.15097 88.1336705,115.75824 C91.852584,115.82768 95.559014,116.210678 99.2135465,116.90316 L99.2135465,89.0926718 L99.3982111,89.0926718 Z M52.1610066,57.182629 C51.7547444,63.7197558 41.5612586,72.6944554 38.3850275,78.4190579 C36.5817907,81.7203936 35.2279073,85.2479431 34.3593392,88.9080072 C33.2582474,94.7541647 34.0608193,100.799344 36.6491802,106.155681 C40.0100759,114.207057 36.2059852,117.235557 38.8282225,115.536642 C42.0783194,112.877472 41.5981915,106.709675 41.339661,102.868651 C41.0074814,99.4049023 41.0074814,95.917316 41.339661,92.4535675 C42.8078022,85.312206 45.8483886,78.5875897 50.2404947,72.7683212 C53.2505575,68.1474786 53.9592745,62.3959674 52.1610066,57.182629 M54.2292501,75.649089 C54.2292501,75.649089 55.3003048,80.8196977 50.5359581,94.1155489 C38.4958262,129.017158 61.6158341,132.304188 68.0421621,140.503296 C68.0421621,140.503296 70.5905336,136.810004 60.1385173,125.730128 C56.4452253,121.593641 52.7519333,112.434277 55.8912315,99.3230906 C60.2123831,80.3765027 54.0445855,75.7229548 54.0445855,75.7229548 M31.4416385,52.8984103 C30.8740347,51.1083773 30.6118401,49.2355589 30.6660472,47.3584723 C31.4047056,39.1593641 38.9020883,35.8354013 40.7487343,34.616615 C42.4593349,33.5033039 43.7871245,31.8919087 44.5528251,30 C46.5589468,35.0036575 44.6726094,40.7250279 40.0839418,43.5543816 C36.359584,45.7670344 33.3574004,49.0129851 31.4416385,52.8984103 M50.3143606,44.9578325 C50.7206227,45.8072897 54.0076526,50.3131059 41.1549964,60.7281893 C28.3023403,71.1432727 32.4388274,78.6037225 32.4388274,78.6037225 C32.4388274,78.6037225 18.9213787,71.2171386 34.5070709,58.1798179 C50.0927631,45.1424971 46.879599,42.6679915 46.879599,42.6679915 C48.3780167,42.7144807 49.7154261,43.6204677 50.3143606,44.9947655 M66.7864428,48.9835208 C68.9654851,49.2789842 70.4797348,56.0746414 75.465679,57.5519582 C79.158971,58.6230129 83.1477263,58.0320862 84.0341164,60.2849943 C83.387359,60.8172796 83.1601821,61.7073389 83.4727257,62.4844743 C83.7852692,63.2616096 84.5654323,63.746543 85.4006344,63.6828229 C86.8410183,59.5463359 87.5427438,44.3299729 66.8233758,48.9835208 L66.7864428,48.9835208 Z M73.6559659,52.1597519 C73.6559659,51.679624 74.099161,52.1597519 74.6531548,51.7165569 C75.2325109,50.9649497 75.9784364,50.3580951 76.832197,49.9437767 C77.5762017,49.7011844 78.3780334,49.7011844 79.1220381,49.9437767 C79.5652331,49.9437767 79.1220381,51.1625631 79.1220381,51.4210935 C79.1220381,51.679624 76.389002,51.0886972 76.389002,52.602947 C76.389002,55.0774526 79.7498977,52.602947 81.4857449,52.602947 C82.3352021,58.6599458 72.3263808,56.9979644 73.8036976,52.0120202 L73.8036976,52.0120202 L73.6559659,52.1597519 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
25
testem.cjs
Normal file
25
testem.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
module.exports = {
|
||||
test_page: 'tests/index.html?hidepassed',
|
||||
disable_watching: true,
|
||||
launch_in_ci: ['Chrome'],
|
||||
launch_in_dev: ['Chrome'],
|
||||
browser_start_timeout: 120,
|
||||
browser_args: {
|
||||
Chrome: {
|
||||
ci: [
|
||||
// --no-sandbox is needed when running Chrome inside a container
|
||||
process.env.CI ? '--no-sandbox' : null,
|
||||
'--headless',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-software-rasterizer',
|
||||
'--mute-audio',
|
||||
'--remote-debugging-port=0',
|
||||
'--window-size=1440,900',
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
42
tests/helpers/index.js
Normal file
42
tests/helpers/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
setupApplicationTest as upstreamSetupApplicationTest,
|
||||
setupRenderingTest as upstreamSetupRenderingTest,
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
|
||||
// This file exists to provide wrappers around ember-qunit's
|
||||
// test setup functions. This way, you can easily extend the setup that is
|
||||
// needed per test type.
|
||||
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
// For example, if you need an authenticated session for each
|
||||
// application test, you could do:
|
||||
//
|
||||
// hooks.beforeEach(async function () {
|
||||
// await authenticateSession(); // ember-simple-auth
|
||||
// });
|
||||
//
|
||||
// This is also a good place to call test setup functions coming
|
||||
// from other addons:
|
||||
//
|
||||
// setupIntl(hooks, 'en-us'); // ember-intl
|
||||
// setupMirage(hooks); // ember-cli-mirage
|
||||
}
|
||||
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
export { setupApplicationTest, setupRenderingTest, setupTest };
|
||||
43
tests/index.html
Normal file
43
tests/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend Worship Decisions Tests</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{content-for "head"}}
|
||||
{{content-for "test-head"}}
|
||||
|
||||
<link rel="stylesheet" href="/@embroider/virtual/vendor.css">
|
||||
<link rel="stylesheet" href="/@embroider/virtual/app.css">
|
||||
<link rel="stylesheet" href="/@embroider/virtual/test-support.css">
|
||||
|
||||
{{content-for "head-footer"}}
|
||||
{{content-for "test-head-footer"}}
|
||||
</head>
|
||||
<body>
|
||||
{{content-for "body"}}
|
||||
{{content-for "test-body"}}
|
||||
|
||||
<div id="qunit"></div>
|
||||
<div id="qunit-fixture">
|
||||
<div id="ember-testing-container">
|
||||
<div id="ember-testing"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/testem.js" integrity="" data-embroider-ignore></script>
|
||||
<script src="/@embroider/virtual/vendor.js"></script>
|
||||
<script src="/@embroider/virtual/test-support.js"></script>
|
||||
<script type="module">import "ember-testing";</script>
|
||||
|
||||
<script type="module">
|
||||
import { start } from './test-helper';
|
||||
import.meta.glob("./**/*.{js,ts,gjs,gts}", { eager: true });
|
||||
start();
|
||||
</script>
|
||||
|
||||
{{content-for "body-footer"}}
|
||||
</body>
|
||||
</html>
|
||||
16
tests/test-helper.js
Normal file
16
tests/test-helper.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import '@warp-drive/ember/install';
|
||||
import Application from 'frontend-lblod-chat/app';
|
||||
import config from 'frontend-lblod-chat/config/environment';
|
||||
import * as QUnit from 'qunit';
|
||||
import { setApplication } from '@ember/test-helpers';
|
||||
import { setup } from 'qunit-dom';
|
||||
import { start as qunitStart, setupEmberOnerrorValidation } from 'ember-qunit';
|
||||
|
||||
export function start() {
|
||||
setApplication(Application.create(config.APP));
|
||||
|
||||
setup(QUnit.assert);
|
||||
setupEmberOnerrorValidation();
|
||||
|
||||
qunitStart();
|
||||
}
|
||||
17
vite.config.mjs
Normal file
17
vite.config.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { extensions, classicEmberSupport, ember } from '@embroider/vite';
|
||||
import { babel } from '@rollup/plugin-babel';
|
||||
import apiProxy from './lib/vite-plugin-api-proxy.mjs';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
classicEmberSupport(),
|
||||
ember(),
|
||||
// extra plugins here
|
||||
apiProxy(),
|
||||
babel({
|
||||
babelHelpers: 'runtime',
|
||||
extensions,
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user