@jskit-ai/assistant-runtime 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/conversation/main.js +2 -1
  4. package/fixtures/conversation/vite.config.mjs +4 -3
  5. package/migrations/assistant_config_initial.cjs +1 -5
  6. package/migrations/assistant_transcripts_initial.cjs +3 -9
  7. package/migrations/assistant_transcripts_summary.cjs +28 -0
  8. package/migrations/assistant_turn_requests.cjs +2 -1
  9. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  10. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  11. package/package.json +17 -10
  12. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  13. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  14. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  15. package/src/client/composables/conversationGoal.js +266 -0
  16. package/src/client/composables/conversationHistory.js +215 -0
  17. package/src/client/composables/conversationRuntime.js +624 -0
  18. package/src/client/composables/useAssistantConversation.js +208 -884
  19. package/src/client/composables/useAssistantRuntime.js +60 -45
  20. package/src/client/providers/AssistantClientProvider.js +1 -0
  21. package/src/client/support/assistantRuntimeState.js +6 -34
  22. package/src/client/support/conversationFailures.js +30 -0
  23. package/src/client/support/conversationRestoreSupport.js +13 -5
  24. package/src/client/support/subscribeAssistantConversation.js +32 -9
  25. package/src/client/support/workspaceScopeSupport.js +13 -22
  26. package/src/client/types.js +46 -0
  27. package/src/server/AssistantProvider.js +45 -17
  28. package/src/server/actions.js +162 -201
  29. package/src/server/conversationOperations.js +141 -0
  30. package/src/server/createAssistantRuntime.js +83 -44
  31. package/src/server/createAssistantSettingsActions.js +69 -47
  32. package/src/server/hostedAssistant.js +21 -0
  33. package/src/server/index.js +1 -0
  34. package/src/server/registerConversationSubscriptions.js +34 -11
  35. package/src/server/registerFastifyConversations.js +18 -6
  36. package/src/server/registerRoutes.js +5 -2
  37. package/src/server/registerSettingsRoutes.js +1 -1
  38. package/src/server/repositories/assistantConfigRepository.js +10 -20
  39. package/src/server/repositories/conversationsRepository.js +35 -10
  40. package/src/server/repositories/messagesRepository.js +14 -10
  41. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  42. package/src/server/repositories/turnRequestsRepository.js +6 -1
  43. package/src/server/services/assistantConfigService.js +25 -23
  44. package/src/server/services/chatService.js +57 -61
  45. package/src/server/services/transcriptService.js +15 -7
  46. package/src/server/sqlConversationStorage.js +90 -18
  47. package/src/server/support/assistantServerConfig.js +1 -1
  48. package/src/server/support/assistantStreamWriter.js +7 -1
  49. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  50. package/src/server/types.js +38 -0
  51. package/src/shared/assistantSurfaces.js +2 -2
  52. package/test/assistantConversationOperations.test.js +5 -5
  53. package/test/assistantRuntimeState.test.js +1 -16
  54. package/test/chatServiceLifecycle.test.js +62 -154
  55. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  56. package/test/featureRuntime.test.js +12 -6
  57. package/test/lazyAppConfig.test.js +3 -5
  58. package/test/packageMetadata.test.js +7 -1
  59. package/test/sqlConversationStorage.mysql.test.js +89 -0
  60. package/test/sqlConversationStorage.test.js +161 -16
  61. package/test/support/memoryAssistantRows.js +6 -1
  62. package/test/support/scriptedModel.js +32 -0
  63. package/test/turnRequests.test.js +4 -2
  64. package/test/usersForeignKeysMigration.test.js +46 -0
  65. package/test/workspacesForeignKeysMigration.test.js +66 -0
  66. 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 *createChatCompletionStream({ messages }) {
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: { page: { nextCursor: older ? null : "older" } } });
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
- if (hasWorkspacesTable) {
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().references("id").inTable("users").onDelete("SET NULL").index();
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
- table.bigInteger("actor_user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
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.9",
3
+ "version": "0.2.11",
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.7",
17
+ "@jskit-ai/assistant-core": "0.2.9",
17
18
  "json-rest-schema": "^1.0.17"
18
19
  },
19
20
  "peerDependencies": {
20
- "@jskit-ai/http-runtime": "0.2.3",
21
- "@jskit-ai/http-web": "0.2.7",
22
- "@jskit-ai/kernel": "0.2.3",
23
- "@jskit-ai/realtime": "0.2.6",
24
- "@jskit-ai/shell-web": "0.2.3",
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.252"
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 and optional database integration."
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
- if (!context.actor?.id) return null;
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: String(context.actor.id) };
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
- api: { type: Object, default: null },
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 };