@jskit-ai/assistant-core 0.1.171 → 0.1.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/conversation/App.vue +62 -10
- package/examples/conversation/README.md +29 -4
- package/examples/conversation/attachments.js +45 -0
- package/examples/conversation/integrations.json +28 -0
- package/examples/conversation/package.json +6 -4
- package/examples/conversation/server.js +61 -10
- package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
- package/fixtures/responsive-assistant/ProgressFixture.vue +61 -0
- package/fixtures/responsive-assistant/main.js +3 -1
- package/package.json +21 -7
- package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
- package/src/client/conversation/AssistantComposerSupport.vue +235 -0
- package/src/client/conversation/AssistantConversationElement.vue +103 -9
- package/src/client/conversation/AssistantGoalControl.vue +81 -0
- package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
- package/src/client/conversation/AssistantModelControl.vue +334 -0
- package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
- package/src/client/conversation/AssistantTranscript.vue +139 -174
- package/src/client/conversation/index.js +9 -0
- package/src/client/conversation/useAssistantAttachments.js +991 -0
- package/src/client/conversation/useAssistantSuggestions.js +99 -0
- package/src/server/index.js +1 -0
- package/src/server/lib/aiConnectionClient.js +90 -0
- package/src/server/lib/providers/anthropicClient.js +6 -21
- package/src/shared/assistantResource.js +7 -0
- package/src/shared/conversation/attachments.js +13 -0
- package/src/shared/conversation/index.js +1 -0
- package/test/aiConnectionClient.test.js +159 -0
- package/test/assistantScroll.browser.test.js +217 -0
- package/test/suggestions.test.js +114 -0
|
@@ -6,17 +6,52 @@
|
|
|
6
6
|
:adapter="adapter" :configuration="configuration"
|
|
7
7
|
:configuration-mode="configurationMode" :configuration-fields="fields"
|
|
8
8
|
>
|
|
9
|
-
<template #
|
|
9
|
+
<template #attachments="{ items }">
|
|
10
|
+
<AssistantMessageAttachments :attachments="items || []" preview-enabled @preview="selectedAttachment = $event" />
|
|
11
|
+
</template>
|
|
12
|
+
<template #composer-feedback><p v-if="error" role="alert">{{ error }}</p></template>
|
|
10
13
|
</AssistantConversationElement>
|
|
14
|
+
<AssistantAttachmentPreview
|
|
15
|
+
v-if="selectedAttachment" :attachment="selectedAttachment" :download-url="attachmentUrl"
|
|
16
|
+
:preview-url="/\.(png|jpe?g|gif|webp)$/i.test(selectedAttachment.fileName) ? attachmentUrl : ''" @close="selectedAttachment = null"
|
|
17
|
+
/>
|
|
11
18
|
</main>
|
|
12
19
|
</v-app>
|
|
13
20
|
</template>
|
|
14
21
|
<script setup>
|
|
15
22
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
|
16
|
-
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
23
|
+
import { AssistantConversationElement, AssistantAttachmentPreview, AssistantMessageAttachments, useAssistantAttachments, useAssistantSuggestions } from "@jskit-ai/assistant-core/client/conversation";
|
|
17
24
|
|
|
18
25
|
const turns = ref([]), draft = ref(""), loading = ref(true), pending = ref(false), error = ref("");
|
|
26
|
+
const accepted = ref(false);
|
|
19
27
|
const providerLabel = ref(""), configurationMode = ref("hidden"), configuration = ref({ tone: "concise" });
|
|
28
|
+
const modelChoices = ref([]), integrationId = ref(""), draftIntegrationId = ref("");
|
|
29
|
+
const selectedAttachment = ref(null);
|
|
30
|
+
const attachmentUrl = computed(() => selectedAttachment.value ? `/api/attachments/${encodeURIComponent(selectedAttachment.value.attachmentId)}` : "");
|
|
31
|
+
async function requestJson(url, options) {
|
|
32
|
+
const response = await fetch(url, options);
|
|
33
|
+
const result = await response.json();
|
|
34
|
+
if (!response.ok) throw new Error(result.error || "Request failed.");
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const attachments = reactive(useAssistantAttachments({
|
|
38
|
+
sessionId: "example", maxBytes: 2_000_000,
|
|
39
|
+
uploadAttachment: (_scope, file, { signal }) => requestJson("/api/attachments", {
|
|
40
|
+
method: "POST", body: file, signal,
|
|
41
|
+
headers: { "Content-Type": "application/octet-stream", "X-File-Name": encodeURIComponent(file.name), "X-File-Type": file.type || "text/plain" }
|
|
42
|
+
}),
|
|
43
|
+
deleteAttachment: (_scope, id) => requestJson(`/api/attachments/${encodeURIComponent(id)}`, { method: "DELETE" })
|
|
44
|
+
}));
|
|
45
|
+
attachments.open = file => { selectedAttachment.value = file; };
|
|
46
|
+
const suggestions = reactive(useAssistantSuggestions({
|
|
47
|
+
active: () => !loading.value && !pending.value,
|
|
48
|
+
requestKey: () => turns.value.at(-1)?.turnId || "new",
|
|
49
|
+
draft,
|
|
50
|
+
generate: ({ draft, signal }) => requestJson("/api/suggestions", {
|
|
51
|
+
method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft })
|
|
52
|
+
}),
|
|
53
|
+
onSelect: prompt => { draft.value = prompt; }
|
|
54
|
+
}));
|
|
20
55
|
const fields = [{ name: "tone", label: "Answer style", items: ["concise", "detailed"] }];
|
|
21
56
|
let request = null;
|
|
22
57
|
let disposed = false;
|
|
@@ -32,25 +67,30 @@ async function reload() {
|
|
|
32
67
|
providerLabel.value = data.providerLabel;
|
|
33
68
|
configurationMode.value = data.configurationMode;
|
|
34
69
|
configuration.value = data.configuration;
|
|
70
|
+
modelChoices.value = data.modelChoices;
|
|
71
|
+
integrationId.value = data.integrationId;
|
|
72
|
+
draftIntegrationId.value = data.integrationId;
|
|
35
73
|
error.value = "";
|
|
36
74
|
} catch (failure) { if (!disposed) error.value = failure.message; }
|
|
37
75
|
finally { if (!disposed) loading.value = false; }
|
|
38
76
|
}
|
|
39
77
|
|
|
40
|
-
async function submit({ configuration: selectedConfiguration }) {
|
|
41
|
-
if (pending.value || !draft.value.trim()) return;
|
|
42
|
-
const
|
|
78
|
+
async function submit({ configuration: selectedConfiguration, attachments: selectedFiles }) {
|
|
79
|
+
if (pending.value || (!draft.value.trim() && !selectedFiles.length)) return;
|
|
80
|
+
const draftSnapshot = draft.value;
|
|
81
|
+
const text = draftSnapshot || "Please review the attached files.";
|
|
43
82
|
pending.value = true;
|
|
83
|
+
accepted.value = false;
|
|
44
84
|
error.value = "";
|
|
45
85
|
const controller = new AbortController();
|
|
46
86
|
request = controller;
|
|
47
87
|
try {
|
|
48
88
|
const response = await fetch("/api/messages", {
|
|
49
89
|
method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal,
|
|
50
|
-
body: JSON.stringify({ messageId: crypto.randomUUID(), text, configuration: selectedConfiguration })
|
|
90
|
+
body: JSON.stringify({ messageId: crypto.randomUUID(), text, configuration: selectedConfiguration, integrationId: integrationId.value, attachmentIds: selectedFiles.map(file => file.attachmentId) })
|
|
51
91
|
});
|
|
52
92
|
if (!response.ok) throw new Error((await response.json()).error || "Could not send.");
|
|
53
|
-
if (draft.value ===
|
|
93
|
+
if (draft.value === draftSnapshot) draft.value = "";
|
|
54
94
|
const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
55
95
|
let buffer = "";
|
|
56
96
|
try {
|
|
@@ -63,6 +103,7 @@ async function submit({ configuration: selectedConfiguration }) {
|
|
|
63
103
|
const event = JSON.parse(buffer.slice(0, newline));
|
|
64
104
|
buffer = buffer.slice(newline + 1);
|
|
65
105
|
if (disposed) continue;
|
|
106
|
+
if (event.type === "accepted") { accepted.value = true; attachments.clearAttachments({ accepted: true, attachmentIds: selectedFiles.map(file => file.attachmentId) }); }
|
|
66
107
|
if (event.type === "snapshot") turns.value = event.turns;
|
|
67
108
|
if (event.type === "error") error.value = event.message;
|
|
68
109
|
}
|
|
@@ -80,10 +121,21 @@ async function stop() {
|
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
const adapter = reactive({
|
|
124
|
+
attachments,
|
|
125
|
+
suggestions,
|
|
126
|
+
models: computed(() => modelChoices.value.length && configurationMode.value !== "hidden" ? {
|
|
127
|
+
providerRows: [{ id: "application", label: "Application AI" }], modelProviderId: "application",
|
|
128
|
+
modelRows: modelChoices.value, modelId: draftIntegrationId.value,
|
|
129
|
+
selectionSummary: modelChoices.value.find(item => item.id === integrationId.value)?.label,
|
|
130
|
+
changesDisabled: configurationMode.value !== "editable" || pending.value,
|
|
131
|
+
canSave: configurationMode.value === "editable" && !pending.value && draftIntegrationId.value !== integrationId.value,
|
|
132
|
+
selectModel: value => { draftIntegrationId.value = value; },
|
|
133
|
+
apply: () => { integrationId.value = draftIntegrationId.value; }
|
|
134
|
+
} : null),
|
|
83
135
|
conversation: { turns, loading, visible: true, assistantLabel: "Assistant", scrollKey: "example" },
|
|
84
136
|
composer: {
|
|
85
|
-
draft, pending,
|
|
86
|
-
canSend: computed(() => !loading.value && !pending.value && Boolean(draft.value.trim())),
|
|
137
|
+
draft, pending: computed(() => pending.value && !accepted.value), submitOnEnter: true, submitOnModifierEnter: true,
|
|
138
|
+
canSend: computed(() => !loading.value && !pending.value && Boolean(draft.value.trim() || attachments.attachments.length)),
|
|
87
139
|
canStop: pending, placeholder: "Ask a question…"
|
|
88
140
|
},
|
|
89
141
|
actions: { reload, submit, stop,
|
|
@@ -99,6 +151,6 @@ html, body, #app { margin: 0; height: 100%; }
|
|
|
99
151
|
.example { display: flex; flex-direction: column; height: 100dvh; max-width: 60rem; width: 100%; margin: auto; padding: 1rem; box-sizing: border-box; }
|
|
100
152
|
.example header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
|
101
153
|
.example h1 { font-size: 1.4rem; }
|
|
102
|
-
.example header span { color:
|
|
154
|
+
.example header span { color: rgba(var(--v-theme-on-surface), .7); font-size: .875rem; }
|
|
103
155
|
.example > .assistant-conversation { flex: 1; min-height: 0; }
|
|
104
156
|
</style>
|
|
@@ -16,16 +16,41 @@ npm run dev
|
|
|
16
16
|
Open http://127.0.0.1:5175. `npm run build` builds the client. The default demo
|
|
17
17
|
provider streams a labelled example response and needs no credentials.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Enable an AI integration
|
|
20
|
+
|
|
21
|
+
The example uses JSKIT's existing `ai` integration configuration and authorized
|
|
22
|
+
connection resolver. To call the explicitly selected model in `integrations.json`:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
ASSISTANT_INTEGRATIONS=./integrations.json npm run server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Edit that portable configuration through JSKIT's integration editor or its JSON
|
|
29
|
+
contract. Shared keys use `env:NAME` references; the key stays in the server's
|
|
30
|
+
normal environment. The example binds authorization to its one local user.
|
|
31
|
+
Provider availability is separate from valid configuration. Demo mode remains
|
|
32
|
+
offline when `ASSISTANT_INTEGRATIONS` is unset.
|
|
20
33
|
|
|
21
34
|
| Variable | Meaning |
|
|
22
35
|
| --- | --- |
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
36
|
+
| `ASSISTANT_INTEGRATIONS` | Path to the app's portable integration JSON; enables real inference. |
|
|
37
|
+
| `ASSISTANT_INTEGRATION_ID` | Default chat integration; `assistant` by default. |
|
|
38
|
+
| `ASSISTANT_SUGGESTION_INTEGRATION_ID` | Separate suggestion agent integration; `suggestions` by default. |
|
|
39
|
+
| `ASSISTANT_SUGGESTION_PROMPT` | Server-owned suggestion instructions; returns an array of `{label,prompt}`. |
|
|
26
40
|
| `ASSISTANT_CONFIGURATION_MODE` | `hidden`, `readonly`, or `editable`; default `editable`. |
|
|
27
41
|
| `ASSISTANT_STORAGE_MODULE` | Path to an app module exporting async `createStorage()`. Otherwise history is held in memory and disappears on restart. |
|
|
28
42
|
|
|
43
|
+
The shared chooser selects the configured chat integrations; suggestions keep
|
|
44
|
+
their independent connection. The shared row shows suggestions and working status,
|
|
45
|
+
and responses stream incrementally. The example accepts text, JSON and common
|
|
46
|
+
web images up to 2 MB each. Uploads use opaque IDs, preview through app URLs,
|
|
47
|
+
and preserve files accepted by a message. Both uploaded bytes and default history
|
|
48
|
+
are transient. When replacing history storage, also replace `attachments.js`
|
|
49
|
+
with storage that preserves referenced files across restart.
|
|
50
|
+
|
|
51
|
+
Goals stay off in this API-model example because it has no goal scheduler.
|
|
52
|
+
Native-agent apps enable the shared goal control by supplying real goal actions.
|
|
53
|
+
|
|
29
54
|
This is a single-user local template with one conversation. A hosted app must
|
|
30
55
|
derive storage scope from authenticated actor/workspace/conversation ownership,
|
|
31
56
|
authorize every route and attachment, and retain provider-delivery evidence for
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Single-user, in-memory example. Hosted applications authorize the owner on
|
|
4
|
+
// upload, resolution, download and deletion, and retain accepted files.
|
|
5
|
+
const files = new Map();
|
|
6
|
+
export const maxFileBytes = 2_000_000;
|
|
7
|
+
export async function upload(request) {
|
|
8
|
+
const fileName = decodeURIComponent(request.headers["x-file-name"] || "attachment");
|
|
9
|
+
const mediaType = request.headers["x-file-type"] || "text/plain";
|
|
10
|
+
if (!/^(text\/[a-z0-9.+-]+|application\/json|image\/(png|jpeg|webp|gif))$/i.test(mediaType)) {
|
|
11
|
+
throw new Error("This example accepts text, JSON, PNG, JPEG, WebP and GIF files.");
|
|
12
|
+
}
|
|
13
|
+
const chunks = [];
|
|
14
|
+
let size = 0;
|
|
15
|
+
for await (const chunk of request) {
|
|
16
|
+
size += chunk.length;
|
|
17
|
+
if (size > maxFileBytes) throw new Error("Files must be at most 2 MB.");
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
}
|
|
20
|
+
const attachmentId = randomUUID();
|
|
21
|
+
const receipt = { attachmentId, fileName: fileName.slice(0, 200), size };
|
|
22
|
+
files.set(attachmentId, { receipt, mediaType, data: Buffer.concat(chunks), accepted: false });
|
|
23
|
+
return receipt;
|
|
24
|
+
}
|
|
25
|
+
export function resolveFiles(ids = []) {
|
|
26
|
+
if (!Array.isArray(ids) || ids.length > 10) throw new Error("Invalid attachments.");
|
|
27
|
+
return ids.map(id => {
|
|
28
|
+
const file = files.get(id);
|
|
29
|
+
if (!file) throw new Error("An attached file is unavailable. Please attach it again.");
|
|
30
|
+
return file;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function contentFor(file) {
|
|
34
|
+
return file.mediaType.startsWith("image/")
|
|
35
|
+
? { type: "image", image: file.data, mediaType: file.mediaType }
|
|
36
|
+
: { type: "text", text: `File: ${file.receipt.fileName}\n${file.data.toString("utf8")}` };
|
|
37
|
+
}
|
|
38
|
+
export function acceptFiles(selected) { for (const file of selected) file.accepted = true; }
|
|
39
|
+
export function removeFile(id) { if (!files.get(id)?.accepted) files.delete(id); }
|
|
40
|
+
export function download(response, id) {
|
|
41
|
+
const [file] = resolveFiles([id]);
|
|
42
|
+
response.writeHead(200, { "Content-Type": file.mediaType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
|
43
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.receipt.fileName)}` });
|
|
44
|
+
response.end(file.data);
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"registrations": {},
|
|
4
|
+
"integrations": {
|
|
5
|
+
"assistant": {
|
|
6
|
+
"provider": "ai",
|
|
7
|
+
"accountMode": "shared",
|
|
8
|
+
"scopes": [],
|
|
9
|
+
"settings": {
|
|
10
|
+
"model": "opencode/big-pickle"
|
|
11
|
+
},
|
|
12
|
+
"authentication": {
|
|
13
|
+
"method": "none"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"suggestions": {
|
|
17
|
+
"provider": "ai",
|
|
18
|
+
"accountMode": "shared",
|
|
19
|
+
"scopes": [],
|
|
20
|
+
"settings": {
|
|
21
|
+
"model": "opencode/big-pickle"
|
|
22
|
+
},
|
|
23
|
+
"authentication": {
|
|
24
|
+
"method": "none"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.173",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.193",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.195",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
|
-
"vuetify": "^4.0.0"
|
|
16
|
+
"vuetify": "^4.0.0",
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.12",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.12"
|
|
17
19
|
},
|
|
18
20
|
"devDependencies": {
|
|
19
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -2,7 +2,11 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { createAiConnectionResolver } from "@jskit-ai/connectors-catalog/server/ai";
|
|
7
|
+
import { createEnvironmentReferenceResolver } from "@jskit-ai/connectors-core/server";
|
|
8
|
+
import { upload, resolveFiles, contentFor, acceptFiles, removeFile, download } from "./attachments.js";
|
|
9
|
+
import { createAiConnectionClient } from "@jskit-ai/assistant-core/server";
|
|
6
10
|
import { createConversationTranscript, createMemoryConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
7
11
|
|
|
8
12
|
// This template is a single-user local application. A hosted app derives scope
|
|
@@ -15,10 +19,23 @@ const transcript = createConversationTranscript({ storage });
|
|
|
15
19
|
const configurationMode = process.env.ASSISTANT_CONFIGURATION_MODE || "editable";
|
|
16
20
|
if (!["hidden", "readonly", "editable"].includes(configurationMode)) throw new Error("Invalid configuration mode.");
|
|
17
21
|
const fixedConfiguration = Object.freeze({ tone: "concise" });
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// Set ASSISTANT_INTEGRATIONS to opt into real inference. The default stays offline.
|
|
23
|
+
const integrationConfiguration = process.env.ASSISTANT_INTEGRATIONS
|
|
24
|
+
? JSON.parse(await readFile(resolve(process.env.ASSISTANT_INTEGRATIONS), "utf8")) : null;
|
|
25
|
+
const context = Object.freeze({ applicationId: "conversation-example", subjectId: "local-user" });
|
|
26
|
+
const connections = integrationConfiguration ? createAiConnectionResolver({
|
|
27
|
+
configuration: integrationConfiguration,
|
|
28
|
+
authorize: actor => actor === context ? actor : null,
|
|
29
|
+
resolveReference: createEnvironmentReferenceResolver(process.env)
|
|
21
30
|
}) : null;
|
|
31
|
+
const assistantIntegrationId = process.env.ASSISTANT_INTEGRATION_ID || "assistant";
|
|
32
|
+
const suggestionIntegrationId = process.env.ASSISTANT_SUGGESTION_INTEGRATION_ID || "suggestions";
|
|
33
|
+
const modelChoices = connections ? Object.entries(integrationConfiguration.integrations)
|
|
34
|
+
.filter(([id, integration]) => integration.provider === "ai" && id !== suggestionIntegrationId)
|
|
35
|
+
.map(([id, integration]) => ({ id, label: `${id} · ${integration.settings?.model || "Default AI model"}` })) : [];
|
|
36
|
+
async function clientFor(integrationId) {
|
|
37
|
+
return connections ? createAiConnectionClient(await connections.resolve({ context, integrationId })) : null;
|
|
38
|
+
}
|
|
22
39
|
let active = null;
|
|
23
40
|
|
|
24
41
|
function configurationFor(input) {
|
|
@@ -29,7 +46,8 @@ function configurationFor(input) {
|
|
|
29
46
|
return { tone: input.tone };
|
|
30
47
|
}
|
|
31
48
|
|
|
32
|
-
async function* reply(messages, configuration, signal) {
|
|
49
|
+
async function* reply(messages, configuration, integrationId, signal) {
|
|
50
|
+
const ai = await clientFor(integrationId);
|
|
33
51
|
if (ai) {
|
|
34
52
|
const stream = await ai.createChatCompletionStream({ signal, messages: [
|
|
35
53
|
{ role: "system", content: `Answer in a ${configuration.tone} style.` }, ...messages
|
|
@@ -40,7 +58,7 @@ async function* reply(messages, configuration, signal) {
|
|
|
40
58
|
}
|
|
41
59
|
return;
|
|
42
60
|
}
|
|
43
|
-
const answer = `This is the **demo provider**. Your message was:\n\n${messages.at(-1).content}\n\nThe application supplies the provider and storage; JSKIT supplies the conversation UI and transcript behavior.`;
|
|
61
|
+
const answer = `This is the **demo provider**. Your message was:\n\n${Array.isArray(messages.at(-1).content) ? messages.at(-1).content.filter(part => part.type === "text").map(part => part.text).join("\n\n") : messages.at(-1).content}\n\nThe application supplies the provider and storage; JSKIT supplies the conversation UI and transcript behavior.`;
|
|
44
62
|
for (const word of answer.match(/\S+\s*/g) || []) {
|
|
45
63
|
await delay(35, undefined, { signal });
|
|
46
64
|
yield word;
|
|
@@ -66,9 +84,36 @@ createServer(async (request, response) => {
|
|
|
66
84
|
try {
|
|
67
85
|
if (request.method === "GET" && request.url === "/api/conversation") {
|
|
68
86
|
json(response, 200, { turns: await transcript.readConversationLog(scope), configurationMode,
|
|
69
|
-
configuration: fixedConfiguration,
|
|
87
|
+
configuration: fixedConfiguration, modelChoices, integrationId: assistantIntegrationId,
|
|
88
|
+
providerLabel: connections ? "Application AI integration" : "Demo provider · transient storage" });
|
|
70
89
|
return;
|
|
71
90
|
}
|
|
91
|
+
if (request.method === "POST" && request.url === "/api/attachments") {
|
|
92
|
+
json(response, 200, await upload(request)); return;
|
|
93
|
+
}
|
|
94
|
+
const attachmentPath = request.url?.match(/^\/api\/attachments\/([A-Za-z0-9-]+)$/);
|
|
95
|
+
if (attachmentPath && request.method === "GET") { download(response, attachmentPath[1]); return; }
|
|
96
|
+
if (attachmentPath && request.method === "DELETE") { removeFile(attachmentPath[1]); json(response, 200, { ok: true }); return; }
|
|
97
|
+
if (request.method === "POST" && request.url === "/api/suggestions") {
|
|
98
|
+
const input = await body(request);
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
response.on("close", () => controller.abort());
|
|
101
|
+
const suggestionAgent = await clientFor(suggestionIntegrationId);
|
|
102
|
+
const history = await transcript.readConversationLog(scope);
|
|
103
|
+
const result = suggestionAgent ? await suggestionAgent.createChatCompletion({
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
messages: [
|
|
106
|
+
{ role: "system", content: process.env.ASSISTANT_SUGGESTION_PROMPT || 'Suggest three useful next user messages. Return only a JSON array of {"label":"short label","prompt":"message"}. Do not execute tasks.' },
|
|
107
|
+
{ role: "user", content: JSON.stringify({ draft: String(input.draft || "").slice(0, 4000), turns: history.slice(-3) }) }
|
|
108
|
+
]
|
|
109
|
+
}) : null;
|
|
110
|
+
const suggestions = result ? JSON.parse(result.choices[0].message.content) : [
|
|
111
|
+
{ label: "Summarize", prompt: "Summarize our conversation so far." },
|
|
112
|
+
{ label: "Explain", prompt: "Explain your last answer in more detail." },
|
|
113
|
+
{ label: "Next steps", prompt: "What would be a useful next step?" }
|
|
114
|
+
];
|
|
115
|
+
json(response, 200, suggestions); return;
|
|
116
|
+
}
|
|
72
117
|
if (request.method === "DELETE" && request.url === "/api/run") {
|
|
73
118
|
active?.abort();
|
|
74
119
|
json(response, 200, { stopped: true });
|
|
@@ -79,11 +124,15 @@ createServer(async (request, response) => {
|
|
|
79
124
|
if (typeof input.text !== "string" || !input.text.trim() || input.text.length > 8000 ||
|
|
80
125
|
typeof input.messageId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input.messageId)) throw new Error("Invalid message.");
|
|
81
126
|
const configuration = configurationFor(input.configuration);
|
|
127
|
+
const integrationId = configurationMode === "editable" ? input.integrationId || assistantIntegrationId : assistantIntegrationId;
|
|
128
|
+
if (connections && !modelChoices.some(choice => choice.id === integrationId)) throw new Error("Choose an allowed AI connection.");
|
|
129
|
+
const files = resolveFiles(input.attachmentIds);
|
|
130
|
+
input.attachments = files.map(file => file.receipt);
|
|
82
131
|
if (active) { json(response, 409, { error: "The assistant is already working." }); return; }
|
|
83
132
|
const controller = new AbortController();
|
|
84
133
|
active = controller;
|
|
85
134
|
let turn;
|
|
86
|
-
try { turn = await transcript.writeConversationUserMessage(scope, input); }
|
|
135
|
+
try { turn = await transcript.writeConversationUserMessage(scope, input); acceptFiles(files); }
|
|
87
136
|
catch (failure) { active = null; throw failure; }
|
|
88
137
|
response.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
|
|
89
138
|
const emit = (event) => { if (!response.destroyed) response.write(`${JSON.stringify(event)}\n`); };
|
|
@@ -92,12 +141,14 @@ createServer(async (request, response) => {
|
|
|
92
141
|
let answer = "";
|
|
93
142
|
try {
|
|
94
143
|
// A duplicate request returns saved state; it never launches the provider again.
|
|
144
|
+
emit({ type: "accepted" });
|
|
95
145
|
if (turn) {
|
|
96
146
|
const history = await transcript.readConversationLog(scope);
|
|
97
147
|
emit({ type: "snapshot", turns: history.map((entry) => entry.turnId === turn.turnId ? { ...entry, pending: true } : entry) });
|
|
98
148
|
const messages = history.flatMap((entry) => [entry.user, entry.assistant].filter(Boolean))
|
|
99
|
-
.slice(-20).map((entry) => ({ role: entry.role, content: entry.
|
|
100
|
-
|
|
149
|
+
.slice(-20).map((entry) => ({ role: entry.role, content: entry.attachments?.length
|
|
150
|
+
? [{ type: "text", text: entry.text }, ...resolveFiles(entry.attachments.map(file => file.attachmentId)).map(contentFor)] : entry.text }));
|
|
151
|
+
for await (const text of reply(messages, configuration, integrationId, controller.signal)) {
|
|
101
152
|
answer += text;
|
|
102
153
|
if (answer.length > 32_000) throw new Error("Response limit reached.");
|
|
103
154
|
emit({ type: "snapshot", turns: history.map((entry) => entry.turnId === turn.turnId
|
|
@@ -1,11 +1,55 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, reactive, ref } from "vue";
|
|
3
3
|
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
|
+
import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
|
|
5
|
+
import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
|
|
4
6
|
const phase = ref("idle");
|
|
5
7
|
const draft = ref("");
|
|
6
8
|
const narrow = ref(false);
|
|
7
9
|
const submissions = ref(0);
|
|
8
10
|
const feedback = ref(false);
|
|
11
|
+
const supportEnabled = new URLSearchParams(location.search).has("support");
|
|
12
|
+
const suggestionModel = ref("small-model");
|
|
13
|
+
const customActivity = ref(false);
|
|
14
|
+
const suggestions = useAssistantSuggestions({
|
|
15
|
+
active: computed(() => supportEnabled && phase.value === "idle"),
|
|
16
|
+
draft, configuration: computed(() => ({ model: suggestionModel.value })), debounceMs: 0,
|
|
17
|
+
generate: async ({ configuration }) => [{ label: "Suggest next step", prompt: `Suggestion from ${configuration.model}` }],
|
|
18
|
+
onSelect(text) { draft.value = text; }
|
|
19
|
+
});
|
|
20
|
+
const capabilitiesEnabled = new URLSearchParams(location.search).has("capabilities");
|
|
21
|
+
const attachmentReceipts = ref([]);
|
|
22
|
+
const waitingUploads = [];
|
|
23
|
+
function finishUploads() { for (const finish of waitingUploads.splice(0)) finish(); }
|
|
24
|
+
const attachments = useAssistantAttachments({
|
|
25
|
+
sessionId: "fixture-conversation", maxItems: 2,
|
|
26
|
+
canUpload: computed(() => capabilitiesEnabled),
|
|
27
|
+
async uploadAttachment(sessionId, file, { onProgress }) {
|
|
28
|
+
onProgress({ bytesSent: file.size / 2, totalBytes: file.size });
|
|
29
|
+
await new Promise(resolve => waitingUploads.push(resolve));
|
|
30
|
+
onProgress({ bytesSent: file.size, totalBytes: file.size });
|
|
31
|
+
return { attachmentId: file.name, fileName: file.name, size: file.size, sessionId };
|
|
32
|
+
},
|
|
33
|
+
async deleteAttachment() { return { ok: true }; }
|
|
34
|
+
});
|
|
35
|
+
const modelId = ref("one");
|
|
36
|
+
const appliedModel = ref("one");
|
|
37
|
+
const models = reactive({
|
|
38
|
+
providerRows: [{ id: "configured", label: "Configured AI" }], modelProviderId: "configured",
|
|
39
|
+
modelRows: Array.from({ length: 8 }, (_, index) => ({ id: index === 0 ? "one" : `model-${index}`, label: `Model ${index + 1}` })),
|
|
40
|
+
modelId, changesDisabled: computed(() => phase.value !== "idle"),
|
|
41
|
+
canSave: computed(() => modelId.value !== appliedModel.value),
|
|
42
|
+
selectModel(value) { modelId.value = value; },
|
|
43
|
+
apply() { appliedModel.value = modelId.value; }
|
|
44
|
+
});
|
|
45
|
+
const goalEnabled = ref(new URLSearchParams(location.search).has("goal"));
|
|
46
|
+
const goal = ref(null);
|
|
47
|
+
const goalState = reactive({
|
|
48
|
+
enabled: goalEnabled, goal,
|
|
49
|
+
set(input) { goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
|
|
50
|
+
pause() { goal.value = { ...goal.value, status: "paused", elapsedSeconds: goal.value.elapsedSeconds + (Date.now() - goal.value.sampledAt) / 1000, sampledAt: Date.now() }; },
|
|
51
|
+
resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
|
|
52
|
+
});
|
|
9
53
|
const adapter = reactive({
|
|
10
54
|
conversation: { turns: [], visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture" },
|
|
11
55
|
composer: {
|
|
@@ -18,9 +62,12 @@ const adapter = reactive({
|
|
|
18
62
|
stopPending: computed(() => phase.value === "stopping"),
|
|
19
63
|
submitLabel: computed(() => phase.value === "reconnecting" ? "Reconnecting…" : "Send")
|
|
20
64
|
},
|
|
65
|
+
suggestions, goal: goalState,
|
|
66
|
+
attachments: capabilitiesEnabled ? attachments : undefined,
|
|
67
|
+
models: capabilitiesEnabled ? models : undefined,
|
|
21
68
|
actions: {
|
|
22
69
|
setDraft(value) { draft.value = value; },
|
|
23
|
-
submit() { submissions.value += 1; draft.value = ""; phase.value = "active"; },
|
|
70
|
+
submit(payload) { attachmentReceipts.value = payload.attachments; attachments.clearAttachments(); submissions.value += 1; draft.value = ""; phase.value = "active"; },
|
|
24
71
|
stop() { phase.value = "stopping"; }
|
|
25
72
|
}
|
|
26
73
|
});
|
|
@@ -32,10 +79,16 @@ const adapter = reactive({
|
|
|
32
79
|
<button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
|
|
33
80
|
<button @click="narrow = !narrow">Resize pane</button>
|
|
34
81
|
<button @click="feedback = !feedback">Toggle action feedback</button>
|
|
82
|
+
<button v-if="supportEnabled" @click="suggestionModel = 'other-model'">Change suggestion model</button>
|
|
83
|
+
<button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
|
|
84
|
+
<button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
|
|
85
|
+
<button @click="goalEnabled = !goalEnabled">Toggle goals</button>
|
|
35
86
|
<output>{{ phase }}; submitted {{ submissions }}</output>
|
|
87
|
+
<output v-if="capabilitiesEnabled">Model: {{ appliedModel }}; sent files: {{ attachmentReceipts.map(item => item.fileName).join(', ') }}</output>
|
|
36
88
|
</div>
|
|
37
89
|
<main class="fixture" :class="{ narrow }">
|
|
38
90
|
<AssistantConversationElement :adapter="adapter">
|
|
91
|
+
<template v-if="customActivity" #activity="{ activity }"><strong>Custom activity: {{ activity.label }}</strong></template>
|
|
39
92
|
<template #composer-feedback><p v-if="feedback" class="feedback" role="status">Describe what you want to change.</p></template>
|
|
40
93
|
</AssistantConversationElement>
|
|
41
94
|
</main>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
|
+
import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
|
|
5
|
+
|
|
6
|
+
const working = ref(true);
|
|
7
|
+
const draft = ref("");
|
|
8
|
+
const scope = ref("first");
|
|
9
|
+
const combined = ref(false);
|
|
10
|
+
const messages = ref([1, 2, 3].map(index => ({
|
|
11
|
+
messageId: `progress-${index}`, role: "thinking", text: `Reasoning ${index}`
|
|
12
|
+
})));
|
|
13
|
+
let sequence = 3;
|
|
14
|
+
function append(role) {
|
|
15
|
+
sequence += 1;
|
|
16
|
+
messages.value.push({ messageId: `message-${sequence}`, role, text: `${role} ${sequence}` });
|
|
17
|
+
}
|
|
18
|
+
const adapter = computed(() => ({
|
|
19
|
+
conversation: {
|
|
20
|
+
working: working.value, scrollKey: scope.value, hasMoreBefore: true,
|
|
21
|
+
turns: combined.value
|
|
22
|
+
? conversationTurnsFromMessages(messages.value)
|
|
23
|
+
: messages.value.map(message => ({
|
|
24
|
+
turnId: `row-${message.messageId}`,
|
|
25
|
+
...(message.role === "system" || message.role === "user"
|
|
26
|
+
? { [message.role]: { messageId: message.messageId, text: message.text } } : {}),
|
|
27
|
+
messages: [message]
|
|
28
|
+
}))
|
|
29
|
+
},
|
|
30
|
+
composer: { draft: draft.value, canSend: Boolean(draft.value), canStop: working.value },
|
|
31
|
+
actions: {
|
|
32
|
+
setDraft(value) { draft.value = value; },
|
|
33
|
+
stop() { working.value = false; },
|
|
34
|
+
loadMore({ complete }) {
|
|
35
|
+
messages.value.unshift({ messageId: `older-${sequence++}`, role: "thinking", text: "Older reasoning" });
|
|
36
|
+
complete({ changed: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
</script>
|
|
41
|
+
<template>
|
|
42
|
+
<v-app>
|
|
43
|
+
<v-main>
|
|
44
|
+
<main class="progress-fixture">
|
|
45
|
+
<nav>
|
|
46
|
+
<button @click="working = !working">Toggle working</button>
|
|
47
|
+
<button @click="combined = !combined">Change storage rows</button>
|
|
48
|
+
<button @click="scope = scope === 'first' ? 'second' : 'first'">Change conversation</button>
|
|
49
|
+
<button v-for="role in ['thinking', 'commentary', 'assistant', 'user', 'system']" :key="role" @click="append(role)">Append {{ role }}</button>
|
|
50
|
+
</nav>
|
|
51
|
+
<AssistantConversationElement :adapter="adapter" />
|
|
52
|
+
</main>
|
|
53
|
+
</v-main>
|
|
54
|
+
</v-app>
|
|
55
|
+
</template>
|
|
56
|
+
<style scoped>
|
|
57
|
+
.progress-fixture { display: flex; flex-direction: column; height: 100dvh; padding: 8px; }
|
|
58
|
+
nav { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
|
|
59
|
+
nav button { padding: 8px; min-height: 48px; border: 1px solid; }
|
|
60
|
+
.assistant-conversation { flex: 1; }
|
|
61
|
+
</style>
|
|
@@ -6,6 +6,7 @@ import * as directives from "vuetify/directives";
|
|
|
6
6
|
import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
7
7
|
import App from "./App.vue";
|
|
8
8
|
import ConversationFixture from "./ConversationFixture.vue";
|
|
9
|
+
import ProgressFixture from "./ProgressFixture.vue";
|
|
9
10
|
|
|
10
11
|
const vuetify = createVuetify({
|
|
11
12
|
components,
|
|
@@ -20,6 +21,7 @@ const vuetify = createVuetify({
|
|
|
20
21
|
}
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
const query = new URLSearchParams(location.search);
|
|
25
|
+
createApp(query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
|
|
24
26
|
.use(vuetify)
|
|
25
27
|
.mount("#app");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.173",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -18,19 +18,33 @@
|
|
|
18
18
|
"./shared/conversation": "./src/shared/conversation/index.js",
|
|
19
19
|
"./client/conversation-submit": "./src/client/conversation/submitText.js",
|
|
20
20
|
"./client/conversation-scroll": "./src/client/conversation/useScrollToBottom.js",
|
|
21
|
-
"./testing/conversation-storage": "./src/testing/conversationStorageContract.js"
|
|
21
|
+
"./testing/conversation-storage": "./src/testing/conversationStorageContract.js",
|
|
22
|
+
"./client/conversation-suggestions": "./src/client/conversation/useAssistantSuggestions.js",
|
|
23
|
+
"./client/conversation-attachments": "./src/client/conversation/useAssistantAttachments.js"
|
|
22
24
|
},
|
|
23
25
|
"dependencies": {
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
+
"@ai-sdk/anthropic": "^4.0.53",
|
|
27
|
+
"@ai-sdk/cerebras": "^3.0.48",
|
|
28
|
+
"@ai-sdk/google": "^4.0.70",
|
|
29
|
+
"@ai-sdk/groq": "^4.0.41",
|
|
30
|
+
"@ai-sdk/mistral": "^4.0.43",
|
|
31
|
+
"@ai-sdk/openai": "^4.0.66",
|
|
32
|
+
"@ai-sdk/openai-compatible": "^3.0.48",
|
|
33
|
+
"@ai-sdk/perplexity": "^4.0.43",
|
|
34
|
+
"@ai-sdk/togetherai": "^3.0.49",
|
|
35
|
+
"@ai-sdk/xai": "^4.0.58",
|
|
36
|
+
"@jskit-ai/resource-core": "0.1.137",
|
|
37
|
+
"@jskit-ai/resource-crud-core": "0.1.137",
|
|
38
|
+
"@mdi/js": "^7.4.47",
|
|
39
|
+
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
40
|
+
"ai": "^7.0.101",
|
|
26
41
|
"json-rest-schema": "^1.0.17",
|
|
27
42
|
"openai": "^6.22.0",
|
|
28
|
-
"@mdi/js": "^7.4.47",
|
|
29
43
|
"ws": "^8.20.1"
|
|
30
44
|
},
|
|
31
45
|
"peerDependencies": {
|
|
32
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
33
|
-
"@jskit-ai/kernel": "0.1.
|
|
46
|
+
"@jskit-ai/http-runtime": "0.1.193",
|
|
47
|
+
"@jskit-ai/kernel": "0.1.195",
|
|
34
48
|
"vue": "^3.5.13",
|
|
35
49
|
"vuetify": "^4.0.0"
|
|
36
50
|
},
|