@jskit-ai/assistant-core 0.1.170 → 0.1.172

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.
Files changed (29) hide show
  1. package/examples/conversation/App.vue +62 -10
  2. package/examples/conversation/README.md +29 -4
  3. package/examples/conversation/attachments.js +45 -0
  4. package/examples/conversation/integrations.json +28 -0
  5. package/examples/conversation/package.json +6 -4
  6. package/examples/conversation/server.js +61 -10
  7. package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
  8. package/package.json +21 -7
  9. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  10. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  11. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  12. package/src/client/conversation/AssistantConversationElement.vue +99 -8
  13. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  14. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  15. package/src/client/conversation/AssistantModelControl.vue +334 -0
  16. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  17. package/src/client/conversation/AssistantTranscript.vue +2 -1
  18. package/src/client/conversation/index.js +9 -0
  19. package/src/client/conversation/useAssistantAttachments.js +991 -0
  20. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  21. package/src/server/index.js +1 -0
  22. package/src/server/lib/aiConnectionClient.js +90 -0
  23. package/src/server/lib/providers/anthropicClient.js +6 -21
  24. package/src/shared/assistantResource.js +7 -0
  25. package/src/shared/conversation/attachments.js +13 -0
  26. package/src/shared/conversation/index.js +1 -0
  27. package/test/aiConnectionClient.test.js +159 -0
  28. package/test/assistantScroll.browser.test.js +144 -0
  29. 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 #hints><p v-if="error" role="alert">{{ error }}</p></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 text = draft.value;
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 === text) 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, disabled: pending, submitOnModifierEnter: true,
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: #666; font-size: .875rem; }
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
- Optional server environment:
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
- | `ASSISTANT_API_KEY` | Enables the existing JSKIT API-model client; server only. |
24
- | `ASSISTANT_PROVIDER` | `openai`, `deepseek`, or `anthropic`; default `openai`. |
25
- | `ASSISTANT_MODEL` | Model available to that provider/account. |
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.170",
12
- "@jskit-ai/http-runtime": "0.1.190",
13
- "@jskit-ai/kernel": "0.1.192",
11
+ "@jskit-ai/assistant-core": "0.1.172",
12
+ "@jskit-ai/http-runtime": "0.1.192",
13
+ "@jskit-ai/kernel": "0.1.194",
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.11",
18
+ "@jskit-ai/connectors-catalog": "0.1.11"
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 { createAiClient } from "@jskit-ai/assistant-core/server";
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
- const ai = process.env.ASSISTANT_API_KEY ? createAiClient({
19
- provider: process.env.ASSISTANT_PROVIDER || "openai", apiKey: process.env.ASSISTANT_API_KEY,
20
- model: process.env.ASSISTANT_MODEL
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, providerLabel: ai ? `${ai.provider} · ${ai.defaultModel}` : "Demo provider · transient storage" });
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.text }));
100
- for await (const text of reply(messages, configuration, controller.signal)) {
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.170",
3
+ "version": "0.1.172",
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
- "@jskit-ai/resource-core": "0.1.134",
25
- "@jskit-ai/resource-crud-core": "0.1.134",
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.136",
37
+ "@jskit-ai/resource-crud-core": "0.1.136",
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.190",
33
- "@jskit-ai/kernel": "0.1.192",
46
+ "@jskit-ai/http-runtime": "0.1.192",
47
+ "@jskit-ai/kernel": "0.1.194",
34
48
  "vue": "^3.5.13",
35
49
  "vuetify": "^4.0.0"
36
50
  },
@@ -0,0 +1,38 @@
1
+ <template>
2
+ <v-dialog :model-value="Boolean(attachment)" max-width="960" @update:model-value="!$event && emit('close')">
3
+ <v-card v-if="attachment" :title="attachment.fileName">
4
+ <v-card-text>
5
+ <img
6
+ v-if="previewUrl && !imageFailed"
7
+ :key="previewUrl"
8
+ :alt="attachment.fileName"
9
+ class="assistant-attachment-preview"
10
+ :src="previewUrl"
11
+ @error="imageFailed = true"
12
+ >
13
+ <p v-else>Preview is unavailable for this file. You can download it below.</p>
14
+ </v-card-text>
15
+ <v-card-actions>
16
+ <v-btn :href="downloadUrl" :download="attachment.fileName" :prepend-icon="mdiDownload" variant="tonal">Download</v-btn>
17
+ <v-spacer />
18
+ <v-btn @click="emit('close')">Close</v-btn>
19
+ </v-card-actions>
20
+ </v-card>
21
+ </v-dialog>
22
+ </template>
23
+
24
+ <script setup>
25
+ import { ref, watch } from "vue";
26
+ import { mdiDownload } from "@mdi/js";
27
+ const props = defineProps({
28
+ attachment: { type: Object, default: null },
29
+ previewUrl: { type: String, default: "" },
30
+ downloadUrl: { type: String, required: true }
31
+ });
32
+ const emit = defineEmits(["close"]);
33
+ const imageFailed = ref(false);
34
+ watch(() => [props.attachment, props.previewUrl], () => { imageFailed.value = false; });
35
+ </script>
36
+ <style scoped>
37
+ .assistant-attachment-preview { display: block; margin: auto; max-height: 70vh; max-width: 100%; object-fit: contain; }
38
+ </style>