@jskit-ai/assistant-runtime 0.1.164 → 0.1.166
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/fixtures/conversation/App.vue +9 -2
- package/fixtures/conversation/vite.config.mjs +47 -1
- package/package.json +7 -7
- package/patterns/assistant-surface/PATTERN.md +24 -4
- package/patterns/assistant-surface/example/config/server.js +1 -1
- package/patterns/assistant-surface/example/integrations.json +17 -0
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +20 -0
- package/src/client/components/AssistantSurfaceClientElement.vue +35 -6
- package/src/client/composables/useAssistantRuntime.js +8 -4
- package/src/client/support/assistantRuntimeState.js +3 -1
- package/src/server/AssistantProvider.js +22 -4
- package/src/server/services/chatService.js +64 -15
- package/src/server/support/assistantServerConfig.js +2 -0
- package/test/aiConnections.test.js +58 -0
- package/test/assistantServerConfig.test.js +2 -0
- package/test/assistantSurfacePattern.test.js +9 -1
- package/test/chatServiceLifecycle.test.js +82 -10
- package/test/conversation.browser.test.js +41 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import {
|
|
2
|
+
import { useAssistantAttachments } from "@jskit-ai/assistant-core/client/conversation";
|
|
3
|
+
import { computed, reactive, ref } from "vue";
|
|
3
4
|
import { useRoute, useRouter } from "vue-router";
|
|
4
5
|
import { useTheme } from "vuetify";
|
|
5
6
|
import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
|
|
@@ -12,6 +13,12 @@ const mounted = ref(true);
|
|
|
12
13
|
const second = ref(false);
|
|
13
14
|
const compact = ref(false);
|
|
14
15
|
const surface = ref("admin");
|
|
16
|
+
const integrationId = ref("quick");
|
|
17
|
+
const attachments = reactive(useAssistantAttachments({
|
|
18
|
+
sessionId: () => String(route.query.workspace || "alpha"),
|
|
19
|
+
uploadAttachment: async (_scope, file) => ({ attachmentId: "fixture-file", fileName: file.name, size: file.size }),
|
|
20
|
+
deleteAttachment: async () => {}
|
|
21
|
+
}));
|
|
15
22
|
const workspace = computed(() => route.query.workspace || "alpha");
|
|
16
23
|
function switchWorkspace() { router.push({ query: { workspace: workspace.value === "alpha" ? "beta" : "alpha" } }); }
|
|
17
24
|
</script>
|
|
@@ -30,7 +37,7 @@ function switchWorkspace() { router.push({ query: { workspace: workspace.value =
|
|
|
30
37
|
<button @click="theme.change(theme.global.name.value === 'dark' ? 'light' : 'dark')">Toggle theme</button>
|
|
31
38
|
</nav>
|
|
32
39
|
<div class="fixture__panes" :class="{ 'fixture__panes--compact': compact }">
|
|
33
|
-
<AssistantSurfaceClientElement v-if="mounted" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
|
|
40
|
+
<AssistantSurfaceClientElement v-if="mounted" v-model:integration-id="integrationId" :connections="[{ id: 'quick', label: 'Quick model' }, { id: 'deep', label: 'Deep model' }]" :attachments="route.query.files ? attachments : null" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
|
|
34
41
|
<AssistantSurfaceClientElement v-if="second" surface-id="console" assistant-label="Second assistant" />
|
|
35
42
|
</div>
|
|
36
43
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createChatService } from "../../src/server/services/chatService.js";
|
|
1
2
|
import { defineConfig } from "vite";
|
|
2
3
|
import vue from "@vitejs/plugin-vue";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,15 +11,19 @@ export default defineConfig({
|
|
|
10
11
|
configureServer(server) {
|
|
11
12
|
const streams = [];
|
|
12
13
|
const requests = [];
|
|
14
|
+
const transcript = [];
|
|
15
|
+
let realService = false;
|
|
13
16
|
server.middlewares.use(async (req, res, next) => {
|
|
14
17
|
const url = new URL(req.url, "http://fixture");
|
|
15
18
|
if (!url.pathname.startsWith("/api/") && !url.pathname.startsWith("/fixture/")) return next();
|
|
16
19
|
const json = (value) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(value)); };
|
|
17
20
|
const send = (stream, event) => stream.res.write(`${JSON.stringify(event)}\n`);
|
|
18
21
|
if (url.pathname === "/api/session") return json({ csrfToken: "fixture-only" });
|
|
19
|
-
if (url.pathname === "/fixture/
|
|
22
|
+
if (url.pathname === "/fixture/service") { realService = true; return json({ ok: true }); }
|
|
23
|
+
if (url.pathname === "/fixture/state") return json({ requests, transcript, streams: streams.map(({ closed }) => ({ closed })) });
|
|
20
24
|
if (url.pathname === "/fixture/next") {
|
|
21
25
|
for (const stream of streams.filter((entry) => !entry.closed)) {
|
|
26
|
+
if (stream.advance) { stream.advance(url.searchParams); continue; }
|
|
22
27
|
if (url.searchParams.has("fail")) {
|
|
23
28
|
send(stream, { type: "error", message: "Fixture provider failed." });
|
|
24
29
|
send(stream, { type: "done", status: "failed" });
|
|
@@ -43,6 +48,47 @@ export default defineConfig({
|
|
|
43
48
|
const stream = { res, closed: false };
|
|
44
49
|
streams.push(stream);
|
|
45
50
|
res.on("close", () => { stream.closed = true; });
|
|
51
|
+
if (realService) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
res.on("close", () => controller.abort());
|
|
54
|
+
let release;
|
|
55
|
+
const nextChunk = () => new Promise((resolve, reject) => {
|
|
56
|
+
release = resolve;
|
|
57
|
+
controller.signal.addEventListener("abort", () => reject(new Error("Stopped")), { once: true });
|
|
58
|
+
});
|
|
59
|
+
stream.advance = parameters => release(parameters);
|
|
60
|
+
const service = createChatService({
|
|
61
|
+
aiClientFactory: { async resolveClient(_surface, { integrationId }) {
|
|
62
|
+
return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
|
|
63
|
+
async *createChatCompletionStream({ messages }) {
|
|
64
|
+
stream.receivedFiles = messages.at(-1).content;
|
|
65
|
+
await nextChunk();
|
|
66
|
+
yield { choices: [{ delta: { content: "Partial answer" } }] };
|
|
67
|
+
await nextChunk();
|
|
68
|
+
yield { choices: [{ delta: { content: " completed." } }] };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
} },
|
|
72
|
+
attachments: { async resolve({ attachmentIds }) {
|
|
73
|
+
return { attachments: attachmentIds.map(attachmentId => ({ attachmentId, fileName: "notes.txt", size: 5 })),
|
|
74
|
+
content: [{ type: "text", text: "Uploaded notes" }] };
|
|
75
|
+
} },
|
|
76
|
+
transcriptService: {
|
|
77
|
+
async createConversationForTurn() { return { conversation: { id: "100" } }; },
|
|
78
|
+
async appendMessage(_surface, _id, message) { transcript.push(message); },
|
|
79
|
+
async completeConversation() {}
|
|
80
|
+
},
|
|
81
|
+
serviceToolCatalog: { resolveToolSet: () => ({ tools: [] }) },
|
|
82
|
+
assistantConfigService: { resolveSystemPrompt: async () => "Answer the user." },
|
|
83
|
+
appConfig: { surfaceDefinitions: { admin: { id: "admin", enabled: true, requiresWorkspace: false, accessPolicyId: "public" } },
|
|
84
|
+
assistantSurfaces: { admin: { settingsSurfaceId: "admin", configScope: "global" } } }
|
|
85
|
+
});
|
|
86
|
+
const streamWriter = Object.fromEntries(["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"].map(method => [method, event => send(stream, event)]));
|
|
87
|
+
try { await service.streamChat({ ...payload, targetSurfaceId: "admin" }, { streamWriter, abortSignal: controller.signal, context: { actor: { id: "1" } } }); }
|
|
88
|
+
catch (error) { if (!res.destroyed) send(stream, { type: "error", message: error.message }); }
|
|
89
|
+
finally { res.end(); }
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
46
92
|
send(stream, { type: "meta", conversationId: payload.conversationId || "100" });
|
|
47
93
|
send(stream, { type: "tool_call", toolCallId: "lookup", name: "Look up records", arguments: "never-render-this-payload" });
|
|
48
94
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.166",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
"./server/actionIds": "./src/server/actionIds.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
14
|
+
"@jskit-ai/assistant-core": "0.1.173",
|
|
15
|
+
"@jskit-ai/database-runtime": "0.1.195",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.193",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.40",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.195",
|
|
22
|
+
"@jskit-ai/shell-web": "0.1.199",
|
|
23
23
|
"@tanstack/vue-query": "^5.90.5",
|
|
24
24
|
"vue": "^3.5.13",
|
|
25
25
|
"vuetify": "^4.0.0"
|
|
@@ -3,7 +3,7 @@ id: assistant/assistant-surface
|
|
|
3
3
|
title: Assistant surface and settings
|
|
4
4
|
summary: Configure an assistant runtime for one application surface and expose its chat and settings elements through ordinary pages and placements.
|
|
5
5
|
keywords: ai, assistant, chat, config, environment, page, placement, settings, surface
|
|
6
|
-
requires: @jskit-ai/assistant-runtime, @jskit-ai/shell-web
|
|
6
|
+
requires: @jskit-ai/assistant-runtime, @jskit-ai/shell-web, @jskit-ai/connectors-catalog, @jskit-ai/connectors-core
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Assistant surface and settings
|
|
@@ -32,7 +32,8 @@ in source or a questionnaire transcript.
|
|
|
32
32
|
- Workspace configuration is used only when both surfaces require a workspace.
|
|
33
33
|
- Each runtime surface appears once in `assistantSurfaces` and
|
|
34
34
|
`assistantServer`.
|
|
35
|
-
- The server config
|
|
35
|
+
- The server config selects an AI integration ID, never a credential value.
|
|
36
|
+
- The application publishes an authorized `integrations.ai` resolver capability.
|
|
36
37
|
- Runtime and settings pages use the public assistant elements.
|
|
37
38
|
- Environment values are supplied through the application's normal secret
|
|
38
39
|
environment boundary.
|
|
@@ -48,12 +49,16 @@ and `assistantServer` in server config.
|
|
|
48
49
|
## Example files
|
|
49
50
|
|
|
50
51
|
`example/` contains concrete public/server config, assistant and settings pages,
|
|
51
|
-
|
|
52
|
+
semantic placements, an AI integration and its server resolver for an `admin` surface.
|
|
53
|
+
Install `@jskit-ai/connectors-catalog` and `@jskit-ai/connectors-core` and register
|
|
54
|
+
`ApplicationAiFeature` in the ordinary server feature list. Set its application ID
|
|
55
|
+
and AI-use policy. The example explicitly selects the catalogue's no-key model;
|
|
56
|
+
provider availability is independent of configuration. Adapt the complete files to the
|
|
52
57
|
application's existing config and placement registries.
|
|
53
58
|
|
|
54
59
|
## Variation points
|
|
55
60
|
|
|
56
|
-
Change surface ids, settings location, config scope,
|
|
61
|
+
Change surface ids, settings location, config scope, AI integration IDs, routes,
|
|
57
62
|
labels, icons, and placement targets. A workspace-scoped application must retain
|
|
58
63
|
the current workspace in both routes and assistant requests through the runtime's
|
|
59
64
|
public workspace support.
|
|
@@ -92,3 +97,18 @@ workspace/surface aborts the local response and discards that view's state.
|
|
|
92
97
|
Reopening restores saved history, not a live stream. Stop cannot guarantee that
|
|
93
98
|
a server-side tool already running has stopped. Custom editor conversations
|
|
94
99
|
should use their own adapter and keep their existing operation/storage owner.
|
|
100
|
+
|
|
101
|
+
## Shared optional controls
|
|
102
|
+
|
|
103
|
+
The shared status row shows “Assistant is working…” before output and answers
|
|
104
|
+
stream as they arrive. Pass safe `connections: [{ id, label }]` with
|
|
105
|
+
`v-model:integration-id` to offer the shared model chooser; list every selectable
|
|
106
|
+
ID in server `aiIntegrationIds`. Use `configuration-mode="hidden"` for a supplied
|
|
107
|
+
fixed connection or `readonly` to show its fixed choice.
|
|
108
|
+
|
|
109
|
+
The optional `suggestions`, `attachments`, `goal` and `activity` props use the
|
|
110
|
+
embeddable conversation contract. Goals are disabled unless a real backend
|
|
111
|
+
scheduler is supplied. Suggestions use their own generator and agent policy.
|
|
112
|
+
File bytes and upload authorization belong to the app's `assistant.attachments`
|
|
113
|
+
resolver and upload routes. See the Assistant and Embeddable assistant
|
|
114
|
+
conversations guides for exact callback shapes.
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
2
|
+
import { createAiConnectionResolver } from "@jskit-ai/connectors-catalog/server/ai";
|
|
3
|
+
import { createEnvironmentReferenceResolver } from "@jskit-ai/connectors-core/server";
|
|
4
|
+
import configuration from "../integrations.json" with { type: "json" };
|
|
5
|
+
|
|
6
|
+
// Register this in the application's ordinary server feature list.
|
|
7
|
+
export const ApplicationAiFeature = defineFeature({
|
|
8
|
+
id: "app.ai", requires: { env: "runtime.env" }, provides: { ai: "integrations.ai" },
|
|
9
|
+
setup({ env }) {
|
|
10
|
+
return { ai: createAiConnectionResolver({
|
|
11
|
+
configuration,
|
|
12
|
+
authorize(context) {
|
|
13
|
+
if (!context.actor?.id) return null;
|
|
14
|
+
// Apply the app's AI-use policy here; the actor comes from authentication.
|
|
15
|
+
return { applicationId: "my-app", subjectId: String(context.actor.id) };
|
|
16
|
+
},
|
|
17
|
+
resolveReference: createEnvironmentReferenceResolver(env)
|
|
18
|
+
}) };
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, onMounted, onScopeDispose, reactive, ref, toRef, watchPostEffect } from "vue";
|
|
2
|
+
import { computed, onMounted, onScopeDispose, reactive, ref, toRef, watch, watchPostEffect } from "vue";
|
|
3
3
|
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
4
4
|
import { useAssistantRuntime } from "../composables/useAssistantRuntime.js";
|
|
5
5
|
import { mapAssistantConversationTurns } from "../support/assistantRuntimeState.js";
|
|
@@ -10,9 +10,18 @@ const props = defineProps({
|
|
|
10
10
|
assistantLabel: { type: String, default: "Assistant" },
|
|
11
11
|
welcomeMessage: { type: String, default: "What would you like to do?" },
|
|
12
12
|
placeholder: { type: String, default: "Message the assistant…" },
|
|
13
|
-
showToolActivity: { type: Boolean, default: true }
|
|
13
|
+
showToolActivity: { type: Boolean, default: true },
|
|
14
|
+
connections: { type: Array, default: () => [] },
|
|
15
|
+
attachments: { type: Object, default: null },
|
|
16
|
+
suggestions: { type: Object, default: null },
|
|
17
|
+
goal: { type: Object, default: null },
|
|
18
|
+
activity: { type: Object, default: undefined },
|
|
19
|
+
configurationMode: { type: String, default: "editable", validator: value => ["hidden", "readonly", "editable"].includes(value) }
|
|
14
20
|
});
|
|
15
|
-
const
|
|
21
|
+
const integrationId = defineModel("integrationId", { type: String, default: "" });
|
|
22
|
+
const draftIntegrationId = ref(integrationId.value);
|
|
23
|
+
watch(integrationId, value => { draftIntegrationId.value = value; });
|
|
24
|
+
const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId"), integrationId });
|
|
16
25
|
const state = reactive(runtime.state);
|
|
17
26
|
const conversation = ref(null);
|
|
18
27
|
const root = ref(null);
|
|
@@ -34,6 +43,20 @@ onMounted(() => {
|
|
|
34
43
|
const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
|
|
35
44
|
const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
|
|
36
45
|
const adapter = computed(() => ({
|
|
46
|
+
attachments: props.attachments,
|
|
47
|
+
suggestions: props.suggestions,
|
|
48
|
+
goal: props.goal,
|
|
49
|
+
activity: props.activity,
|
|
50
|
+
models: props.connections.length && props.configurationMode !== "hidden" ? {
|
|
51
|
+
providerRows: [{ id: "application", label: "Application AI" }],
|
|
52
|
+
modelProviderId: "application", modelRows: props.connections, modelId: draftIntegrationId.value,
|
|
53
|
+
selectionSummary: props.connections.find(connection => connection.id === integrationId.value)?.label || "Application default",
|
|
54
|
+
changesDisabled: props.configurationMode !== "editable" || state.isStreaming,
|
|
55
|
+
canSave: props.configurationMode === "editable" && !state.isStreaming && draftIntegrationId.value !== integrationId.value &&
|
|
56
|
+
props.connections.some(connection => connection.id === draftIntegrationId.value),
|
|
57
|
+
selectModel(value) { draftIntegrationId.value = value; },
|
|
58
|
+
apply() { integrationId.value = draftIntegrationId.value; }
|
|
59
|
+
} : null,
|
|
37
60
|
conversation: {
|
|
38
61
|
turns: turns.value,
|
|
39
62
|
scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
|
|
@@ -43,7 +66,8 @@ const adapter = computed(() => ({
|
|
|
43
66
|
},
|
|
44
67
|
composer: {
|
|
45
68
|
draft: state.input,
|
|
46
|
-
canSend: state.canSend
|
|
69
|
+
canSend: state.canSend || Boolean(props.attachments?.enabled !== false && props.attachments?.attachments?.length &&
|
|
70
|
+
!state.isStreaming && !state.isRestoringConversation && state.hasRuntimeScope),
|
|
47
71
|
disabled: !state.hasRuntimeScope,
|
|
48
72
|
canStop: state.isStreaming,
|
|
49
73
|
stopPending: state.isCanceling,
|
|
@@ -55,7 +79,10 @@ const adapter = computed(() => ({
|
|
|
55
79
|
},
|
|
56
80
|
actions: {
|
|
57
81
|
setDraft(value) { state.input = value; },
|
|
58
|
-
submit: runtime.actions.sendMessage
|
|
82
|
+
submit: ({ attachments }) => runtime.actions.sendMessage({
|
|
83
|
+
attachments,
|
|
84
|
+
onAccepted: () => props.attachments?.clearAttachments({ accepted: true, attachmentIds: attachments.map(file => file.attachmentId) })
|
|
85
|
+
}),
|
|
59
86
|
stop: runtime.actions.cancelStream
|
|
60
87
|
}
|
|
61
88
|
}));
|
|
@@ -96,11 +123,13 @@ defineExpose({ focus: () => conversation.value?.focus() });
|
|
|
96
123
|
</v-menu>
|
|
97
124
|
</div>
|
|
98
125
|
<AssistantConversationElement ref="conversation" :adapter="adapter" label="Conversation" class="assistant-surface__conversation">
|
|
126
|
+
<template v-if="$slots.activity" #activity="scope"><slot name="activity" v-bind="scope" /></template>
|
|
127
|
+
<template v-if="$slots.attachments" #attachments="scope"><slot name="attachments" v-bind="scope" /></template>
|
|
99
128
|
<template #message-actions="{ message }">
|
|
100
129
|
<p v-if="message.status === 'failed' && message.error" class="assistant-surface__message-error" role="status">{{ message.error }}</p>
|
|
101
130
|
<p v-if="message.status === 'interrupted'" class="assistant-surface__notice" role="status">Response canceled. A tool already running may still finish.</p>
|
|
102
131
|
</template>
|
|
103
|
-
<template #
|
|
132
|
+
<template #composer-feedback>
|
|
104
133
|
<p v-if="state.error && !state.messages.some((message) => message.error === state.error)" class="assistant-surface__message-error" role="status">{{ state.error }}</p>
|
|
105
134
|
</template>
|
|
106
135
|
<template #composer-tools><slot name="composer-tools" :runtime="runtime" /></template>
|
|
@@ -135,7 +135,7 @@ function createRuntimeApi({ overrideApi = null, resolveBasePath, resolveSurfaceI
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
138
|
+
function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" } = {}) {
|
|
139
139
|
const runtimePolicy = resolveRuntimePolicy();
|
|
140
140
|
const queryClient = useQueryClient();
|
|
141
141
|
const errorRuntime = useShellWebErrorRuntime();
|
|
@@ -425,8 +425,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
425
425
|
abortController.value.abort();
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
-
async function sendMessage() {
|
|
429
|
-
const normalizedInput = normalizeText(input.value).slice(0, MAX_INPUT_CHARS);
|
|
428
|
+
async function sendMessage({ attachments = [], onAccepted } = {}) {
|
|
429
|
+
const normalizedInput = normalizeText(input.value).slice(0, MAX_INPUT_CHARS) || (attachments.length ? "Please review the attached files." : "");
|
|
430
430
|
if (!normalizedInput || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
|
|
431
431
|
return;
|
|
432
432
|
}
|
|
@@ -441,6 +441,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
441
441
|
role: "user",
|
|
442
442
|
kind: "chat",
|
|
443
443
|
text: normalizedInput,
|
|
444
|
+
attachments,
|
|
444
445
|
status: "done"
|
|
445
446
|
});
|
|
446
447
|
|
|
@@ -472,6 +473,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
472
473
|
messageId,
|
|
473
474
|
...(parsedConversationId ? { conversationId: parsedConversationId } : {}),
|
|
474
475
|
input: normalizedInput,
|
|
476
|
+
...(attachments.length ? { attachmentIds: attachments.map(file => file.attachmentId) } : {}),
|
|
477
|
+
...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {}),
|
|
475
478
|
history
|
|
476
479
|
},
|
|
477
480
|
{
|
|
@@ -481,6 +484,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
481
484
|
const eventType = normalizeAssistantStreamEventType(event?.type, "");
|
|
482
485
|
|
|
483
486
|
if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
|
|
487
|
+
onAccepted?.();
|
|
484
488
|
conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
|
|
485
489
|
writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
|
|
486
490
|
return;
|
|
@@ -503,7 +507,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
503
507
|
const text = String(event?.text || "");
|
|
504
508
|
updateMessage(assistantMessageId, {
|
|
505
509
|
text,
|
|
506
|
-
status: "done"
|
|
510
|
+
status: event.status === "streaming" ? "streaming" : "done"
|
|
507
511
|
});
|
|
508
512
|
return;
|
|
509
513
|
}
|
|
@@ -60,7 +60,8 @@ function buildHistory(messages) {
|
|
|
60
60
|
})
|
|
61
61
|
.map((message) => ({
|
|
62
62
|
role: message.role,
|
|
63
|
-
content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
|
|
63
|
+
content: String(message.text || "").slice(0, MAX_INPUT_CHARS),
|
|
64
|
+
...(message.role === "user" && message.attachments?.length ? { attachmentIds: message.attachments.map(file => file.attachmentId) } : {})
|
|
64
65
|
}));
|
|
65
66
|
|
|
66
67
|
return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
|
|
@@ -123,6 +124,7 @@ function mapTranscriptEntriesToAssistantState(entries) {
|
|
|
123
124
|
role,
|
|
124
125
|
kind: "chat",
|
|
125
126
|
text,
|
|
127
|
+
...(Array.isArray(metadata.attachments) ? { attachments: metadata.attachments } : {}),
|
|
126
128
|
status: "done"
|
|
127
129
|
});
|
|
128
130
|
if (role === "user") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
2
2
|
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
-
import {
|
|
3
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
4
|
+
import { createAiClient, createAiConnectionClient } from "@jskit-ai/assistant-core/server";
|
|
4
5
|
import { createAssistantActions } from "./actions.js";
|
|
5
6
|
import { registerRoutes } from "./registerRoutes.js";
|
|
6
7
|
import { createRepository as createAssistantConfigRepository } from "./repositories/assistantConfigRepository.js";
|
|
@@ -9,7 +10,7 @@ import { createRepository as createMessagesRepository } from "./repositories/mes
|
|
|
9
10
|
import { createService as createAssistantConfigService } from "./services/assistantConfigService.js";
|
|
10
11
|
import { createChatService } from "./services/chatService.js";
|
|
11
12
|
import { createTranscriptService } from "./services/transcriptService.js";
|
|
12
|
-
import { resolveAssistantAiConfig } from "./support/assistantServerConfig.js";
|
|
13
|
+
import { resolveAssistantAiConfig, resolveAssistantServerConfig } from "./support/assistantServerConfig.js";
|
|
13
14
|
import { createSurfaceAwareToolCatalog } from "./support/createSurfaceAwareToolCatalog.js";
|
|
14
15
|
|
|
15
16
|
function createAssistantAiClientFactory(config = {}) {
|
|
@@ -18,12 +19,24 @@ function createAssistantAiClientFactory(config = {}) {
|
|
|
18
19
|
const cache = new Map();
|
|
19
20
|
|
|
20
21
|
return Object.freeze({
|
|
21
|
-
resolveClient(targetSurfaceId = "") {
|
|
22
|
+
async resolveClient(targetSurfaceId = "", { context, integrationId } = {}) {
|
|
22
23
|
const normalizedTargetSurfaceId = normalizeText(targetSurfaceId).toLowerCase();
|
|
23
24
|
if (!normalizedTargetSurfaceId) {
|
|
24
25
|
throw new Error("assistant.ai.client.factory.resolveClient requires targetSurfaceId.");
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
const surfaceConfig = resolveAssistantServerConfig(appConfig, normalizedTargetSurfaceId);
|
|
29
|
+
if (surfaceConfig.aiIntegrationId) {
|
|
30
|
+
const selected = integrationId || surfaceConfig.aiIntegrationId;
|
|
31
|
+
if (![surfaceConfig.aiIntegrationId, ...surfaceConfig.aiIntegrationIds].includes(selected)) {
|
|
32
|
+
throw new AppError(403, "This AI connection is not available in this assistant.");
|
|
33
|
+
}
|
|
34
|
+
if (!config.aiConnections?.resolve) throw new Error("The assistant requires an authorized AI connection resolver.");
|
|
35
|
+
const connection = await config.aiConnections.resolve({ context, integrationId: selected });
|
|
36
|
+
return createAiConnectionClient(connection, { timeoutMs: surfaceConfig.timeoutMs });
|
|
37
|
+
}
|
|
38
|
+
if (integrationId) throw new AppError(403, "This assistant does not allow selecting an AI connection.");
|
|
39
|
+
|
|
27
40
|
if (cache.has(normalizedTargetSurfaceId)) {
|
|
28
41
|
return cache.get(normalizedTargetSurfaceId);
|
|
29
42
|
}
|
|
@@ -48,13 +61,15 @@ function createAssistantRuntime({
|
|
|
48
61
|
consoleRuntime,
|
|
49
62
|
database,
|
|
50
63
|
env,
|
|
64
|
+
aiConnections,
|
|
65
|
+
attachments,
|
|
51
66
|
workspaces
|
|
52
67
|
} = {}) {
|
|
53
68
|
const assistantConfigRepository = createAssistantConfigRepository(database.knex);
|
|
54
69
|
const conversationsRepository = createConversationsRepository(database.knex);
|
|
55
70
|
const messagesRepository = createMessagesRepository(database.knex);
|
|
56
71
|
const workspaceScopeSupport = workspaces?.scope || null;
|
|
57
|
-
const aiClientFactory = createAssistantAiClientFactory({ appConfig: config, env });
|
|
72
|
+
const aiClientFactory = createAssistantAiClientFactory({ appConfig: config, env, aiConnections });
|
|
58
73
|
const toolCatalog = createSurfaceAwareToolCatalog(actionCatalogue, { appConfig: config });
|
|
59
74
|
const configService = createAssistantConfigService({
|
|
60
75
|
assistantConfigRepository,
|
|
@@ -64,6 +79,7 @@ function createAssistantRuntime({
|
|
|
64
79
|
});
|
|
65
80
|
const transcriptService = createTranscriptService({ conversationsRepository, messagesRepository });
|
|
66
81
|
const chatService = createChatService({
|
|
82
|
+
attachments,
|
|
67
83
|
aiClientFactory,
|
|
68
84
|
transcriptService,
|
|
69
85
|
serviceToolCatalog: toolCatalog,
|
|
@@ -94,6 +110,8 @@ const AssistantFeature = defineFeature({
|
|
|
94
110
|
http: "runtime.http"
|
|
95
111
|
},
|
|
96
112
|
optional: {
|
|
113
|
+
aiConnections: "integrations.ai",
|
|
114
|
+
attachments: "assistant.attachments",
|
|
97
115
|
consoleRuntime: "console.core",
|
|
98
116
|
workspaces: "workspaces.core"
|
|
99
117
|
},
|
|
@@ -38,7 +38,8 @@ function normalizeHistory(history = []) {
|
|
|
38
38
|
|
|
39
39
|
return {
|
|
40
40
|
role,
|
|
41
|
-
content
|
|
41
|
+
content,
|
|
42
|
+
attachmentIds: role === "user" ? item.attachmentIds : []
|
|
42
43
|
};
|
|
43
44
|
})
|
|
44
45
|
.filter(Boolean);
|
|
@@ -71,6 +72,7 @@ function normalizeStreamInput(payload = {}) {
|
|
|
71
72
|
messageId,
|
|
72
73
|
conversationId: normalizeConversationId(source.conversationId),
|
|
73
74
|
input,
|
|
75
|
+
attachmentIds: source.attachmentIds,
|
|
74
76
|
history: normalizeHistory(source.history)
|
|
75
77
|
};
|
|
76
78
|
}
|
|
@@ -296,10 +298,10 @@ function sanitizeAssistantMessageText(value) {
|
|
|
296
298
|
}
|
|
297
299
|
|
|
298
300
|
const blockPatterns = [
|
|
299
|
-
/<[^>\n]*function_calls[^>\n]*>[\s\S]
|
|
300
|
-
/<[^>\n]*tool_calls?[^>\n]*>[\s\S]
|
|
301
|
-
/<[^>\n]*invoke\b[^>\n]*>[\s\S]
|
|
302
|
-
/<(?:analysis|reasoning|think)>[\s\S]
|
|
301
|
+
/<[^>\n]*function_calls[^>\n]*>[\s\S]*?(?:<\/[^>\n]*function_calls>|$)/gi,
|
|
302
|
+
/<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?(?:<\/[^>\n]*tool_calls?[^>\n]*>|$)/gi,
|
|
303
|
+
/<[^>\n]*invoke\b[^>\n]*>[\s\S]*?(?:<\/[^>\n]*invoke>|$)/gi,
|
|
304
|
+
/<(?:analysis|reasoning|think)>[\s\S]*?(?:<\/(?:analysis|reasoning|think)>|$)/gi
|
|
303
305
|
];
|
|
304
306
|
for (const pattern of blockPatterns) {
|
|
305
307
|
source = source.replace(pattern, " ");
|
|
@@ -380,7 +382,7 @@ function parseDsmlToolCallsFromText(value = "") {
|
|
|
380
382
|
return calls;
|
|
381
383
|
}
|
|
382
384
|
|
|
383
|
-
async function consumeCompletionStream(stream) {
|
|
385
|
+
async function consumeCompletionStream(stream, onText = () => {}) {
|
|
384
386
|
let assistantText = "";
|
|
385
387
|
const toolCallsByIndex = new Map();
|
|
386
388
|
|
|
@@ -391,6 +393,11 @@ async function consumeCompletionStream(stream) {
|
|
|
391
393
|
const textDelta = extractTextDelta(delta.content);
|
|
392
394
|
if (textDelta) {
|
|
393
395
|
assistantText += textDelta;
|
|
396
|
+
// An opening provider tag can be split between chunks. Keep it private
|
|
397
|
+
// until its boundary is known; complete internal blocks are stripped below.
|
|
398
|
+
const tagStart = assistantText.lastIndexOf("<");
|
|
399
|
+
const safeText = tagStart > assistantText.lastIndexOf(">") ? assistantText.slice(0, tagStart) : assistantText;
|
|
400
|
+
onText(sanitizeAssistantMessageText(safeText));
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
@@ -456,6 +463,7 @@ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
|
|
|
456
463
|
|
|
457
464
|
function createChatService({
|
|
458
465
|
aiClientFactory,
|
|
466
|
+
attachments,
|
|
459
467
|
transcriptService,
|
|
460
468
|
serviceToolCatalog,
|
|
461
469
|
assistantConfigService,
|
|
@@ -486,14 +494,16 @@ function createChatService({
|
|
|
486
494
|
|
|
487
495
|
async function streamChat(payload = {}, options = {}) {
|
|
488
496
|
const assistantSurface = requireAssistantSurface(resolveCurrentAppConfig(), payload?.targetSurfaceId);
|
|
489
|
-
const
|
|
497
|
+
const context = normalizeObject(options.context);
|
|
498
|
+
const assistantContext = buildAssistantActionContext(context, assistantSurface);
|
|
499
|
+
const workspace = resolveRuntimeWorkspace(assistantSurface, assistantContext, payload);
|
|
500
|
+
const aiClient = await aiClientFactory.resolveClient(assistantSurface.targetSurfaceId, {
|
|
501
|
+
context: assistantContext, integrationId: payload.integrationId
|
|
502
|
+
});
|
|
490
503
|
if (!aiClient.enabled) {
|
|
491
504
|
throw new AppError(503, "Assistant provider is not configured.");
|
|
492
505
|
}
|
|
493
506
|
|
|
494
|
-
const context = normalizeObject(options.context);
|
|
495
|
-
const assistantContext = buildAssistantActionContext(context, assistantSurface);
|
|
496
|
-
const workspace = resolveRuntimeWorkspace(assistantSurface, assistantContext, payload);
|
|
497
507
|
const source = normalizeStreamInput(payload);
|
|
498
508
|
const streamWriter = options.streamWriter;
|
|
499
509
|
if (!hasStreamWriter(streamWriter)) {
|
|
@@ -524,6 +534,27 @@ function createChatService({
|
|
|
524
534
|
throw new AppError(500, "Assistant failed to create conversation.");
|
|
525
535
|
}
|
|
526
536
|
|
|
537
|
+
async function resolveFiles(attachmentIds) {
|
|
538
|
+
if (attachmentIds === undefined || (Array.isArray(attachmentIds) && !attachmentIds.length)) return { attachments: [], content: [] };
|
|
539
|
+
if (!Array.isArray(attachmentIds) || attachmentIds.length > 10 ||
|
|
540
|
+
attachmentIds.some(id => typeof id !== "string" || !id || id.length > 256)) {
|
|
541
|
+
throw new AppError(400, "Invalid attachment identifiers.");
|
|
542
|
+
}
|
|
543
|
+
if (!attachments?.resolve || !aiClient.supportsAttachments) {
|
|
544
|
+
throw new AppError(400, "File attachments are not configured for this assistant.");
|
|
545
|
+
}
|
|
546
|
+
// Only the application can authorize uploads and turn stored bytes into
|
|
547
|
+
// provider input. Browser-supplied paths, URLs and file contents are never used.
|
|
548
|
+
return attachments.resolve({ attachmentIds, context: assistantContext, workspace, conversation });
|
|
549
|
+
}
|
|
550
|
+
const files = await resolveFiles(source.attachmentIds);
|
|
551
|
+
const history = [];
|
|
552
|
+
for (const message of source.history) {
|
|
553
|
+
const previousFiles = await resolveFiles(message.attachmentIds);
|
|
554
|
+
history.push({ role: message.role, content: previousFiles.content.length
|
|
555
|
+
? [{ type: "text", text: message.content }, ...previousFiles.content] : message.content });
|
|
556
|
+
}
|
|
557
|
+
|
|
527
558
|
await transcriptService.appendMessage(
|
|
528
559
|
assistantSurface,
|
|
529
560
|
conversationId,
|
|
@@ -533,7 +564,8 @@ function createChatService({
|
|
|
533
564
|
clientMessageSid: source.messageId,
|
|
534
565
|
contentText: source.input,
|
|
535
566
|
metadata: {
|
|
536
|
-
surfaceId: assistantSurface.targetSurfaceId
|
|
567
|
+
surfaceId: assistantSurface.targetSurfaceId,
|
|
568
|
+
...(files.attachments.length ? { attachments: files.attachments } : {})
|
|
537
569
|
}
|
|
538
570
|
},
|
|
539
571
|
{
|
|
@@ -566,10 +598,10 @@ function createChatService({
|
|
|
566
598
|
role: "system",
|
|
567
599
|
content: systemPrompt
|
|
568
600
|
},
|
|
569
|
-
...
|
|
601
|
+
...history,
|
|
570
602
|
{
|
|
571
603
|
role: "user",
|
|
572
|
-
content: source.input
|
|
604
|
+
content: files.content.length ? [{ type: "text", text: source.input }, ...files.content] : source.input
|
|
573
605
|
}
|
|
574
606
|
];
|
|
575
607
|
|
|
@@ -623,6 +655,23 @@ function createChatService({
|
|
|
623
655
|
};
|
|
624
656
|
}
|
|
625
657
|
|
|
658
|
+
let displayedText = "";
|
|
659
|
+
async function consumeResponse(stream) {
|
|
660
|
+
if (displayedText) {
|
|
661
|
+
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
|
|
662
|
+
displayedText = "";
|
|
663
|
+
}
|
|
664
|
+
return consumeCompletionStream(stream, text => {
|
|
665
|
+
if (text === displayedText) return;
|
|
666
|
+
if (text.startsWith(displayedText)) {
|
|
667
|
+
streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: text.slice(displayedText.length) });
|
|
668
|
+
} else {
|
|
669
|
+
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text, status: "streaming" });
|
|
670
|
+
}
|
|
671
|
+
displayedText = text;
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
626
675
|
async function executeToolCalls(toolCalls = [], { toolFailures = [], toolSuccesses = [] } = {}) {
|
|
627
676
|
const roundFailures = [];
|
|
628
677
|
|
|
@@ -745,7 +794,7 @@ function createChatService({
|
|
|
745
794
|
tools: [],
|
|
746
795
|
signal: options.abortSignal
|
|
747
796
|
});
|
|
748
|
-
const completion = await
|
|
797
|
+
const completion = await consumeResponse(completionStream);
|
|
749
798
|
|
|
750
799
|
const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
|
|
751
800
|
if (recoveryToolCalls.length > 0) {
|
|
@@ -824,7 +873,7 @@ function createChatService({
|
|
|
824
873
|
signal: options.abortSignal
|
|
825
874
|
});
|
|
826
875
|
|
|
827
|
-
const completion = await
|
|
876
|
+
const completion = await consumeResponse(completionStream);
|
|
828
877
|
|
|
829
878
|
const toolCalls = completion.toolCalls.filter((entry) => entry.name);
|
|
830
879
|
if (toolCalls.length < 1) {
|
|
@@ -18,6 +18,8 @@ function resolveAssistantServerConfigMap(appConfig = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
resolved[targetSurfaceId] = Object.freeze({
|
|
21
|
+
aiIntegrationId: normalizeText(entry.aiIntegrationId),
|
|
22
|
+
aiIntegrationIds: Object.freeze(normalizeUniqueTextList(entry.aiIntegrationIds)),
|
|
21
23
|
aiConfigPrefix: normalizeText(entry.aiConfigPrefix),
|
|
22
24
|
provider: normalizeText(entry.provider).toLowerCase(),
|
|
23
25
|
apiKey: normalizeText(entry.apiKey),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
4
|
+
import { createAssistantAiClientFactory } from "../src/server/AssistantProvider.js";
|
|
5
|
+
|
|
6
|
+
test("assistant connections authorize each request and resolve rotated and individual credentials afresh", async t => {
|
|
7
|
+
const keys = { alice: "alice-first", bob: "bob-only" };
|
|
8
|
+
const authorized = [];
|
|
9
|
+
const usedKeys = [];
|
|
10
|
+
const aiConnections = createAiConnectionResolver({
|
|
11
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
12
|
+
chat: { provider: "ai", accountMode: "per-user", scopes: [],
|
|
13
|
+
settings: { model: "openai/gpt-4.1-mini" }, authentication: { method: "api-key", secretRef: "account:chat" } }
|
|
14
|
+
} },
|
|
15
|
+
authorize(context, request) {
|
|
16
|
+
authorized.push([context.actor.id, request.integrationId]);
|
|
17
|
+
return context.actor.id === "denied" ? null : { applicationId: "example", subjectId: context.actor.id };
|
|
18
|
+
},
|
|
19
|
+
resolveReference(_reference, owner) { return keys[owner.subjectId]; }
|
|
20
|
+
});
|
|
21
|
+
t.mock.method(globalThis, "fetch", async (_url, options) => {
|
|
22
|
+
usedKeys.push(new Headers(options.headers).get("authorization"));
|
|
23
|
+
return new Response([
|
|
24
|
+
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
|
|
25
|
+
{ type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "Ready" },
|
|
26
|
+
{ type: "response.completed", response: { id: "resp_1", created_at: 1, model: "gpt-4.1-mini", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1 } } }
|
|
27
|
+
].map(data => `data: ${JSON.stringify(data)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
|
28
|
+
});
|
|
29
|
+
const factory = createAssistantAiClientFactory({
|
|
30
|
+
appConfig: { assistantServer: { admin: { aiIntegrationId: "chat" } } }, aiConnections
|
|
31
|
+
});
|
|
32
|
+
async function run(id) {
|
|
33
|
+
const client = await factory.resolveClient("admin", { context: { actor: { id } } });
|
|
34
|
+
return Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] }));
|
|
35
|
+
}
|
|
36
|
+
await run("alice");
|
|
37
|
+
keys.alice = "alice-rotated";
|
|
38
|
+
await run("alice");
|
|
39
|
+
await run("bob");
|
|
40
|
+
assert.deepEqual(usedKeys, ["Bearer alice-first", "Bearer alice-rotated", "Bearer bob-only"]);
|
|
41
|
+
delete keys.alice;
|
|
42
|
+
await assert.rejects(run("alice"), /API key is unavailable/u);
|
|
43
|
+
await assert.rejects(run("denied"), /denied/u);
|
|
44
|
+
await assert.rejects(factory.resolveClient("admin", { context: { actor: { id: "bob" } }, integrationId: "not-allowed" }), /not available/u);
|
|
45
|
+
assert.deepEqual(authorized, [["alice", "chat"], ["alice", "chat"], ["bob", "chat"], ["alice", "chat"], ["denied", "chat"]]);
|
|
46
|
+
assert.equal(usedKeys.length, 3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("existing explicit environment configuration remains available and rejects connection overrides", async () => {
|
|
50
|
+
const factory = createAssistantAiClientFactory({
|
|
51
|
+
appConfig: { assistantServer: { admin: { aiConfigPrefix: "ADMIN" } } },
|
|
52
|
+
env: { ADMIN_AI_PROVIDER: "openai", ADMIN_AI_API_KEY: "test-key", ADMIN_AI_MODEL: "chosen-model" }
|
|
53
|
+
});
|
|
54
|
+
const first = await factory.resolveClient("admin");
|
|
55
|
+
assert.equal(first.defaultModel, "chosen-model");
|
|
56
|
+
assert.equal(await factory.resolveClient("admin"), first);
|
|
57
|
+
await assert.rejects(factory.resolveClient("admin", { integrationId: "chat" }), /does not allow/u);
|
|
58
|
+
});
|
|
@@ -32,6 +32,8 @@ test("assistant server config resolves per-surface AI config without app-level g
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
assert.deepEqual(resolveAssistantServerConfig(appConfig, "admin"), {
|
|
35
|
+
aiIntegrationId: "",
|
|
36
|
+
aiIntegrationIds: [],
|
|
35
37
|
aiConfigPrefix: "ADMIN_ASSISTANT",
|
|
36
38
|
provider: "anthropic",
|
|
37
39
|
apiKey: "config-key",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import test from "node:test";
|
|
4
|
+
import { createAssistantAiClientFactory } from "../src/server/AssistantProvider.js";
|
|
4
5
|
import { parse as parseSfc } from "@vue/compiler-sfc";
|
|
5
6
|
|
|
6
7
|
const exampleRoot = new URL("../patterns/assistant-surface/example/", import.meta.url);
|
|
@@ -18,7 +19,14 @@ test("assistant surface pattern keeps product config explicit and secrets extern
|
|
|
18
19
|
settingsSurfaceId: "admin",
|
|
19
20
|
configScope: "global"
|
|
20
21
|
});
|
|
21
|
-
assert.equal(serverConfig.assistantServer.admin.
|
|
22
|
+
assert.equal(serverConfig.assistantServer.admin.aiIntegrationId, "assistant");
|
|
23
|
+
const { ApplicationAiFeature } = await import(new URL("src/ApplicationAiFeature.js", exampleRoot));
|
|
24
|
+
const { ai } = await ApplicationAiFeature.setup({ env: {} });
|
|
25
|
+
await assert.rejects(ai.resolve({ context: {}, integrationId: "assistant" }));
|
|
26
|
+
const client = await createAssistantAiClientFactory({ appConfig: serverConfig, env: {}, aiConnections: ai })
|
|
27
|
+
.resolveClient("admin", { context: { actor: { id: "fixture-user" } } });
|
|
28
|
+
assert.equal(client.enabled, true);
|
|
29
|
+
assert.equal(client.defaultModel, "big-pickle");
|
|
22
30
|
assert.doesNotMatch(JSON.stringify({ publicConfig, serverConfig }), /api[_-]?key|secret/iu);
|
|
23
31
|
assert.doesNotMatch(placementSource, /generator|scaffold|receipt|provenance/u);
|
|
24
32
|
|
|
@@ -69,7 +69,7 @@ async function* completionStream(completion = {}) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null } = {}) {
|
|
72
|
+
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
|
|
73
73
|
const pendingCompletions = [...completions];
|
|
74
74
|
const completionRequests = [];
|
|
75
75
|
const transcriptMessages = [];
|
|
@@ -89,10 +89,12 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
89
89
|
}));
|
|
90
90
|
|
|
91
91
|
const chatService = createChatService({
|
|
92
|
+
attachments,
|
|
92
93
|
aiClientFactory: {
|
|
93
94
|
resolveClient() {
|
|
94
95
|
return {
|
|
95
96
|
enabled: true,
|
|
97
|
+
supportsAttachments,
|
|
96
98
|
provider: "test",
|
|
97
99
|
defaultModel: "test-model",
|
|
98
100
|
async createChatCompletionStream(request) {
|
|
@@ -102,7 +104,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
102
104
|
});
|
|
103
105
|
const completion = pendingCompletions.shift();
|
|
104
106
|
assert.ok(completion, "Expected a queued assistant completion.");
|
|
105
|
-
return completionStream(completion);
|
|
107
|
+
return completion[Symbol.asyncIterator] ? completion : completionStream(completion);
|
|
106
108
|
}
|
|
107
109
|
};
|
|
108
110
|
}
|
|
@@ -172,13 +174,14 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
172
174
|
};
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
async function run(input = "Help me") {
|
|
177
|
+
async function run(input = "Help me", payload = {}) {
|
|
176
178
|
return chatService.streamChat(
|
|
177
179
|
{
|
|
178
180
|
targetSurfaceId: "assistant",
|
|
179
181
|
messageId: "message_1",
|
|
180
182
|
input,
|
|
181
|
-
history: []
|
|
183
|
+
history: [],
|
|
184
|
+
...payload
|
|
182
185
|
},
|
|
183
186
|
{
|
|
184
187
|
context: {
|
|
@@ -203,7 +206,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
203
206
|
|
|
204
207
|
function assistantMessages(events) {
|
|
205
208
|
return events
|
|
206
|
-
.filter((event) => event.method === "sendAssistantMessage")
|
|
209
|
+
.filter((event) => event.method === "sendAssistantMessage" && event.payload.status !== "streaming")
|
|
207
210
|
.map((event) => event.payload.text);
|
|
208
211
|
}
|
|
209
212
|
|
|
@@ -255,7 +258,7 @@ test("an empty authorized tool set does not advertise discovery or collection ac
|
|
|
255
258
|
assert.doesNotMatch(request.messages[0].content, /assistant_action_search|collection action/u);
|
|
256
259
|
});
|
|
257
260
|
|
|
258
|
-
test("progress-only output is
|
|
261
|
+
test("progress-only output is replaced on retry and current-time prompts require a workspace clock", async () => {
|
|
259
262
|
const harness = createHarness(
|
|
260
263
|
[
|
|
261
264
|
textCompletion("Let me query the current time."),
|
|
@@ -295,7 +298,7 @@ test("progress-only output is retried silently and current-time prompts require
|
|
|
295
298
|
true
|
|
296
299
|
);
|
|
297
300
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
|
|
298
|
-
assert.
|
|
301
|
+
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
299
302
|
assert.match(
|
|
300
303
|
harness.completionRequests[0].messages[0].content,
|
|
301
304
|
/first use any available authoritative workspace clock action/u
|
|
@@ -360,7 +363,7 @@ test("current-time preflight does not invent required tool input", async () => {
|
|
|
360
363
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["Choose a timezone before asking for its local time."]);
|
|
361
364
|
});
|
|
362
365
|
|
|
363
|
-
test("native search, contract, and execution workflows
|
|
366
|
+
test("native search, contract, and execution workflows stream progress across more than four tool rounds", async () => {
|
|
364
367
|
const harness = createHarness([
|
|
365
368
|
toolCompletion("action_search", 1, { text: "I'll search first." }),
|
|
366
369
|
toolCompletion("action_contract", 2, { text: "Let me inspect that contract." }),
|
|
@@ -377,7 +380,7 @@ test("native search, contract, and execution workflows can exceed four silent to
|
|
|
377
380
|
["action_search", "action_contract", "action_execute", "action_contract", "action_execute"]
|
|
378
381
|
);
|
|
379
382
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
|
|
380
|
-
assert.
|
|
383
|
+
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
381
384
|
const assistantToolMessages = harness.completionRequests
|
|
382
385
|
.flatMap((request) => request.messages)
|
|
383
386
|
.filter((message) => Array.isArray(message.tool_calls));
|
|
@@ -415,7 +418,7 @@ test("tool-loop exhaustion gives the concise new-conversation instruction", asyn
|
|
|
415
418
|
assert.equal(harness.executedTools.length, 16);
|
|
416
419
|
assert.equal(harness.completionRequests.length, 19);
|
|
417
420
|
assert.deepEqual(finalMessages, ["Limit reached. Start a new conversation."]);
|
|
418
|
-
assert.
|
|
421
|
+
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
419
422
|
});
|
|
420
423
|
|
|
421
424
|
test("tool-failure recovery retains the bounded latest successful result", async () => {
|
|
@@ -461,3 +464,72 @@ test("tool-failure recovery retains the bounded latest successful result", async
|
|
|
461
464
|
assert.match(fallback, /…\[truncated\]$/u);
|
|
462
465
|
assert.doesNotMatch(fallback, /Limit reached/u);
|
|
463
466
|
});
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
test("answer text reaches the client before the provider finishes, then persists once", async () => {
|
|
470
|
+
const firstChunk = Promise.withResolvers();
|
|
471
|
+
const continueResponse = Promise.withResolvers();
|
|
472
|
+
async function* response() {
|
|
473
|
+
yield { choices: [{ delta: { content: "The first sentence." } }] };
|
|
474
|
+
firstChunk.resolve();
|
|
475
|
+
await continueResponse.promise;
|
|
476
|
+
yield { choices: [{ delta: { content: " The second sentence." } }] };
|
|
477
|
+
}
|
|
478
|
+
const harness = createHarness([response()]);
|
|
479
|
+
const running = harness.run();
|
|
480
|
+
await firstChunk.promise;
|
|
481
|
+
try {
|
|
482
|
+
assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The first sentence."]);
|
|
483
|
+
assert.deepEqual(harness.completedConversations, []);
|
|
484
|
+
assert.deepEqual(harness.transcriptMessages.filter(message => message.role === "assistant"), []);
|
|
485
|
+
} finally {
|
|
486
|
+
continueResponse.resolve();
|
|
487
|
+
}
|
|
488
|
+
await running;
|
|
489
|
+
assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The first sentence.", " The second sentence."]);
|
|
490
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["The first sentence. The second sentence."]);
|
|
491
|
+
assert.deepEqual(harness.transcriptMessages.filter(message => message.role === "assistant").map(message => message.contentText), ["The first sentence. The second sentence."]);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("split internal tags and tool arguments never become answer deltas", async () => {
|
|
495
|
+
async function* response() {
|
|
496
|
+
for (const content of ["<th", "ink>private reasoning", "</thi", "nk>", "<|DSML|function_", "calls><|DSML|invoke name=\"action_search\">", '{"secret":"private arguments"}', "</|DSML|invoke></|DSML|function_calls>"]) {
|
|
497
|
+
yield { choices: [{ delta: { content } }] };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const harness = createHarness([response(), textCompletion("The search is complete.")]);
|
|
501
|
+
await harness.run();
|
|
502
|
+
assert.deepEqual(harness.executedTools.map(request => request.toolName), ["action_search"]);
|
|
503
|
+
assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The search is complete."]);
|
|
504
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["The search is complete."]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
test("application-authorized attachments reach the model and survive transcript restore", async () => {
|
|
509
|
+
const calls = [];
|
|
510
|
+
const receipt = { attachmentId: "file-one", fileName: "notes.txt", size: 12 };
|
|
511
|
+
const attachments = { async resolve(request) {
|
|
512
|
+
calls.push(request);
|
|
513
|
+
assert.equal(request.context.actor.id, "user_1");
|
|
514
|
+
assert.equal(request.conversation.id, "conversation_1");
|
|
515
|
+
if (request.attachmentIds[0] !== "file-one") throw new Error("Attachment access denied");
|
|
516
|
+
return { attachments: [receipt], content: [{ type: "text", text: "Authorized file bytes" }] };
|
|
517
|
+
} };
|
|
518
|
+
const harness = createHarness([textCompletion("I read your notes.")], { attachments });
|
|
519
|
+
await harness.run("Read this", { attachmentIds: ["file-one"], history: [
|
|
520
|
+
{ role: "user", content: "Previous file", attachmentIds: ["file-one"] }
|
|
521
|
+
] });
|
|
522
|
+
assert.equal(calls.length, 2, "Both current and historical files must be authorized");
|
|
523
|
+
assert.deepEqual(harness.transcriptMessages[0].metadata.attachments, [receipt]);
|
|
524
|
+
assert.deepEqual(harness.completionRequests[0].messages.at(-1).content, [
|
|
525
|
+
{ type: "text", text: "Read this" }, { type: "text", text: "Authorized file bytes" }
|
|
526
|
+
]);
|
|
527
|
+
const denied = createHarness([textCompletion("Must not run")], { attachments });
|
|
528
|
+
await assert.rejects(denied.run("Read this", { attachmentIds: ["another-users-file"] }), /access denied/);
|
|
529
|
+
assert.deepEqual(denied.completionRequests, []);
|
|
530
|
+
assert.deepEqual(denied.transcriptMessages, []);
|
|
531
|
+
const disabled = createHarness([textCompletion("Must not run")]);
|
|
532
|
+
await assert.rejects(disabled.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
|
|
533
|
+
const legacy = createHarness([textCompletion("Must not run")], { attachments, supportsAttachments: false });
|
|
534
|
+
await assert.rejects(legacy.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
|
|
535
|
+
});
|
|
@@ -258,3 +258,44 @@ test("page assistant bounds long history in a document layout and preserves draf
|
|
|
258
258
|
await stopProcess(vite);
|
|
259
259
|
}
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
test("the ready-made UI streams from the real chat service with selected AI connections and files", {
|
|
264
|
+
skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1", timeout: 90_000
|
|
265
|
+
}, async () => {
|
|
266
|
+
const vite = await startViteFixture({ fixtureRoot });
|
|
267
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
268
|
+
const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
|
|
269
|
+
try {
|
|
270
|
+
await page.request.get(`${vite.baseURL}/fixture/service`);
|
|
271
|
+
await page.goto(`${vite.baseURL}/?files=1`);
|
|
272
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
273
|
+
await expect(input).toBeEnabled();
|
|
274
|
+
await page.getByRole("button", { name: "Choose AI", exact: true }).click();
|
|
275
|
+
await page.getByRole("button", { name: "Deep model", exact: true }).click();
|
|
276
|
+
await page.getByRole("button", { name: "Apply", exact: true }).click();
|
|
277
|
+
await page.locator('input[type="file"]').setInputFiles({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("notes") });
|
|
278
|
+
await expect(page.getByText("notes.txt", { exact: true })).toBeVisible();
|
|
279
|
+
await input.fill("Read the notes");
|
|
280
|
+
await input.press("Enter");
|
|
281
|
+
await expect(page.getByText("Assistant is working…", { exact: true }).last()).toBeVisible();
|
|
282
|
+
await expect(page.getByLabel("Message attachments")).toHaveCount(0);
|
|
283
|
+
await expect(page.getByLabel("Attached files")).toBeVisible();
|
|
284
|
+
await input.fill("My next draft");
|
|
285
|
+
await input.evaluate(element => element.setSelectionRange(3, 7));
|
|
286
|
+
await page.request.get(`${vite.baseURL}/fixture/next`);
|
|
287
|
+
await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
|
|
288
|
+
await expect(input).toHaveValue("My next draft");
|
|
289
|
+
assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 7]);
|
|
290
|
+
let state = await (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
|
|
291
|
+
assert.equal(state.requests[0].integrationId, "deep");
|
|
292
|
+
assert.deepEqual(state.requests[0].attachmentIds, ["fixture-file"]);
|
|
293
|
+
assert.equal(state.transcript.filter(message => message.role === "assistant").length, 0);
|
|
294
|
+
await page.request.get(`${vite.baseURL}/fixture/next?finish=1`);
|
|
295
|
+
await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(1);
|
|
296
|
+
await expect(page.getByText("Assistant is working…", { exact: true })).toHaveCount(0);
|
|
297
|
+
state = await (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
|
|
298
|
+
assert.deepEqual(state.transcript[0].metadata.attachments, [{ attachmentId: "fixture-file", fileName: "notes.txt", size: 5 }]);
|
|
299
|
+
assert.equal(state.transcript.filter(message => message.role === "assistant").length, 1);
|
|
300
|
+
} finally { await browser.close(); await stopProcess(vite); }
|
|
301
|
+
});
|