@jskit-ai/assistant-runtime 0.2.9 → 0.2.10
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/README.md +1 -1
- package/fixtures/canonical-conversation/main.js +2 -1
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/migrations/assistant_config_initial.cjs +1 -5
- package/migrations/assistant_transcripts_initial.cjs +3 -9
- package/migrations/assistant_transcripts_summary.cjs +28 -0
- package/migrations/assistant_turn_requests.cjs +2 -1
- package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
- package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
- package/package.json +17 -10
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
- package/src/client/components/AssistantConversationClientElement.vue +10 -4
- package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
- package/src/client/composables/conversationGoal.js +266 -0
- package/src/client/composables/conversationHistory.js +215 -0
- package/src/client/composables/conversationRuntime.js +624 -0
- package/src/client/composables/useAssistantConversation.js +208 -884
- package/src/client/composables/useAssistantRuntime.js +60 -45
- package/src/client/providers/AssistantClientProvider.js +1 -0
- package/src/client/support/assistantRuntimeState.js +6 -34
- package/src/client/support/conversationFailures.js +30 -0
- package/src/client/support/conversationRestoreSupport.js +13 -5
- package/src/client/support/subscribeAssistantConversation.js +32 -9
- package/src/client/support/workspaceScopeSupport.js +13 -22
- package/src/client/types.js +46 -0
- package/src/server/AssistantProvider.js +45 -17
- package/src/server/actions.js +162 -201
- package/src/server/conversationOperations.js +141 -0
- package/src/server/createAssistantRuntime.js +83 -44
- package/src/server/createAssistantSettingsActions.js +69 -47
- package/src/server/hostedAssistant.js +21 -0
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +34 -11
- package/src/server/registerFastifyConversations.js +18 -6
- package/src/server/registerRoutes.js +5 -2
- package/src/server/registerSettingsRoutes.js +1 -1
- package/src/server/repositories/assistantConfigRepository.js +10 -20
- package/src/server/repositories/conversationsRepository.js +35 -10
- package/src/server/repositories/messagesRepository.js +14 -10
- package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
- package/src/server/repositories/turnRequestsRepository.js +6 -1
- package/src/server/services/assistantConfigService.js +25 -23
- package/src/server/services/chatService.js +57 -61
- package/src/server/services/transcriptService.js +15 -7
- package/src/server/sqlConversationStorage.js +90 -18
- package/src/server/support/assistantServerConfig.js +1 -1
- package/src/server/support/assistantStreamWriter.js +7 -1
- package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
- package/src/server/types.js +38 -0
- package/src/shared/assistantSurfaces.js +2 -2
- package/test/assistantConversationOperations.test.js +5 -5
- package/test/assistantRuntimeState.test.js +1 -16
- package/test/chatServiceLifecycle.test.js +62 -154
- package/test/createSurfaceAwareToolCatalog.test.js +5 -0
- package/test/featureRuntime.test.js +12 -6
- package/test/lazyAppConfig.test.js +3 -5
- package/test/packageMetadata.test.js +7 -1
- package/test/sqlConversationStorage.mysql.test.js +89 -0
- package/test/sqlConversationStorage.test.js +161 -16
- package/test/support/memoryAssistantRows.js +6 -1
- package/test/support/scriptedModel.js +32 -0
- package/test/turnRequests.test.js +4 -2
- package/test/usersForeignKeysMigration.test.js +46 -0
- package/test/workspacesForeignKeysMigration.test.js +66 -0
- package/src/client/support/accessDenied.js +0 -6
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Main exports by subpath:
|
|
|
19
19
|
|
|
20
20
|
| Subpath | Provides |
|
|
21
21
|
| --- | --- |
|
|
22
|
-
| `@jskit-ai/assistant-runtime/server` | `AssistantFeature`, `createAssistantActions`, `registerAssistantRoutes`, `registerConversationSubscriptions`, `registerFastifyConversations` |
|
|
22
|
+
| `@jskit-ai/assistant-runtime/server` | `AssistantFeature`, `createAssistantActions`, `registerAssistantRoutes`, `registerConversationSubscriptions`, `registerFastifyConversations`, `createSurfaceAwareToolCatalog` |
|
|
23
23
|
| `@jskit-ai/assistant-runtime/server/actionIds` | Assistant action identifiers |
|
|
24
24
|
| `@jskit-ai/assistant-runtime/client` | `AssistantConversationClientElement`, `AssistantSurfaceClientElement`, `AssistantSettingsClientElement`, `useAssistantConversation`, `configureAssistantConversations` |
|
|
25
25
|
| `@jskit-ai/assistant-runtime/shared` | Assistant surface configuration helpers |
|
|
@@ -10,12 +10,13 @@ import { createWebPlacementRuntime } from "../../../shell-web/src/client/placeme
|
|
|
10
10
|
import { createFixtureSocket } from "./socket.js";
|
|
11
11
|
import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
|
|
12
12
|
import { configureAssistantConversations, useAssistantConversation } from "../../src/client/composables/useAssistantConversation.js";
|
|
13
|
+
import { silentLogger } from "@jskit-ai/kernel/shared/support/silentLogger";
|
|
13
14
|
|
|
14
15
|
globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
|
|
15
16
|
surfaceDefinitions: { home: { requiresWorkspace: false } },
|
|
16
17
|
assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
|
|
17
18
|
};
|
|
18
|
-
const placement = createWebPlacementRuntime({ components: new Map() });
|
|
19
|
+
const placement = createWebPlacementRuntime({ components: new Map(), logger: silentLogger });
|
|
19
20
|
placement.setContext({ user: { id: "42" }, surfaceConfig: {
|
|
20
21
|
defaultSurfaceId: "home", surfacesById: { home: { id: "home", routeBase: "/", requiresWorkspace: false } }
|
|
21
22
|
} });
|
|
@@ -8,6 +8,7 @@ import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
|
8
8
|
import "vuetify/styles";
|
|
9
9
|
import App from "./App.vue";
|
|
10
10
|
import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
|
|
11
|
+
import { silentLogger } from "@jskit-ai/kernel/shared/support/silentLogger";
|
|
11
12
|
|
|
12
13
|
globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
|
|
13
14
|
surfaceDefinitions: { admin: { requiresWorkspace: true }, console: { requiresWorkspace: false } },
|
|
@@ -18,7 +19,7 @@ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
|
|
|
18
19
|
};
|
|
19
20
|
const router = createRouter({ history: createWebHistory(), routes: [{ path: "/:pathMatch(.*)*", component: App }] });
|
|
20
21
|
const app = createApp(App);
|
|
21
|
-
const placement = createWebPlacementRuntime({ components: new Map() });
|
|
22
|
+
const placement = createWebPlacementRuntime({ components: new Map(), logger: silentLogger });
|
|
22
23
|
placement.setContext({ user: { id: "1" }, surfaceConfig: {
|
|
23
24
|
defaultSurfaceId: "admin",
|
|
24
25
|
surfacesById: { admin: { id: "admin", routeBase: "/", requiresWorkspace: false } }
|
|
@@ -3,6 +3,7 @@ import { createChatService } from "../../src/server/services/chatService.js";
|
|
|
3
3
|
import { ASSISTANT_STREAM_WRITER_METHODS } from "../../src/server/support/assistantStreamWriter.js";
|
|
4
4
|
import { createAssistantConversationRuntime } from "../../src/server/createAssistantRuntime.js";
|
|
5
5
|
import { createMemoryAssistantRows } from "../../test/support/memoryAssistantRows.js";
|
|
6
|
+
import { createScriptedModel } from "../../test/support/scriptedModel.js";
|
|
6
7
|
import { defineConfig } from "vite";
|
|
7
8
|
import vue from "@vitejs/plugin-vue";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -66,14 +67,14 @@ export default defineConfig({
|
|
|
66
67
|
turnRequests,
|
|
67
68
|
aiClientFactory: { async resolveClient(_surface, { integrationId }) {
|
|
68
69
|
return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
|
|
69
|
-
async *
|
|
70
|
+
model: createScriptedModel(async function* ({ messages }) {
|
|
70
71
|
stream.receivedFiles = messages.at(-1).content;
|
|
71
72
|
await nextChunk();
|
|
72
73
|
yield { choices: [{ delta: { content: "Partial answer" } }] };
|
|
73
74
|
await nextChunk();
|
|
74
75
|
yield { choices: [{ delta: { content: " completed." } }] };
|
|
75
76
|
yield { choices: [{ delta: {}, finish_reason: "stop" }] };
|
|
76
|
-
}
|
|
77
|
+
}, { model: integrationId })
|
|
77
78
|
};
|
|
78
79
|
} },
|
|
79
80
|
attachments: { async resolve({ attachmentIds }) {
|
|
@@ -118,7 +119,7 @@ export default defineConfig({
|
|
|
118
119
|
const older = url.searchParams.has("page[cursor]");
|
|
119
120
|
return json({ data: [{ type: "assistant-conversations", id: older ? "2" : "1", attributes: {
|
|
120
121
|
title: older ? "Older conversation" : "Saved conversation", status: "completed", startedAt: "2026-09-01T00:00:00Z"
|
|
121
|
-
} }], meta: {
|
|
122
|
+
} }], meta: { pagination: { cursor: { next: older ? null : "older" } } } });
|
|
122
123
|
}
|
|
123
124
|
res.statusCode = 404;
|
|
124
125
|
json({ error: "Unknown fixture request" });
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
exports.up = async function up(knex) {
|
|
2
2
|
const hasTable = await knex.schema.hasTable("assistant_config");
|
|
3
3
|
if (!hasTable) {
|
|
4
|
-
const hasWorkspacesTable = await knex.schema.hasTable("workspaces");
|
|
5
|
-
|
|
6
4
|
await knex.schema.createTable("assistant_config", (table) => {
|
|
7
5
|
table.bigIncrements("id").primary();
|
|
8
6
|
table.string("target_surface_id", 64).notNullable();
|
|
9
7
|
table.string("scope_key", 160).notNullable();
|
|
8
|
+
// constraints/assistant_workspaces_foreign_keys.cjs adds the workspaces key when that table exists.
|
|
10
9
|
table.bigInteger("workspace_id").unsigned().nullable();
|
|
11
|
-
if (hasWorkspacesTable) {
|
|
12
|
-
table.foreign("workspace_id").references("id").inTable("workspaces").onDelete("CASCADE");
|
|
13
|
-
}
|
|
14
10
|
table.text("system_prompt").notNullable().defaultTo("");
|
|
15
11
|
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
|
|
16
12
|
table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
+
// constraints/ adds the users keys, and the workspaces keys when that table exists.
|
|
1
2
|
exports.up = async function up(knex) {
|
|
2
|
-
const hasWorkspacesTable = await knex.schema.hasTable("workspaces");
|
|
3
3
|
const hasConversationsTable = await knex.schema.hasTable("assistant_conversations");
|
|
4
4
|
if (!hasConversationsTable) {
|
|
5
5
|
await knex.schema.createTable("assistant_conversations", (table) => {
|
|
6
6
|
table.bigIncrements("id").primary();
|
|
7
7
|
table.bigInteger("workspace_id").unsigned().nullable();
|
|
8
|
-
|
|
9
|
-
table.foreign("workspace_id").references("id").inTable("workspaces").onDelete("CASCADE");
|
|
10
|
-
}
|
|
11
|
-
table.bigInteger("created_by_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL").index();
|
|
8
|
+
table.bigInteger("created_by_user_id").unsigned().nullable().index();
|
|
12
9
|
table.string("title", 160).notNullable().defaultTo("New conversation");
|
|
13
10
|
table.string("status", 32).notNullable().defaultTo("active");
|
|
14
11
|
table.string("provider", 64).notNullable().defaultTo("");
|
|
@@ -33,14 +30,11 @@ exports.up = async function up(knex) {
|
|
|
33
30
|
table.bigIncrements("id").primary();
|
|
34
31
|
table.bigInteger("conversation_id").unsigned().notNullable().references("id").inTable("assistant_conversations").onDelete("CASCADE");
|
|
35
32
|
table.bigInteger("workspace_id").unsigned().nullable();
|
|
36
|
-
if (hasWorkspacesTable) {
|
|
37
|
-
table.foreign("workspace_id").references("id").inTable("workspaces").onDelete("CASCADE");
|
|
38
|
-
}
|
|
39
33
|
table.integer("seq").unsigned().notNullable();
|
|
40
34
|
table.string("role", 32).notNullable();
|
|
41
35
|
table.string("kind", 32).notNullable().defaultTo("chat");
|
|
42
36
|
table.string("client_message_sid", 128).notNullable().defaultTo("");
|
|
43
|
-
table.bigInteger("actor_user_id").unsigned().nullable().
|
|
37
|
+
table.bigInteger("actor_user_id").unsigned().nullable().index();
|
|
44
38
|
table.text("content_text").nullable();
|
|
45
39
|
table.text("metadata_json").nullable();
|
|
46
40
|
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A long conversation keeps one compaction summary next to its messages, which are never
|
|
2
|
+
// deleted: the summary text and the last turn it covers. transcript_revision changes on
|
|
3
|
+
// every saved transcript change, so a server can keep reusing the rows it already read.
|
|
4
|
+
const COLUMNS = ["summary_text", "summary_through_turn_id", "transcript_revision"];
|
|
5
|
+
|
|
6
|
+
exports.up = async function up(knex) {
|
|
7
|
+
const missing = [];
|
|
8
|
+
for (const column of COLUMNS) {
|
|
9
|
+
if (!await knex.schema.hasColumn("assistant_conversations", column)) missing.push(column);
|
|
10
|
+
}
|
|
11
|
+
if (!missing.length) return;
|
|
12
|
+
await knex.schema.alterTable("assistant_conversations", table => {
|
|
13
|
+
if (missing.includes("summary_text")) table.text("summary_text", "longtext").nullable();
|
|
14
|
+
if (missing.includes("summary_through_turn_id")) table.string("summary_through_turn_id", 64).nullable();
|
|
15
|
+
if (missing.includes("transcript_revision")) table.string("transcript_revision", 36).nullable();
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
exports.down = async function down(knex) {
|
|
20
|
+
const present = [];
|
|
21
|
+
for (const column of COLUMNS) {
|
|
22
|
+
if (await knex.schema.hasColumn("assistant_conversations", column)) present.push(column);
|
|
23
|
+
}
|
|
24
|
+
if (!present.length) return;
|
|
25
|
+
await knex.schema.alterTable("assistant_conversations", table => {
|
|
26
|
+
table.dropColumns(...present);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
@@ -2,7 +2,8 @@ exports.up = async function up(knex) {
|
|
|
2
2
|
if (await knex.schema.hasTable("assistant_turn_requests")) return;
|
|
3
3
|
await knex.schema.createTable("assistant_turn_requests", table => {
|
|
4
4
|
table.bigIncrements("id").primary();
|
|
5
|
-
|
|
5
|
+
// constraints/assistant_users_foreign_keys.cjs adds the users key once the users table exists.
|
|
6
|
+
table.bigInteger("actor_user_id").unsigned().notNullable();
|
|
6
7
|
table.string("scope_key", 128).notNullable();
|
|
7
8
|
table.string("message_sid", 128).notNullable();
|
|
8
9
|
table.string("claim_token", 36).notNullable();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The users table can come from the application's own migrations, which run after every
|
|
2
|
+
// package's table migrations, so the assistant tables get their users keys in this later phase.
|
|
3
|
+
// A database migrated before this file already has them from the table migrations.
|
|
4
|
+
const USER_KEYS = [
|
|
5
|
+
{ tableName: "assistant_conversations", columnName: "created_by_user_id", parentTable: "users", onDelete: "SET NULL" },
|
|
6
|
+
{ tableName: "assistant_messages", columnName: "actor_user_id", parentTable: "users", onDelete: "SET NULL" },
|
|
7
|
+
{ tableName: "assistant_turn_requests", columnName: "actor_user_id", parentTable: "users", onDelete: "CASCADE" }
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
exports.up = async function up(knex) {
|
|
11
|
+
const { addMissingForeignKey } = await import("@jskit-ai/database-runtime/server/foreignKeyMigrations");
|
|
12
|
+
for (const key of USER_KEYS) {
|
|
13
|
+
await addMissingForeignKey(knex, key);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
exports.down = async function down(knex) {
|
|
18
|
+
const { dropForeignKeyIfPresent } = await import("@jskit-ai/database-runtime/server/foreignKeyMigrations");
|
|
19
|
+
for (const key of USER_KEYS) {
|
|
20
|
+
await dropForeignKeyIfPresent(knex, key);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// workspaces-core's migrations run after assistant-runtime's, and an application may create
|
|
2
|
+
// workspaces itself, so the assistant tables get their workspaces keys in this later phase, when
|
|
3
|
+
// that table exists; an application without workspaces gets none.
|
|
4
|
+
// A database migrated before this file may already have them from the table migrations.
|
|
5
|
+
const WORKSPACE_KEYS = [
|
|
6
|
+
{ tableName: "assistant_config", columnName: "workspace_id", parentTable: "workspaces", onDelete: "CASCADE" },
|
|
7
|
+
{ tableName: "assistant_conversations", columnName: "workspace_id", parentTable: "workspaces", onDelete: "CASCADE" },
|
|
8
|
+
{ tableName: "assistant_messages", columnName: "workspace_id", parentTable: "workspaces", onDelete: "CASCADE" }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
exports.up = async function up(knex) {
|
|
12
|
+
if (!(await knex.schema.hasTable("workspaces"))) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const { addMissingForeignKey } = await import("@jskit-ai/database-runtime/server/foreignKeyMigrations");
|
|
16
|
+
for (const key of WORKSPACE_KEYS) {
|
|
17
|
+
await addMissingForeignKey(knex, key);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
exports.down = async function down(knex) {
|
|
22
|
+
const { dropForeignKeyIfPresent } = await import("@jskit-ai/database-runtime/server/foreignKeyMigrations");
|
|
23
|
+
for (const key of WORKSPACE_KEYS) {
|
|
24
|
+
await dropForeignKeyIfPresent(knex, key);
|
|
25
|
+
}
|
|
26
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -10,22 +10,23 @@
|
|
|
10
10
|
"./shared": "./src/shared/index.js",
|
|
11
11
|
"./server/actionIds": "./src/server/actionIds.js",
|
|
12
12
|
"./server": "./src/server/index.js",
|
|
13
|
+
"./server/types": "./src/server/types.js",
|
|
13
14
|
"./locales/en.json": "./locales/en.json"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
17
|
+
"@jskit-ai/assistant-core": "0.2.8",
|
|
17
18
|
"json-rest-schema": "^1.0.17"
|
|
18
19
|
},
|
|
19
20
|
"peerDependencies": {
|
|
20
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
21
|
-
"@jskit-ai/http-web": "0.2.
|
|
22
|
-
"@jskit-ai/kernel": "0.2.
|
|
23
|
-
"@jskit-ai/realtime": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
21
|
+
"@jskit-ai/http-runtime": "0.2.4",
|
|
22
|
+
"@jskit-ai/http-web": "0.2.8",
|
|
23
|
+
"@jskit-ai/kernel": "0.2.4",
|
|
24
|
+
"@jskit-ai/realtime": "0.2.7",
|
|
25
|
+
"@jskit-ai/shell-web": "0.2.4",
|
|
25
26
|
"@tanstack/vue-query": "^5.90.5",
|
|
26
27
|
"vue": "^3.5.13",
|
|
27
28
|
"vuetify": "^4.0.0",
|
|
28
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
29
|
+
"@jskit-ai/database-runtime": "0.1.253"
|
|
29
30
|
},
|
|
30
31
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
31
32
|
"jskit": {
|
|
@@ -38,8 +39,10 @@
|
|
|
38
39
|
"runtime.actions",
|
|
39
40
|
"runtime.config",
|
|
40
41
|
"runtime.env",
|
|
42
|
+
"runtime.events",
|
|
41
43
|
"runtime.http",
|
|
42
|
-
"runtime.http-web"
|
|
44
|
+
"runtime.http-web",
|
|
45
|
+
"runtime.logger"
|
|
43
46
|
]
|
|
44
47
|
},
|
|
45
48
|
"runtime": {
|
|
@@ -95,7 +98,11 @@
|
|
|
95
98
|
},
|
|
96
99
|
{
|
|
97
100
|
"subpath": "./server",
|
|
98
|
-
"summary": "Provides conversation routes, authorized shared realtime subscriptions, explicit Fastify hosting shorthand
|
|
101
|
+
"summary": "Provides conversation routes, authorized shared realtime subscriptions, explicit Fastify hosting shorthand, optional database integration and the per-surface assistant tool catalogue."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"subpath": "./server/types",
|
|
105
|
+
"summary": "Type of the assistant.runtime capability, and the contracts of the optional capabilities it reads."
|
|
99
106
|
}
|
|
100
107
|
],
|
|
101
108
|
"capabilities": [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
1
2
|
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
2
3
|
import { createAiConnectionResolver } from "@jskit-ai/connectors-catalog/server/ai";
|
|
3
4
|
import { createEnvironmentReferenceResolver } from "@jskit-ai/connectors-core/server";
|
|
@@ -10,9 +11,10 @@ export const ApplicationAiFeature = defineFeature({
|
|
|
10
11
|
return { ai: createAiConnectionResolver({
|
|
11
12
|
configuration,
|
|
12
13
|
authorize(context) {
|
|
13
|
-
|
|
14
|
+
const actorId = resolveActorId(context);
|
|
15
|
+
if (!actorId) return null;
|
|
14
16
|
// Apply the app's AI-use policy here; the actor comes from authentication.
|
|
15
|
-
return { applicationId: "my-app", subjectId:
|
|
17
|
+
return { applicationId: "my-app", subjectId: actorId };
|
|
16
18
|
},
|
|
17
19
|
resolveReference: createEnvironmentReferenceResolver(env)
|
|
18
20
|
}) };
|
|
@@ -4,6 +4,11 @@ import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/co
|
|
|
4
4
|
import { useAssistantConversation } from "../composables/useAssistantConversation.js";
|
|
5
5
|
import { useJskitText } from "../i18n.js";
|
|
6
6
|
|
|
7
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantApi} AssistantApi */
|
|
8
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantAttachments} AssistantAttachments */
|
|
9
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantSuggestions} AssistantSuggestions */
|
|
10
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantModelChoices} AssistantModelChoices */
|
|
11
|
+
|
|
7
12
|
const props = defineProps({
|
|
8
13
|
conversationId: { type: String, required: true },
|
|
9
14
|
endpoint: { type: String, default: "" },
|
|
@@ -11,7 +16,8 @@ const props = defineProps({
|
|
|
11
16
|
hostSurfaceId: { type: String, default: "" },
|
|
12
17
|
workspaceSlug: { type: String, default: undefined },
|
|
13
18
|
actorKey: { type: String, default: undefined },
|
|
14
|
-
|
|
19
|
+
// An application's own assistant API; without one the binding uses the surface's standard API.
|
|
20
|
+
api: { type: /** @type {import("vue").PropType<AssistantApi>} */ (Object), default: null },
|
|
15
21
|
active: { type: Boolean, default: true },
|
|
16
22
|
clearDraftOn: { type: String, default: undefined, validator: value => ["dispatch", "accepted"].includes(value) },
|
|
17
23
|
// Unset texts follow the active language; an application's own text is shown as given.
|
|
@@ -20,9 +26,9 @@ const props = defineProps({
|
|
|
20
26
|
placeholder: { type: String, default: undefined },
|
|
21
27
|
layout: { type: String, default: "compact", validator: value => ["page", "compact"].includes(value) },
|
|
22
28
|
data: { type: Object, default: undefined },
|
|
23
|
-
attachments: { type: Object, default: null },
|
|
24
|
-
suggestions: { type: Object, default: null },
|
|
25
|
-
models: { type: Object, default: null },
|
|
29
|
+
attachments: { type: /** @type {import("vue").PropType<AssistantAttachments>} */ (Object), default: null },
|
|
30
|
+
suggestions: { type: /** @type {import("vue").PropType<AssistantSuggestions>} */ (Object), default: null },
|
|
31
|
+
models: { type: /** @type {import("vue").PropType<AssistantModelChoices>} */ (Object), default: null },
|
|
26
32
|
questions: { type: [Boolean, Object], default: null },
|
|
27
33
|
goal: { type: [Boolean, Object], default: null },
|
|
28
34
|
avatarSize: { type: String, default: "compact", validator: (value) => ["hidden", "compact", "standard", "large"].includes(value) }
|
|
@@ -6,6 +6,10 @@ import { mapAssistantConversationTurns, toolStatusText } from "../support/assist
|
|
|
6
6
|
import { DEFAULT_CONVERSATION_TITLE } from "../../shared/assistantRuntimeConfig.js";
|
|
7
7
|
import { useJskitText } from "../i18n.js";
|
|
8
8
|
|
|
9
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantConversationAdapter} AssistantConversationAdapter */
|
|
10
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantAttachments} AssistantAttachments */
|
|
11
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantSuggestions} AssistantSuggestions */
|
|
12
|
+
|
|
9
13
|
const props = defineProps({
|
|
10
14
|
surfaceId: { type: String, required: true },
|
|
11
15
|
layout: { type: String, default: "page", validator: (value) => ["page", "compact"].includes(value) },
|
|
@@ -15,10 +19,10 @@ const props = defineProps({
|
|
|
15
19
|
placeholder: { type: String, default: undefined },
|
|
16
20
|
showToolActivity: { type: Boolean, default: true },
|
|
17
21
|
connections: { type: Array, default: () => [] },
|
|
18
|
-
attachments: { type: Object, default: null },
|
|
19
|
-
suggestions: { type: Object, default: null },
|
|
20
|
-
goal: { type: Object, default: null },
|
|
21
|
-
activity: { type: Object, default: undefined },
|
|
22
|
+
attachments: { type: /** @type {import("vue").PropType<AssistantAttachments>} */ (Object), default: null },
|
|
23
|
+
suggestions: { type: /** @type {import("vue").PropType<AssistantSuggestions>} */ (Object), default: null },
|
|
24
|
+
goal: { type: /** @type {import("vue").PropType<AssistantConversationAdapter["goal"]>} */ (Object), default: null },
|
|
25
|
+
activity: { type: /** @type {import("vue").PropType<AssistantConversationAdapter["activity"]>} */ (Object), default: undefined },
|
|
22
26
|
configurationMode: { type: String, default: "editable", validator: value => ["hidden", "readonly", "editable"].includes(value) }
|
|
23
27
|
});
|
|
24
28
|
const integrationId = defineModel("integrationId", { type: String, default: "" });
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, toValue, watch } from "vue";
|
|
2
|
+
import { failureMessage, isAccessDenied, resultError } from "../support/conversationFailures.js";
|
|
3
|
+
import { useJskitText } from "../i18n.js";
|
|
4
|
+
|
|
5
|
+
const { t } = useJskitText();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A retained conversation's goal: the goal read for the current segment and the Set, Resume, Pause and Cancel
|
|
9
|
+
* commands. The conversation calls its lifecycle operations; it starts no watcher of its own.
|
|
10
|
+
* @param {{
|
|
11
|
+
* api: import("@jskit-ai/assistant-core/client/types").AssistantApi,
|
|
12
|
+
* identity: import("../types.js").ConversationIdentity,
|
|
13
|
+
* readers: Map<symbol, import("../types.js").ConversationReader>,
|
|
14
|
+
* goalReadEnabled: boolean,
|
|
15
|
+
* current: import("vue").ComputedRef<boolean>,
|
|
16
|
+
* active: import("vue").ComputedRef<boolean>,
|
|
17
|
+
* available: import("vue").ComputedRef<boolean>,
|
|
18
|
+
* accessDenied: import("vue").Ref<boolean>,
|
|
19
|
+
* snapshot: import("vue").ShallowRef<any>,
|
|
20
|
+
* delivery: import("@jskit-ai/assistant-core/client/types").AssistantMessageDelivery,
|
|
21
|
+
* submitRequest: (payload: object, options: { messageId: string }) => Promise<any>,
|
|
22
|
+
* receiveError: (failure: any) => void,
|
|
23
|
+
* reloadSubscription: () => Promise<unknown> | undefined
|
|
24
|
+
* }} conversation
|
|
25
|
+
*/
|
|
26
|
+
function createConversationGoal({ api, identity, readers, goalReadEnabled, current, active, available, accessDenied, snapshot,
|
|
27
|
+
delivery, submitRequest, receiveError, reloadSubscription }) {
|
|
28
|
+
const goalAction = ref("");
|
|
29
|
+
// An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
|
|
30
|
+
const goalSettling = ref(false);
|
|
31
|
+
const goalTargetCurrent = ref(false);
|
|
32
|
+
const goalError = ref("");
|
|
33
|
+
const goalView = shallowRef(null);
|
|
34
|
+
const goalLoadError = ref("");
|
|
35
|
+
let goalRead = null;
|
|
36
|
+
let goalSegment = null;
|
|
37
|
+
let goalReadController = null;
|
|
38
|
+
let goalReadQueued = false;
|
|
39
|
+
let goalCommandSequence = 0;
|
|
40
|
+
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
41
|
+
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Follows a new conversation state: a new segment needs a new goal target, and a changed goal a fresh read.
|
|
45
|
+
* @param {any} state
|
|
46
|
+
* @param {any} previous The state it replaces.
|
|
47
|
+
* @param {{ initial: boolean }} options
|
|
48
|
+
*/
|
|
49
|
+
function receiveState(state, previous, { initial }) {
|
|
50
|
+
const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
|
|
51
|
+
previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
|
|
52
|
+
JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
|
|
53
|
+
if (state.segmentId !== goalSegment) {
|
|
54
|
+
// Keep the open goal menu while its first native identity arrives. The
|
|
55
|
+
// pending read disables commands until it supplies the new exact target.
|
|
56
|
+
goalTargetCurrent.value = false;
|
|
57
|
+
goalLoadError.value = "";
|
|
58
|
+
abortRead();
|
|
59
|
+
}
|
|
60
|
+
if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
|
|
61
|
+
goalSegment = state.segmentId;
|
|
62
|
+
}
|
|
63
|
+
/** Clears the goal shown with the conversation view. The open command, queued read and target stay. */
|
|
64
|
+
function clear() {
|
|
65
|
+
goalError.value = "";
|
|
66
|
+
goalView.value = null;
|
|
67
|
+
goalLoadError.value = "";
|
|
68
|
+
abortRead();
|
|
69
|
+
}
|
|
70
|
+
/** After lost access, commands wait for a target read with access again. */
|
|
71
|
+
function forgetTarget() {
|
|
72
|
+
goalTargetCurrent.value = false;
|
|
73
|
+
}
|
|
74
|
+
/** After the actor changed, no open command or queued read survives. */
|
|
75
|
+
function retire() {
|
|
76
|
+
goalAction.value = "";
|
|
77
|
+
goalSettling.value = false;
|
|
78
|
+
goalReadQueued = false;
|
|
79
|
+
}
|
|
80
|
+
/** Stops the goal read in flight. */
|
|
81
|
+
function abortRead() {
|
|
82
|
+
// readable-check: allow optional-collaborator - data: the controller of the goal read in flight, none between reads
|
|
83
|
+
goalReadController?.abort();
|
|
84
|
+
}
|
|
85
|
+
/** A goal command whose delivery was confirmed no longer shows its earlier error. */
|
|
86
|
+
function clearCommandError() {
|
|
87
|
+
goalError.value = "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function refreshGoal() {
|
|
91
|
+
if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
|
|
92
|
+
if (goalRead) { goalReadQueued = true; return goalRead; }
|
|
93
|
+
const segment = snapshot.value.segmentId;
|
|
94
|
+
const capabilities = snapshot.value.capabilities;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
goalReadController = controller;
|
|
97
|
+
const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
|
|
98
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
99
|
+
})).then(result => {
|
|
100
|
+
if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
|
|
101
|
+
// Hosts may supply a same-read target and product view. Standalone reads
|
|
102
|
+
// retain their existing goal/null contract and captured conversation target.
|
|
103
|
+
goalView.value = result && Object.hasOwn(result, "target") ? result : {
|
|
104
|
+
status: "available", goal: result, target: { segmentId: segment, capabilities }
|
|
105
|
+
};
|
|
106
|
+
// A background read does not invalidate this observation. Its supplied
|
|
107
|
+
// goal target may intentionally differ from the visible chat segment.
|
|
108
|
+
goalTargetCurrent.value = true;
|
|
109
|
+
goalLoadError.value = "";
|
|
110
|
+
return true;
|
|
111
|
+
}).catch(failure => {
|
|
112
|
+
if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
|
|
113
|
+
goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}).finally(() => {
|
|
117
|
+
if (goalRead !== job) return;
|
|
118
|
+
goalRead = null;
|
|
119
|
+
goalReadController = null;
|
|
120
|
+
if (goalReadQueued) {
|
|
121
|
+
goalReadQueued = false;
|
|
122
|
+
void refreshGoal();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
goalRead = job;
|
|
126
|
+
return job;
|
|
127
|
+
}
|
|
128
|
+
async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
|
|
129
|
+
const view = goalView.value;
|
|
130
|
+
const target = view?.target;
|
|
131
|
+
const command = target?.capabilities?.goalCommands?.[action];
|
|
132
|
+
// Pause and Cancel never depend on chat delivery: each is pinned to the goal
|
|
133
|
+
// it stops and goes straight to the goal operation, even while a message is
|
|
134
|
+
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
135
|
+
const stopping = action === "pause" || action === "cancel";
|
|
136
|
+
if (!available.value || view?.status !== "available" || goalLoadError.value ||
|
|
137
|
+
goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
|
|
138
|
+
!["message", "control"].includes(command?.delivery) ||
|
|
139
|
+
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
140
|
+
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
141
|
+
goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const retry = stopping ? null : delivery.find(messageId);
|
|
145
|
+
if (retry && retry.status !== "failed") return false;
|
|
146
|
+
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
147
|
+
expectedGoalId: view.goal?.id || null,
|
|
148
|
+
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
149
|
+
...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
|
|
150
|
+
const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
|
|
151
|
+
const sequence = ++goalCommandSequence;
|
|
152
|
+
let accepted = false;
|
|
153
|
+
goalAction.value = action;
|
|
154
|
+
goalSettling.value = false;
|
|
155
|
+
goalError.value = "";
|
|
156
|
+
try {
|
|
157
|
+
let result;
|
|
158
|
+
if (retry || !stopping && command.delivery === "message") {
|
|
159
|
+
result = await submitRequest(retry?.payload || {
|
|
160
|
+
message: action === "set" ? request.objective
|
|
161
|
+
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
162
|
+
goalRequest: request
|
|
163
|
+
}, { messageId });
|
|
164
|
+
if (result === false || result?.ok === false) {
|
|
165
|
+
if (current.value) {
|
|
166
|
+
goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
result = await api.updateConversationGoal(identity.conversationId,
|
|
172
|
+
command.delivery === "message" ? { ...request, messageId } : request);
|
|
173
|
+
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
174
|
+
}
|
|
175
|
+
accepted = true;
|
|
176
|
+
return result;
|
|
177
|
+
} catch (failure) {
|
|
178
|
+
if (current.value) {
|
|
179
|
+
goalError.value = failureMessage(failure);
|
|
180
|
+
if (isAccessDenied(failure)) receiveError(failure);
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
} finally {
|
|
184
|
+
// The command stays Starting, Resuming, Pausing or Cancelling until a goal
|
|
185
|
+
// read made after the server's answer arrives; until then the control
|
|
186
|
+
// shows no stale goal or enabled form. An accepted Set or Resume also
|
|
187
|
+
// waits while its goal waits behind the running turn.
|
|
188
|
+
if (current.value && goalCommandSequence === sequence) {
|
|
189
|
+
await goalRead;
|
|
190
|
+
await refreshGoal();
|
|
191
|
+
if (accepted && !stopping) await awaitGoalChange(before, sequence);
|
|
192
|
+
reloadSubscription();
|
|
193
|
+
}
|
|
194
|
+
if (goalCommandSequence === sequence) {
|
|
195
|
+
goalAction.value = "";
|
|
196
|
+
goalSettling.value = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// An accepted Set or Resume changes the server's goal, now or once the turn
|
|
201
|
+
// it waits behind has ended. When the conversation stops working without
|
|
202
|
+
// that change, a fresh read decides and the reason is shown. A later Pause
|
|
203
|
+
// or Cancel ends the wait.
|
|
204
|
+
const goalChangedFrom = before => {
|
|
205
|
+
const goal = goalView.value?.goal;
|
|
206
|
+
return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
|
|
207
|
+
};
|
|
208
|
+
async function awaitGoalChange(before, sequence) {
|
|
209
|
+
const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
|
|
210
|
+
await reloadSubscription();
|
|
211
|
+
if (!settled()) goalSettling.value = true;
|
|
212
|
+
while (!settled() && snapshot.value?.status === "working") {
|
|
213
|
+
await new Promise(resolve => {
|
|
214
|
+
const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (settled()) return;
|
|
218
|
+
await goalRead;
|
|
219
|
+
await refreshGoal();
|
|
220
|
+
if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
|
|
221
|
+
}
|
|
222
|
+
const goalState = computed(() => {
|
|
223
|
+
const view = goalView.value;
|
|
224
|
+
if (!readsGoals.value) return null;
|
|
225
|
+
const capabilities = view?.target?.capabilities || {};
|
|
226
|
+
const commands = capabilities.goalCommands || {};
|
|
227
|
+
const loading = !goalTargetCurrent.value && !goalLoadError.value;
|
|
228
|
+
const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
|
|
229
|
+
const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
|
|
230
|
+
return {
|
|
231
|
+
enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
|
|
232
|
+
loading,
|
|
233
|
+
available: Boolean(goalAvailable),
|
|
234
|
+
action: goalAction.value,
|
|
235
|
+
working: snapshot.value?.status === "working",
|
|
236
|
+
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
237
|
+
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
238
|
+
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
239
|
+
pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
|
|
240
|
+
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
241
|
+
stopPending,
|
|
242
|
+
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
243
|
+
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
244
|
+
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
245
|
+
...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
|
|
246
|
+
["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
readsGoals,
|
|
252
|
+
goalState,
|
|
253
|
+
goalView,
|
|
254
|
+
goalLoadError,
|
|
255
|
+
refreshGoal,
|
|
256
|
+
changeGoal,
|
|
257
|
+
receiveState,
|
|
258
|
+
clear,
|
|
259
|
+
forgetTarget,
|
|
260
|
+
retire,
|
|
261
|
+
abortRead,
|
|
262
|
+
clearCommandError
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { createConversationGoal };
|