@things-factory/ai-assistant 10.1.6 → 10.1.7

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 (44) hide show
  1. package/client/components/board-ai-chat.ts +165 -100
  2. package/client/components/chat-defaults.ts +13 -0
  3. package/client/components/chat-input-builder.test.ts +81 -0
  4. package/client/components/chat-input-builder.ts +43 -0
  5. package/client/components/chat-triggers.test.ts +72 -0
  6. package/client/components/chat-triggers.ts +62 -0
  7. package/dist-client/components/board-ai-chat.d.ts +8 -0
  8. package/dist-client/components/board-ai-chat.js +160 -100
  9. package/dist-client/components/board-ai-chat.js.map +1 -1
  10. package/dist-client/components/chat-defaults.d.ts +12 -0
  11. package/dist-client/components/chat-defaults.js +2 -0
  12. package/dist-client/components/chat-defaults.js.map +1 -1
  13. package/dist-client/components/chat-input-builder.d.ts +29 -0
  14. package/dist-client/components/chat-input-builder.js +22 -0
  15. package/dist-client/components/chat-input-builder.js.map +1 -1
  16. package/dist-client/components/chat-input-builder.test.d.ts +1 -0
  17. package/dist-client/components/chat-input-builder.test.js +67 -0
  18. package/dist-client/components/chat-input-builder.test.js.map +1 -0
  19. package/dist-client/components/chat-triggers.d.ts +16 -0
  20. package/dist-client/components/chat-triggers.js +42 -0
  21. package/dist-client/components/chat-triggers.js.map +1 -0
  22. package/dist-client/components/chat-triggers.test.d.ts +1 -0
  23. package/dist-client/components/chat-triggers.test.js +61 -0
  24. package/dist-client/components/chat-triggers.test.js.map +1 -0
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/service/assistant-chat-resolver.d.ts +32 -0
  27. package/dist-server/service/assistant-chat-resolver.js +342 -0
  28. package/dist-server/service/assistant-chat-resolver.js.map +1 -0
  29. package/dist-server/service/chat-session-resolver.js +18 -18
  30. package/dist-server/service/chat-session-resolver.js.map +1 -1
  31. package/dist-server/service/index.d.ts +3 -1
  32. package/dist-server/service/index.js +4 -0
  33. package/dist-server/service/index.js.map +1 -1
  34. package/dist-server/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +5 -5
  36. package/server/service/assistant-chat-resolver.ts +326 -0
  37. package/server/service/chat-session-resolver.ts +18 -18
  38. package/server/service/index.ts +4 -0
  39. package/test/translations.test.ts +79 -0
  40. package/translations/en.json +59 -0
  41. package/translations/ja.json +58 -0
  42. package/translations/ko.json +59 -0
  43. package/translations/ms.json +58 -0
  44. package/translations/zh.json +58 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.6",
3
+ "version": "10.1.7",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -29,10 +29,10 @@
29
29
  "@operato/graphql": "^10.0.0",
30
30
  "@operato/i18n": "^10.0.0",
31
31
  "@operato/styles": "^10.0.0",
32
- "@things-factory/ai-client-base": "^10.1.6",
33
- "@things-factory/auth-base": "^10.1.6",
32
+ "@things-factory/ai-client-base": "^10.1.7",
33
+ "@things-factory/auth-base": "^10.1.7",
34
34
  "@things-factory/env": "^10.1.3",
35
- "@things-factory/shell": "^10.1.4",
35
+ "@things-factory/shell": "^10.1.7",
36
36
  "dompurify": "^3.0.0",
37
37
  "graphql-scalars": "^1.22.0",
38
38
  "graphql-tag": "^2.12.6",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
47
+ "gitHead": "9a036f83f5751f9e4af9180e9a0018f716a4ba27"
48
48
  }
@@ -0,0 +1,326 @@
1
+ /*
2
+ * The neutral door into a conversation.
3
+ *
4
+ * ── Why this exists (2026-09-11) ───────────────────────────────────────────────
5
+ * The chat component moved here from `board-ai`, but the only mutation it could call stayed
6
+ * behind. operato-figure mounted the dock, registered four tools, and got
7
+ * `Cannot query field "boardAIChat" on type "Mutation"` — it does not load board-ai. The twin has
8
+ * the same shape and it does not show: its space deliberation runs through board-ai's door purely
9
+ * because the board authoring half of the same product already loaded that package.
10
+ *
11
+ * ── Why the board resolver was not moved ───────────────────────────────────────
12
+ * `board-ai-resolver.ts` is 692 lines and the larger part of it is board authoring itself:
13
+ * `currentBoard` as a BoardModel, PatchEntry, board patch broadcasts, refid injection for
14
+ * component mentions. Moving that here would teach this package the board document, which is the
15
+ * dependency we just cut, pointing the other way.
16
+ *
17
+ * So the door moved and the editor did not. `boardAIChat` stays in board-ai for board authoring;
18
+ * this one is for a surface that asks questions and runs registered tools.
19
+ *
20
+ * ── What this is not ───────────────────────────────────────────────────────────
21
+ * It has no document, so it takes no document and returns no patch. Tools that write to a
22
+ * document are not offered here — `registryAssistantChat` enforces that, and it decides what may
23
+ * be dispatched from the same list it offers, so the two cannot drift apart.
24
+ */
25
+ import { Arg, Ctx, Directive, Field, InputType, Mutation, ObjectType, Resolver } from 'type-graphql'
26
+ import { GraphQLJSON } from 'graphql-scalars'
27
+ import '@things-factory/auth-base'
28
+
29
+ import { config } from '@things-factory/env'
30
+ import { getRepository } from '@things-factory/shell'
31
+ import { createAIClient, getDefaultAIClient, registryAssistantChat, type AIClient } from '@things-factory/ai-client-base'
32
+
33
+ import { ChatSession } from './chat-session/chat-session.js'
34
+ import { ChatMessage } from './chat-message/chat-message.js'
35
+ import { buildLlmHistory } from './chat-message/llm-history.js'
36
+ import { publishChatMessage } from './chat-message/chat-message-publish.js'
37
+ import { publishSessionActivity } from './chat-session/session-activity-publish.js'
38
+ import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
39
+
40
+ /**
41
+ * How many stored messages reach the model.
42
+ *
43
+ * A deliberation runs for days. Without a cap the prompt grows without bound and the oldest turns
44
+ * bury the recent ones. Kept the same as the board conversation's cap so the two surfaces of one
45
+ * product do not remember differently.
46
+ */
47
+ const LLM_HISTORY_MAX_TURNS = 40
48
+
49
+ @InputType()
50
+ class AssistantMessageInput {
51
+ @Field({ description: "Role: 'user' or 'assistant'" })
52
+ role: string
53
+
54
+ @Field({ description: 'Message content' })
55
+ content: string
56
+ }
57
+
58
+ @InputType({ description: 'One turn of a conversation that has no document of its own.' })
59
+ class AssistantChatInput {
60
+ @Field({
61
+ nullable: true,
62
+ description: 'ChatSession id. Omit for a one-off question with no persistence.'
63
+ })
64
+ sessionId?: string
65
+
66
+ @Field(() => [AssistantMessageInput], {
67
+ description:
68
+ 'Conversation, newest last. For a persisted session only the last user message is appended; the rest is read from storage.'
69
+ })
70
+ messages: AssistantMessageInput[]
71
+
72
+ @Field({
73
+ description:
74
+ 'Who the assistant is on this surface, in the words of the product that owns the screen. The rules for using each tool come from whoever registered it and are appended by the server.'
75
+ })
76
+ systemPrompt: string
77
+
78
+ @Field(() => [String], {
79
+ nullable: true,
80
+ description:
81
+ 'Registered tool categories this conversation may use (registerToolCategory names, e.g. ["figure-authoring"]). Omit to allow every registered category. An empty list allows none — a plain conversation.'
82
+ })
83
+ toolCategories?: string[]
84
+
85
+ @Field(() => GraphQLJSON, {
86
+ nullable: true,
87
+ description:
88
+ 'What this surface is looking at (e.g. { figureId } while modelling). Tools read it so the model never has to ask the user for identifiers. Client-supplied — never used for authorization; a tool verifies the scope belongs to the caller tenant.'
89
+ })
90
+ hostContext?: any
91
+
92
+ @Field({
93
+ nullable: true,
94
+ description:
95
+ 'Last message to keep from the stored history. The client keeps its own truncation when a user edits a message, so pass the last kept id to hide the retracted span from the model.'
96
+ })
97
+ truncateAfterMessageId?: string
98
+
99
+ @Field({
100
+ nullable: true,
101
+ description:
102
+ 'Require a tool call before the model may answer. For surfaces that report live state, where an unchecked assertion reads the same as a checked one.'
103
+ })
104
+ requireGroundingTools?: boolean
105
+
106
+ @Field({ nullable: true, description: 'Model identifier override. Falls back to the configured default.' })
107
+ model?: string
108
+
109
+ @Field({ nullable: true, description: 'Sampling temperature override.' })
110
+ temperature?: number
111
+
112
+ @Field({ nullable: true, description: 'Max output tokens per call.' })
113
+ maxTokens?: number
114
+ }
115
+
116
+ @ObjectType()
117
+ class AssistantChatOutput {
118
+ @Field({ description: 'The reply, including the notice when the loop stopped early.' })
119
+ reply: string
120
+
121
+ @Field({ description: 'AI client identifier (provider:model).' })
122
+ clientId: string
123
+
124
+ @Field({ nullable: true, description: 'Echo of the session id, when persisted.' })
125
+ sessionId?: string
126
+
127
+ @Field({ nullable: true, description: 'Stored ChatMessage id of the user input.' })
128
+ userMessageId?: string
129
+
130
+ @Field({ nullable: true, description: 'Stored ChatMessage id of the reply.' })
131
+ assistantMessageId?: string
132
+
133
+ @Field(() => GraphQLJSON, {
134
+ nullable: true,
135
+ description: 'Tool usages during the turn — {name, arguments, result, kind}. The surface shows them foldably.'
136
+ })
137
+ toolUsages?: any
138
+
139
+ @Field(() => [String], {
140
+ nullable: true,
141
+ description:
142
+ 'Tool names offered to the model this turn. A surface that expected a category and sees it missing here knows the registration did not happen, rather than reading a vague answer as a refusal.'
143
+ })
144
+ offeredTools?: string[]
145
+
146
+ @Field(() => GraphQLJSON, {
147
+ nullable: true,
148
+ description:
149
+ 'Actions a tool recorded without executing — each carries what the tool returned alongside `proposed: true`. The surface renders a confirm button; executing is the user\'s act.'
150
+ })
151
+ proposals?: any
152
+
153
+ @Field(() => GraphQLJSON, {
154
+ nullable: true,
155
+ description:
156
+ 'Identifiers in the reply that appear in nothing the model received. Hallucination candidates surfaced to the user; the reply itself is not altered. Null when the answer is grounded.'
157
+ })
158
+ groundingWarnings?: any
159
+ }
160
+
161
+ function resolveAIClient(): AIClient | undefined {
162
+ const existing = getDefaultAIClient()
163
+ if (existing) return existing
164
+ const cfg = config.get('aiClient', null) as any
165
+ if (cfg && cfg.provider) return createAIClient(cfg)
166
+ return undefined
167
+ }
168
+
169
+ @Resolver()
170
+ export class AssistantChatResolver {
171
+ /*
172
+ * No `@transaction`, for the same reason the board conversation has none: a model call takes
173
+ * seconds to tens of seconds, and holding a write lock for that long collides with everything
174
+ * else on SQLite. Writes are short and around the call. For a conversation, a partly stored
175
+ * history is safer than a lost one.
176
+ */
177
+ @Mutation(() => AssistantChatOutput, {
178
+ description:
179
+ 'Ask the assistant a question on a surface that has no document of its own. Tools come from the registry; with a sessionId the conversation is persisted.'
180
+ })
181
+ /*
182
+ * `ai-assistant:mutation` — the same door as the board conversation's, which moved here on
183
+ * 2026-09-10. No `legacy` alias: this operation is new, so no one was ever passing under an old
184
+ * name, and declaring an alias would open a second name that nothing needs.
185
+ */
186
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
187
+ async assistantChat(@Arg('input') input: AssistantChatInput, @Ctx() context: any): Promise<AssistantChatOutput> {
188
+ const client = resolveAIClient()
189
+ if (!client) {
190
+ throw new Error(
191
+ 'No AI client configured. Set config.aiClient = { provider, model, apiKey } or call setDefaultAIClient(...).'
192
+ )
193
+ }
194
+
195
+ const { domain, user } = context.state
196
+ const sessionRepo = getRepository(ChatSession)
197
+ const messageRepo = getRepository(ChatMessage)
198
+
199
+ let session: ChatSession | undefined
200
+ if (input.sessionId) {
201
+ const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
202
+ if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
203
+ session = found
204
+ }
205
+
206
+ const lastUserMessage = input.messages[input.messages.length - 1]
207
+ let userMessageId: string | undefined
208
+
209
+ if (session && lastUserMessage?.role === 'user') {
210
+ const previousLast = await messageRepo.findOne({
211
+ where: { session: { id: session.id } as any },
212
+ order: { createdAt: 'DESC' }
213
+ })
214
+ const saved = await messageRepo.save({
215
+ session: { id: session.id } as any,
216
+ role: 'user',
217
+ content: lastUserMessage.content,
218
+ creator: user,
219
+ updater: user,
220
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
221
+ })
222
+ userMessageId = saved.id
223
+
224
+ /*
225
+ * Session activity is denormalised for the list: most-recent ordering and another
226
+ * participant's unread badge read it. Best effort — a conversation must not fail because a
227
+ * list sorts one position late.
228
+ */
229
+ try {
230
+ const patch: any = activityPatch(lastUserMessage.content)
231
+ if (!session.name) patch.name = autoSessionName(lastUserMessage.content)
232
+ await sessionRepo.update(session.id!, patch)
233
+ } catch {
234
+ /* noop */
235
+ }
236
+
237
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved } as any)
238
+ publishSessionActivity({
239
+ domainId: domain.id,
240
+ sessionId: session.id!,
241
+ kind: 'message',
242
+ anchorType: (session as any).anchorType,
243
+ anchorId: (session as any).anchorId,
244
+ preview: lastUserMessage.content,
245
+ actorId: user?.id
246
+ })
247
+ }
248
+
249
+ const conversation = session
250
+ ? buildLlmHistory(
251
+ await messageRepo.find({
252
+ where: { session: { id: session.id } as any },
253
+ order: { createdAt: 'ASC' },
254
+ relations: ['creator']
255
+ }),
256
+ {
257
+ maxTurns: LLM_HISTORY_MAX_TURNS,
258
+ truncateAfterMessageId: input.truncateAfterMessageId,
259
+ /* The message just stored is this turn's question — truncation must never drop it. */
260
+ keepMessageIds: userMessageId ? [userMessageId] : undefined,
261
+ summary: (session as any).lastSummary
262
+ ? { text: (session as any).lastSummary, upToMessageId: (session as any).summaryUpToMessageId }
263
+ : undefined
264
+ }
265
+ )
266
+ : input.messages.map(message => ({ role: message.role as any, content: message.content }))
267
+
268
+ const result = await registryAssistantChat({
269
+ client,
270
+ messages: conversation as any,
271
+ systemPrompt: input.systemPrompt,
272
+ toolCategories: input.toolCategories,
273
+ hostContext: input.hostContext,
274
+ state: context.state,
275
+ model: input.model,
276
+ temperature: input.temperature,
277
+ maxTokens: input.maxTokens,
278
+ requireToolOnFirstTurn: input.requireGroundingTools
279
+ })
280
+
281
+ let assistantMessageId: string | undefined
282
+ if (session) {
283
+ const stored = await messageRepo.save({
284
+ session: { id: session.id } as any,
285
+ role: 'assistant',
286
+ content: result.reply,
287
+ toolUsages: result.loop.toolUsages?.length ? JSON.stringify(result.loop.toolUsages) : undefined,
288
+ groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : undefined,
289
+ creator: user,
290
+ updater: user,
291
+ parentMessage: userMessageId ? ({ id: userMessageId } as any) : undefined
292
+ })
293
+ assistantMessageId = stored.id
294
+
295
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: stored } as any)
296
+ /* The reply is session activity too. Leaving it out shows other participants "nothing new". */
297
+ publishSessionActivity({
298
+ domainId: domain.id,
299
+ sessionId: session.id!,
300
+ kind: 'message',
301
+ anchorType: (session as any).anchorType,
302
+ anchorId: (session as any).anchorId,
303
+ preview: result.reply,
304
+ actorId: user?.id
305
+ })
306
+
307
+ await sessionRepo.update(session.id!, {
308
+ aiClientId: client.id,
309
+ updater: user,
310
+ ...activityPatch(result.reply)
311
+ } as any)
312
+ }
313
+
314
+ return {
315
+ reply: result.reply,
316
+ clientId: client.id,
317
+ sessionId: session?.id,
318
+ userMessageId,
319
+ assistantMessageId,
320
+ toolUsages: result.loop.toolUsages?.length ? result.loop.toolUsages : null,
321
+ offeredTools: result.offeredTools,
322
+ proposals: result.loop.proposals?.length ? result.loop.proposals : null,
323
+ groundingWarnings: result.groundingWarnings?.length ? result.groundingWarnings : null
324
+ }
325
+ }
326
+ }
@@ -68,7 +68,7 @@ class ChatSessionInboxEntry {
68
68
  @Resolver()
69
69
  export class ChatSessionResolver {
70
70
  @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
71
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
71
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
72
72
  async chatSession(
73
73
  @Arg('id') id: string,
74
74
  @Ctx() context: ResolverContext
@@ -81,7 +81,7 @@ export class ChatSessionResolver {
81
81
  description:
82
82
  'List AI chat sessions anchored to a target (oldest first). Anchor decides the nature and the history boundary of the conversation: board = authoring, space = the site and the twins running there. Board anchor also matches legacy rows that only have boardId.'
83
83
  })
84
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
84
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
85
85
  async chatSessionsByAnchor(
86
86
  @Arg('anchorType') anchorType: string,
87
87
  @Arg('anchorId') anchorId: string,
@@ -103,7 +103,7 @@ export class ChatSessionResolver {
103
103
  description:
104
104
  "List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
105
105
  })
106
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
106
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
107
107
  async chatSessionInbox(
108
108
  @Arg('anchorType') anchorType: string,
109
109
  @Arg('anchorId') anchorId: string,
@@ -132,7 +132,7 @@ export class ChatSessionResolver {
132
132
  description:
133
133
  'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
134
134
  })
135
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
135
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
136
136
  async chatSessionInboxTotal(
137
137
  @Arg('anchorType') anchorType: string,
138
138
  @Arg('anchorId') anchorId: string,
@@ -167,7 +167,7 @@ export class ChatSessionResolver {
167
167
  description:
168
168
  "Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
169
169
  })
170
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
170
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
171
171
  async hiddenSessionCount(
172
172
  @Arg('anchorType') anchorType: string,
173
173
  @Arg('anchorId') anchorId: string,
@@ -223,7 +223,7 @@ export class ChatSessionResolver {
223
223
  description:
224
224
  'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
225
225
  })
226
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
226
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
227
227
  async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
228
228
  const { domain, user } = context.state
229
229
  const repo = getRepository(ChatSession)
@@ -255,7 +255,7 @@ export class ChatSessionResolver {
255
255
  description:
256
256
  'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
257
257
  })
258
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
258
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
259
259
  async hideAISession(
260
260
  @Arg('sessionId') sessionId: string,
261
261
  @Ctx() context: ResolverContext,
@@ -301,7 +301,7 @@ export class ChatSessionResolver {
301
301
  description:
302
302
  'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
303
303
  })
304
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
304
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
305
305
  async renameAISession(
306
306
  @Arg('sessionId') sessionId: string,
307
307
  @Arg('name') name: string,
@@ -336,7 +336,7 @@ export class ChatSessionResolver {
336
336
  description:
337
337
  "Mark a session as read up to now for the current user. Idempotent. Distinct from presence (lastSeenAt) — a window can be open without being read."
338
338
  })
339
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
339
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
340
340
  async markSessionRead(
341
341
  @Arg('sessionId') sessionId: string,
342
342
  @Ctx() context: ResolverContext
@@ -356,7 +356,7 @@ export class ChatSessionResolver {
356
356
  description:
357
357
  'List users in the current domain for `@` mention popup. board-ai 권한이면 누구나 멘션용 검색 가능 (관리자 전용 users() 와 별도).'
358
358
  })
359
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
359
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
360
360
  async domainUsersForMention(
361
361
  @Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
362
362
  @Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
@@ -375,7 +375,7 @@ export class ChatSessionResolver {
375
375
  @Query(() => [ChatSessionParticipant], {
376
376
  description: 'List participants of a ChatSession (members / owner).'
377
377
  })
378
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
378
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
379
379
  async chatSessionParticipants(
380
380
  @Arg('sessionId') sessionId: string,
381
381
  @Ctx() context: ResolverContext
@@ -395,7 +395,7 @@ export class ChatSessionResolver {
395
395
  @Mutation(() => Boolean, {
396
396
  description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
397
397
  })
398
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
398
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
399
399
  async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
400
400
  const { domain, user } = context.state
401
401
  const repo = getRepository(ChatSessionParticipant)
@@ -408,7 +408,7 @@ export class ChatSessionResolver {
408
408
  }
409
409
 
410
410
  @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
411
- @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
411
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
412
412
  async chatMessages(
413
413
  @Arg('sessionId') sessionId: string,
414
414
  @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
@@ -436,7 +436,7 @@ export class ChatSessionResolver {
436
436
  description:
437
437
  'Start (or get existing) AI chat session anchored to a target. Idempotent — returns first existing or creates one. Use anchorType board for board authoring, space for a site (its co-located twins).'
438
438
  })
439
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
439
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
440
440
  async startAISession(
441
441
  @Arg('anchorType') anchorType: string,
442
442
  @Arg('anchorId') anchorId: string,
@@ -471,7 +471,7 @@ export class ChatSessionResolver {
471
471
  description:
472
472
  'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — 새 탭 열기.'
473
473
  })
474
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
474
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
475
475
  async createAISession(
476
476
  @Arg('anchorType') anchorType: string,
477
477
  @Arg('anchorId') anchorId: string,
@@ -511,7 +511,7 @@ export class ChatSessionResolver {
511
511
  @Mutation(() => Boolean, {
512
512
  description: 'Rename a ChatSession (tab label).'
513
513
  })
514
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
514
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
515
515
  async renameChatSession(
516
516
  @Arg('sessionId') sessionId: string,
517
517
  @Arg('name') name: string,
@@ -530,7 +530,7 @@ export class ChatSessionResolver {
530
530
  description:
531
531
  'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
532
532
  })
533
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
533
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
534
534
  async appendSessionNote(
535
535
  @Arg('sessionId') sessionId: string,
536
536
  @Arg('content') content: string,
@@ -580,7 +580,7 @@ export class ChatSessionResolver {
580
580
  description:
581
581
  'Carry a conversation from another surface into this session as its own turns (user / assistant), preserving order. Use when a lightweight exchange is promoted into a persistent discussion — the participants should read it as the conversation continuing, not as a machine dump.'
582
582
  })
583
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
583
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
584
584
  async appendSessionTurns(
585
585
  @Arg('sessionId') sessionId: string,
586
586
  @Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
@@ -1,4 +1,5 @@
1
1
  import { ChatSessionResolver } from './chat-session-resolver.js'
2
+ import { AssistantChatResolver } from './assistant-chat-resolver.js'
2
3
  import { MySessionActivitySubscription } from './chat-session/session-activity-subscription.js'
3
4
  import { ChatMessageSubscription } from './chat-message/chat-message-subscription.js'
4
5
  import { entities as ChatSessionEntities } from './chat-session/index.js'
@@ -7,6 +8,7 @@ import { entities as ChatSessionParticipantEntities } from './chat-session-parti
7
8
 
8
9
  export * from './types.js'
9
10
  export * from './chat-session-resolver.js'
11
+ export * from './assistant-chat-resolver.js'
10
12
  export * from './subscribe-auth.js'
11
13
  /* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
12
14
  export * from './chat-session/session-anchor.js'
@@ -30,6 +32,8 @@ export const entities = [...ChatSessionEntities, ...ChatMessageEntities, ...Chat
30
32
  export const schema = {
31
33
  resolverClasses: [
32
34
  ChatSessionResolver,
35
+ /* 문서를 갖지 않은 표면의 대화 — board 를 안 싣는 제품이 물어볼 수 있는 자리. */
36
+ AssistantChatResolver,
33
37
  ChatMessageSubscription,
34
38
  /* 내가 참여한 모든 세션의 활동 — 창을 닫아 둔 표면이 배지·목록을 갱신할 수 있게. */
35
39
  MySessionActivitySubscription
@@ -0,0 +1,79 @@
1
+ /*
2
+ * What this holds: **the chat's words live in the same package as the chat.**
3
+ *
4
+ * The chat component moved from `board-ai` to this package on 2026-09-10, but its translation
5
+ * files stayed behind. In an app that loads board-ai the strings still resolved, so nothing looked
6
+ * wrong. In an app that does not — operato-figure was the first — the dock opened with twelve raw
7
+ * keys on screen (`board-ai.text.triggers-heading`, ...). No build error, no console error.
8
+ *
9
+ * The keys are strings, so neither tsc nor eslint can see a key that has no entry. These tests are
10
+ * the only thing that can.
11
+ */
12
+ import { readFileSync, readdirSync } from 'fs'
13
+ import { join } from 'path'
14
+
15
+ const PACKAGE_ROOT = join(__dirname, '..')
16
+ const CLIENT_DIR = join(PACKAGE_ROOT, 'client')
17
+ const TRANSLATIONS_DIR = join(PACKAGE_ROOT, 'translations')
18
+
19
+ const KEY_IN_SOURCE = /'(ai-assistant\.[a-z0-9.-]+)'/g
20
+
21
+ function sourceFiles(dir: string): string[] {
22
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
23
+ const path = join(dir, entry.name)
24
+ if (entry.isDirectory()) return sourceFiles(path)
25
+ return /\.tsx?$/.test(entry.name) ? [path] : []
26
+ })
27
+ }
28
+
29
+ const files = sourceFiles(CLIENT_DIR)
30
+ const usedKeys = new Set<string>(
31
+ files.flatMap(file => [...readFileSync(file, 'utf8').matchAll(KEY_IN_SOURCE)].map(match => match[1]))
32
+ )
33
+
34
+ function locale(name: string): Record<string, string> {
35
+ return JSON.parse(readFileSync(join(TRANSLATIONS_DIR, `${name}.json`), 'utf8'))
36
+ }
37
+
38
+ /*
39
+ * An absence test passes for free once it stops finding anything to look at — a renamed directory,
40
+ * a changed key prefix, a moved component. So state the floor: this suite only means something
41
+ * while it is still reading the real client source and the real translation files.
42
+ */
43
+ describe('the suite examined something', () => {
44
+ it('reads the client source of this package', () => {
45
+ expect(files.length).toBeGreaterThanOrEqual(5)
46
+ expect(usedKeys.size).toBeGreaterThanOrEqual(30)
47
+ })
48
+
49
+ it('reads the translation files of this package', () => {
50
+ const locales = readdirSync(TRANSLATIONS_DIR).filter(name => name.endsWith('.json'))
51
+ expect(locales).toEqual(expect.arrayContaining(['en.json', 'ko.json']))
52
+ })
53
+ })
54
+
55
+ describe('every key the chat asks for has an entry', () => {
56
+ it('★ english — a missing entry renders the key itself on screen', () => {
57
+ const entries = locale('en')
58
+ const missing = [...usedKeys].filter(key => !(key in entries))
59
+ expect(missing).toEqual([])
60
+ })
61
+
62
+ it('★ korean — the same, for the language most of these screens run in', () => {
63
+ const entries = locale('ko')
64
+ const missing = [...usedKeys].filter(key => !(key in entries))
65
+ expect(missing).toEqual([])
66
+ })
67
+ })
68
+
69
+ describe('the keys belong to this package', () => {
70
+ it('★ no key still carries the board-ai prefix — the component is not in board-ai any more', () => {
71
+ const stale = files.filter(file => /'board-ai\.[a-z0-9.-]+'/.test(readFileSync(file, 'utf8')))
72
+ expect(stale).toEqual([])
73
+ })
74
+
75
+ it('every entry in en.json is an ai-assistant key', () => {
76
+ const wrongPrefix = Object.keys(locale('en')).filter(key => !key.startsWith('ai-assistant.'))
77
+ expect(wrongPrefix).toEqual([])
78
+ })
79
+ })