@things-factory/ai-assistant 10.1.26 → 10.1.28

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 (194) hide show
  1. package/client/components/assistant-chat.ts +653 -59
  2. package/client/components/assistant-mode.test.ts +86 -55
  3. package/client/components/assistant-mode.ts +124 -82
  4. package/client/components/chat-echo-dedup.test.ts +23 -0
  5. package/client/components/chat-echo-dedup.ts +29 -3
  6. package/client/components/chat-input-builder.test.ts +20 -20
  7. package/client/components/chat-input-builder.ts +16 -14
  8. package/client/components/chat-proposal-survival.test.ts +73 -0
  9. package/client/components/mention-popup.ts +31 -8
  10. package/client/components/panel-aria.test.ts +34 -0
  11. package/client/components/proposal-outcome-line.ts +33 -0
  12. package/client/utils/assistant-session-controller.test.ts +75 -0
  13. package/client/utils/assistant-session-controller.ts +23 -0
  14. package/client/utils/assistant-session-transport.ts +11 -3
  15. package/dist-client/components/assistant-chat.d.ts +128 -13
  16. package/dist-client/components/assistant-chat.js +613 -67
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-mode.d.ts +80 -68
  19. package/dist-client/components/assistant-mode.js +63 -19
  20. package/dist-client/components/assistant-mode.js.map +1 -1
  21. package/dist-client/components/assistant-mode.test.js +74 -53
  22. package/dist-client/components/assistant-mode.test.js.map +1 -1
  23. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  24. package/dist-client/components/chat-echo-dedup.js +27 -4
  25. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  26. package/dist-client/components/chat-echo-dedup.test.js +20 -0
  27. package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
  28. package/dist-client/components/chat-input-builder.d.ts +12 -7
  29. package/dist-client/components/chat-input-builder.js +8 -10
  30. package/dist-client/components/chat-input-builder.js.map +1 -1
  31. package/dist-client/components/chat-input-builder.test.js +17 -18
  32. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  33. package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
  34. package/dist-client/components/chat-proposal-survival.test.js +61 -0
  35. package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
  36. package/dist-client/components/mention-popup.js +28 -8
  37. package/dist-client/components/mention-popup.js.map +1 -1
  38. package/dist-client/components/panel-aria.test.d.ts +1 -0
  39. package/dist-client/components/panel-aria.test.js +31 -0
  40. package/dist-client/components/panel-aria.test.js.map +1 -0
  41. package/dist-client/components/proposal-outcome-line.d.ts +9 -0
  42. package/dist-client/components/proposal-outcome-line.js +15 -0
  43. package/dist-client/components/proposal-outcome-line.js.map +1 -0
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  46. package/dist-client/utils/assistant-session-controller.js +16 -0
  47. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  48. package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-session-controller.test.js +56 -0
  50. package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-transport.js +11 -3
  52. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  53. package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
  54. package/dist-server/service/assistant-capability-resolver.js +88 -0
  55. package/dist-server/service/assistant-capability-resolver.js.map +1 -0
  56. package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
  57. package/dist-server/service/assistant-chat-resolver.js +166 -44
  58. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  59. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +25 -0
  60. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +140 -0
  61. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
  62. package/dist-server/service/assistant-mode/index.d.ts +9 -0
  63. package/dist-server/service/assistant-mode/index.js +19 -0
  64. package/dist-server/service/assistant-mode/index.js.map +1 -0
  65. package/dist-server/service/assistant-mode/registry.d.ts +18 -0
  66. package/dist-server/service/assistant-mode/registry.js +136 -0
  67. package/dist-server/service/assistant-mode/registry.js.map +1 -0
  68. package/dist-server/service/assistant-mode/resolve.d.ts +33 -0
  69. package/dist-server/service/assistant-mode/resolve.js +60 -0
  70. package/dist-server/service/assistant-mode/resolve.js.map +1 -0
  71. package/dist-server/service/assistant-mode/types.d.ts +99 -0
  72. package/dist-server/service/assistant-mode/types.js +12 -0
  73. package/dist-server/service/assistant-mode/types.js.map +1 -0
  74. package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
  75. package/dist-server/service/chat-message/carried-turns.js +80 -0
  76. package/dist-server/service/chat-message/carried-turns.js.map +1 -0
  77. package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
  78. package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
  79. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  80. package/dist-server/service/chat-message/chat-message.d.ts +32 -0
  81. package/dist-server/service/chat-message/chat-message.js +17 -0
  82. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  83. package/dist-server/service/chat-message/door-access.d.ts +15 -0
  84. package/dist-server/service/chat-message/door-access.js +26 -0
  85. package/dist-server/service/chat-message/door-access.js.map +1 -0
  86. package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
  87. package/dist-server/service/chat-message/fold-session-history.js +133 -0
  88. package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
  89. package/dist-server/service/chat-message/llm-history.d.ts +7 -0
  90. package/dist-server/service/chat-message/llm-history.js +23 -11
  91. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  92. package/dist-server/service/chat-message/message-doors.d.ts +29 -0
  93. package/dist-server/service/chat-message/message-doors.js +107 -0
  94. package/dist-server/service/chat-message/message-doors.js.map +1 -0
  95. package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
  96. package/dist-server/service/chat-message/proposal-outcome.js +31 -0
  97. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
  98. package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
  99. package/dist-server/service/chat-message/reader-gate.js +37 -0
  100. package/dist-server/service/chat-message/reader-gate.js.map +1 -0
  101. package/dist-server/service/chat-message/room-message.d.ts +4 -0
  102. package/dist-server/service/chat-message/room-message.js +25 -0
  103. package/dist-server/service/chat-message/room-message.js.map +1 -0
  104. package/dist-server/service/chat-session/chat-session.d.ts +24 -0
  105. package/dist-server/service/chat-session/chat-session.js +26 -2
  106. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  107. package/dist-server/service/chat-session/session-inbox.d.ts +4 -1
  108. package/dist-server/service/chat-session/session-inbox.js +3 -2
  109. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  110. package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
  111. package/dist-server/service/chat-session/session-visibility.js +28 -0
  112. package/dist-server/service/chat-session/session-visibility.js.map +1 -0
  113. package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
  114. package/dist-server/service/chat-session/station-session-guard.js +7 -2
  115. package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
  116. package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
  117. package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
  118. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
  119. package/dist-server/service/chat-session-resolver.d.ts +15 -0
  120. package/dist-server/service/chat-session-resolver.js +267 -15
  121. package/dist-server/service/chat-session-resolver.js.map +1 -1
  122. package/dist-server/service/grounding-sources.d.ts +14 -0
  123. package/dist-server/service/grounding-sources.js +26 -0
  124. package/dist-server/service/grounding-sources.js.map +1 -0
  125. package/dist-server/service/index.d.ts +8 -1
  126. package/dist-server/service/index.js +19 -0
  127. package/dist-server/service/index.js.map +1 -1
  128. package/dist-server/service/request-translator.d.ts +2 -0
  129. package/dist-server/service/request-translator.js +20 -0
  130. package/dist-server/service/request-translator.js.map +1 -0
  131. package/dist-server/service/testing/conversation-script.d.ts +123 -0
  132. package/dist-server/service/testing/conversation-script.js +202 -0
  133. package/dist-server/service/testing/conversation-script.js.map +1 -0
  134. package/dist-server/service/testing/mode-conformance.d.ts +40 -0
  135. package/dist-server/service/testing/mode-conformance.js +90 -0
  136. package/dist-server/service/testing/mode-conformance.js.map +1 -0
  137. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  138. package/dist-server/tool-registry-boot-report.js +25 -2
  139. package/dist-server/tool-registry-boot-report.js.map +1 -1
  140. package/dist-server/tsconfig.tsbuildinfo +1 -1
  141. package/package.json +5 -5
  142. package/server/explicit-graphql-types.test.ts +56 -0
  143. package/server/service/assistant-capability-resolver.ts +66 -0
  144. package/server/service/assistant-chat-resolver.ts +184 -42
  145. package/server/service/assistant-mode/assistant-mode-resolver.ts +102 -0
  146. package/server/service/assistant-mode/index.ts +16 -0
  147. package/server/service/assistant-mode/registry.test.ts +105 -0
  148. package/server/service/assistant-mode/registry.ts +147 -0
  149. package/server/service/assistant-mode/resolve.test.ts +78 -0
  150. package/server/service/assistant-mode/resolve.ts +96 -0
  151. package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
  152. package/server/service/assistant-mode/types.ts +103 -0
  153. package/server/service/chat-message/carried-turns.test.ts +69 -0
  154. package/server/service/chat-message/carried-turns.ts +99 -0
  155. package/server/service/chat-message/chat-message-subscription.ts +20 -4
  156. package/server/service/chat-message/chat-message.ts +44 -0
  157. package/server/service/chat-message/door-access.ts +45 -0
  158. package/server/service/chat-message/fold-session-history.test.ts +98 -0
  159. package/server/service/chat-message/fold-session-history.ts +115 -0
  160. package/server/service/chat-message/llm-history.test.ts +58 -7
  161. package/server/service/chat-message/llm-history.ts +30 -11
  162. package/server/service/chat-message/message-doors.test.ts +87 -0
  163. package/server/service/chat-message/message-doors.ts +117 -0
  164. package/server/service/chat-message/proposal-outcome.test.ts +48 -0
  165. package/server/service/chat-message/proposal-outcome.ts +42 -0
  166. package/server/service/chat-message/reader-gate.ts +42 -0
  167. package/server/service/chat-message/room-message.test.ts +21 -0
  168. package/server/service/chat-message/room-message.ts +22 -0
  169. package/server/service/chat-session/chat-session.ts +31 -1
  170. package/server/service/chat-session/session-inbox.test.ts +18 -0
  171. package/server/service/chat-session/session-inbox.ts +9 -2
  172. package/server/service/chat-session/session-visibility.test.ts +36 -0
  173. package/server/service/chat-session/session-visibility.ts +26 -0
  174. package/server/service/chat-session/station-session-guard.ts +10 -2
  175. package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
  176. package/server/service/chat-session-participant/mention-invite.ts +121 -0
  177. package/server/service/chat-session-resolver.ts +272 -15
  178. package/server/service/grounding-sources.test.ts +55 -0
  179. package/server/service/grounding-sources.ts +46 -0
  180. package/server/service/index.ts +19 -0
  181. package/server/service/request-translator.test.ts +45 -0
  182. package/server/service/request-translator.ts +33 -0
  183. package/server/service/station-session-guard.test.ts +12 -5
  184. package/server/service/testing/conversation-scenario.test.ts +163 -0
  185. package/server/service/testing/conversation-script.test.ts +131 -0
  186. package/server/service/testing/conversation-script.ts +288 -0
  187. package/server/service/testing/mode-conformance.ts +107 -0
  188. package/server/tool-registry-boot-report.ts +25 -2
  189. package/test/assistant-chat-call-sites.test.ts +82 -0
  190. package/translations/en.json +44 -2
  191. package/translations/ja.json +44 -2
  192. package/translations/ko.json +44 -2
  193. package/translations/ms.json +44 -2
  194. package/translations/zh.json +44 -2
@@ -0,0 +1,288 @@
1
+ /*
2
+ * A conversation with several people in it, written as a script, so a test can say what the
3
+ * assistant should have heard and what it should have done.
4
+ *
5
+ * ── Why a harness (standard A15) ───────────────────────────────────────────────
6
+ * A shared session carries two kinds of talk: people talking to each other, and people talking to
7
+ * the assistant. Everything a person types is stored the same way, and the model reads all of it.
8
+ * So the questions a test must be able to ask are:
9
+ *
10
+ * 누가 말했나 · 누구에게 한 말인가 · 그 턴에 모델을 불렀나 · 모델이 무엇을 읽었나 ·
11
+ * 무엇을 쓸 수 있었나 · 답과 제안은 누구 것인가
12
+ *
13
+ * Those are hard to see from a resolver test, because the interesting part is what the model was
14
+ * handed — and that is a string built from stored rows. The script keeps the rows, builds the same
15
+ * history the door builds, and records every model call.
16
+ *
17
+ * ── What this does not do ──────────────────────────────────────────────────────
18
+ * No database, no GraphQL, no provider. It drives the same two pieces the door drives
19
+ * (`buildLlmHistory` and `registryAssistantChat`) with a recording client, so a failure names a
20
+ * rule rather than a stack. Anything that only the resolver does — storing, broadcasting, session
21
+ * activity — is out of scope here and belongs in a resolver test.
22
+ */
23
+ import { registryAssistantChat } from '@things-factory/ai-client-base'
24
+
25
+ import { buildLlmHistory, type HistoryRow } from '../chat-message/llm-history.js'
26
+ import { formatProposalOutcomeLine, type ProposalOutcome } from '../chat-message/proposal-outcome.js'
27
+ import { roomMessageBody } from '../chat-message/room-message.js'
28
+ import { inviteMentionedUsers } from '../chat-session-participant/mention-invite.js'
29
+
30
+ /** Who a turn was meant for. The room hears everything; only the assistant answers. */
31
+ export type Addressee = 'room' | 'assistant'
32
+
33
+ export interface ScriptedTurn {
34
+ speaker: string
35
+ addressee: Addressee
36
+ content: string
37
+ /** `mention` 이 부른 사람 — 본문에는 마커로 실린다. */
38
+ mentions?: string[]
39
+ /** 호스트가 카드에 답한 것 — 사람의 말이 아니라 기록이다. */
40
+ outcome?: { proposalKey: string; outcome: ProposalOutcome }
41
+ }
42
+
43
+ /** One model call, exactly as the provider saw it. */
44
+ export interface RecordedCall {
45
+ systemPrompt: string
46
+ /** The conversation handed over, newest last. */
47
+ messages: { role: string; content: any }[]
48
+ /** Tool names offered this turn. */
49
+ tools: string[]
50
+ }
51
+
52
+ export interface ScriptResult {
53
+ /** Every model call, in order. A room turn must not produce one. */
54
+ calls: RecordedCall[]
55
+ /** What the assistant answered, one per `ask`. */
56
+ replies: string[]
57
+ /** Proposals returned, paired with the turn that asked for them. */
58
+ proposals: { askedBy: string; proposal: any }[]
59
+ /** The rows as they stand, in the order they were said. */
60
+ rows: HistoryRow[]
61
+ /** Who is in the room now, and what each of them may do. */
62
+ participants: ScriptParticipant[]
63
+ /** What each `mention` did — who joined, or why nobody did. */
64
+ invites: { by: string; invited: string[]; notSeated?: string[] }[]
65
+ }
66
+
67
+ /** A person in the room. `read` is how far they have read — the fact others see as a badge. */
68
+ export interface ScriptParticipant {
69
+ userId: string
70
+ role: 'owner' | 'member' | 'viewer'
71
+ read?: number
72
+ }
73
+
74
+ /** A provider that answers with a fixed script and records what it was given. */
75
+ export interface RecordingModel {
76
+ client: any
77
+ calls: RecordedCall[]
78
+ }
79
+
80
+ /**
81
+ * A model that answers each call with the next canned reply, and records what it saw.
82
+ *
83
+ * `toolCalls` lets a script make the model ask for a tool on a given turn — the harness then
84
+ * exercises the dispatch path rather than only the prompt.
85
+ */
86
+ export function recordingModel(
87
+ replies: string[] = ['ok'],
88
+ toolCalls: { name: string; arguments?: any }[][] = []
89
+ ): RecordingModel {
90
+ const calls: RecordedCall[] = []
91
+ let turn = 0
92
+ let replyTurn = 0
93
+
94
+ const client = {
95
+ id: 'recording:test',
96
+ async chatWithTools(messages: any[], tools: any[], options: any) {
97
+ const index = turn++
98
+ calls.push({
99
+ systemPrompt: options?.systemPrompt ?? '',
100
+ messages: messages.map(m => ({ role: m.role, content: m.content })),
101
+ tools: (tools ?? []).map(t => t.name)
102
+ })
103
+ /* The loop asks again after a tool answers, so only the first call of a turn asks for tools. */
104
+ const wanted = toolCalls[index] ?? []
105
+ if (wanted.length > 0) {
106
+ return {
107
+ text: '',
108
+ toolCalls: wanted.map((c, i) => ({ id: `call-${index}-${i}`, name: c.name, arguments: c.arguments ?? {} })),
109
+ stopReason: 'tool_use'
110
+ }
111
+ }
112
+ return { text: replies[Math.min(replyTurn++, replies.length - 1)] ?? 'ok', toolCalls: [], stopReason: 'end_turn' }
113
+ }
114
+ }
115
+
116
+ return { client, calls }
117
+ }
118
+
119
+ export interface ConversationOptions {
120
+ /**
121
+ * Who is in the room when the script starts, and as what.
122
+ *
123
+ * A conversation always has an owner — the person who started it — and `createAISession` puts
124
+ * them there. Since ADR-0073 a mention grants nothing, so the roster no longer decides who may
125
+ * name whom; it still decides who the room reaches and who is already seated.
126
+ */
127
+ participants?: ScriptParticipant[]
128
+ /** 이 대화가 공개인가 — 기본은 공개(ADR-0073 판정 2). 'private' 이면 멘션이 사람을 앉히지 않는다. */
129
+ visibility?: 'open' | 'private'
130
+ /** Who the assistant is on this surface — in real use it comes from the registered mode. */
131
+ systemPrompt?: string
132
+ /** Which registered categories this conversation may use. Omitted allows none. */
133
+ toolCategories?: string[]
134
+ /** Request state the tools receive (domain, user). */
135
+ state?: any
136
+ /** What the surface is looking at — reaches tools as `ctx.host`, never the model. */
137
+ hostContext?: any
138
+ /** Passed through to the runner; a live-state surface makes the model read first. */
139
+ requireToolOnFirstTurn?: boolean
140
+ }
141
+
142
+ /**
143
+ * Write the conversation, then run it.
144
+ *
145
+ * ```ts
146
+ * const result = await conversation({ toolCategories: ['twin-ops'] })
147
+ * .say('alice', '3 라인이 멈춘 것 같아요')
148
+ * .say('bob', '아까부터 그랬어')
149
+ * .ask('alice', '왜 멈췄는지 확인해줘')
150
+ * .run(recordingModel(['확인했습니다']))
151
+ * ```
152
+ */
153
+ export function conversation(options: ConversationOptions = {}) {
154
+ const turns: ScriptedTurn[] = []
155
+
156
+ const api = {
157
+ /** Someone speaks to the room. Nobody calls the assistant; it reads this next time it is asked. */
158
+ say(speaker: string, content: string) {
159
+ turns.push({ speaker, addressee: 'room', content })
160
+ return api
161
+ },
162
+ /** Someone asks the assistant. This is the turn that makes a model call. */
163
+ ask(speaker: string, content: string) {
164
+ turns.push({ speaker, addressee: 'assistant', content })
165
+ return api
166
+ },
167
+ /**
168
+ * Someone asks the assistant **and names a colleague** — the way a second person joins.
169
+ *
170
+ * The marker is what the browser puts in the text when a name is picked from `@`, and the door
171
+ * reads it from the stored turn. Only a person already in the room may bring another in.
172
+ */
173
+ mention(speaker: string, userIds: string[], content: string) {
174
+ const marked = [...userIds.map(id => `@${id}{userId:${id}}`), content].join(' ')
175
+ turns.push({ speaker, addressee: 'assistant', content: marked, mentions: userIds })
176
+ return api
177
+ },
178
+
179
+ /** The assistant's own answer, when a script needs one already in the history. */
180
+ answered(content: string) {
181
+ turns.push({ speaker: 'assistant', addressee: 'room', content })
182
+ return api
183
+ },
184
+
185
+ /**
186
+ * The host says what it did with a proposal — the card's state and the model's next turn both
187
+ * read this row (ADR-0070 A12-a).
188
+ */
189
+ outcome(proposalKey: string, outcome: ProposalOutcome) {
190
+ turns.push({ speaker: 'system', addressee: 'room', content: '', outcome: { proposalKey, outcome } })
191
+ return api
192
+ },
193
+
194
+ async run(model: RecordingModel = recordingModel()): Promise<ScriptResult> {
195
+ const rows: HistoryRow[] = []
196
+ const replies: string[] = []
197
+ const proposals: { askedBy: string; proposal: any }[] = []
198
+ const invites: { by: string; invited: string[]; notSeated?: string[] }[] = []
199
+
200
+ /* 참여자 저장소 — 실제 문이 쓰는 것과 같은 규칙을 지나되, 기억에만 둔다. */
201
+ const roster: any[] = (options.participants ?? [{ userId: 'alice', role: 'owner' }]).map((person, index) => ({
202
+ id: `participant-${index + 1}`,
203
+ userId: person.userId,
204
+ role: person.role,
205
+ read: person.read
206
+ }))
207
+ const participants = {
208
+ find: async () => roster,
209
+ save: async (row: any) => {
210
+ roster.push({ id: `participant-${roster.length + 1}`, userId: row.user.id, role: row.role })
211
+ },
212
+ update: async (id: string, patch: any) => {
213
+ const found = roster.find(row => row.id === id)
214
+ if (found) Object.assign(found, patch)
215
+ }
216
+ }
217
+
218
+ for (const turn of turns) {
219
+ /* 호스트의 답은 사람의 말이 아니다 — 서버가 쓰는 `system` 행이다. */
220
+ if (turn.outcome) {
221
+ rows.push({
222
+ id: `row-${rows.length + 1}`,
223
+ role: 'system',
224
+ content: formatProposalOutcomeLine(turn.outcome.proposalKey, turn.outcome.outcome)
225
+ })
226
+ continue
227
+ }
228
+
229
+ /* 빈 말은 저장되지 않는다 — 문이 지키는 규칙과 같은 것을 쓴다. */
230
+ const body = roomMessageBody(turn.content)
231
+ if (!body) continue
232
+
233
+ rows.push({
234
+ id: `row-${rows.length + 1}`,
235
+ role: turn.speaker === 'assistant' ? 'assistant' : 'user',
236
+ content: body,
237
+ senderId: turn.speaker,
238
+ senderName: turn.speaker,
239
+ /* 사람의 말에만 대상이 있다 — 문이 찍는 값과 같은 자리다(ADR-0070 A15 결정 ①). */
240
+ addressee: turn.speaker === 'assistant' ? undefined : turn.addressee
241
+ })
242
+
243
+ /* 멘션은 사람의 말을 저장하는 문에서만 사람을 불러들인다(ADR-0071 결정 1 ①). */
244
+ if (turn.speaker !== 'assistant') {
245
+ const invite = await inviteMentionedUsers({
246
+ content: body,
247
+ sessionId: 'script-session',
248
+ domainId: 'script-domain',
249
+ inviter: { id: turn.speaker },
250
+ participants,
251
+ visibility: options.visibility
252
+ })
253
+ if (invite.invited.length > 0 || invite.notSeated) {
254
+ invites.push({ by: turn.speaker, invited: invite.invited, notSeated: invite.notSeated })
255
+ }
256
+ }
257
+
258
+ /* Only a turn addressed to the assistant runs it. Room talk is context for the next one. */
259
+ if (turn.addressee !== 'assistant') continue
260
+
261
+ const result = await registryAssistantChat({
262
+ client: model.client,
263
+ messages: buildLlmHistory(rows, {}) as any,
264
+ systemPrompt: options.systemPrompt ?? 'you are a test assistant',
265
+ toolCategories: options.toolCategories,
266
+ hostContext: options.hostContext,
267
+ state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
268
+ requireToolOnFirstTurn: options.requireToolOnFirstTurn
269
+ })
270
+
271
+ replies.push(result.reply)
272
+ rows.push({ id: `row-${rows.length + 1}`, role: 'assistant', content: result.reply })
273
+ for (const proposal of result.loop.proposals ?? []) proposals.push({ askedBy: turn.speaker, proposal })
274
+ }
275
+
276
+ return {
277
+ calls: model.calls,
278
+ replies,
279
+ proposals,
280
+ rows,
281
+ participants: roster.map(row => ({ userId: row.userId, role: row.role, read: row.read })),
282
+ invites
283
+ }
284
+ }
285
+ }
286
+
287
+ return api
288
+ }
@@ -0,0 +1,107 @@
1
+ /*
2
+ * 등록된 모드가 **선언한 대로 도는가** — 글자가 아니라 문을 지나서 본다.
3
+ *
4
+ * 모드 선언은 소스에 적힌 값이고, 사람이 만나는 것은 그 값이 문을 지나 만들어 낸 것이다. 그 둘이
5
+ * 어긋나는 자리가 오늘 하루에만 여럿 나왔다: 범주에 없는 도구를 프롬프트가 부르라고 했고, 키는 있는데
6
+ * 번역이 없었고, 부품에 규칙이 있는데 부르는 쪽이 없었다. 전부 「선언은 맞는데 돌지 않는」 모양이다.
7
+ *
8
+ * 그래서 앱의 시험이 이것으로 **자기 모드를 실제로 돌려 본다**:
9
+ * · 그 이름으로 모드가 서는가
10
+ * · 그 모드가 여는 도구가 무엇인가 — 모델에게 실제로 건네지는 목록
11
+ * · 첫 턴 도구 호출을 강제하는가
12
+ * · 화면에 낼 말이 **키가 아니라 문장**으로 나오는가
13
+ * · 슬래시가 가리키는 도구가 그 모드 안에 있는가
14
+ */
15
+ import { registryAssistantChat } from '@things-factory/ai-client-base'
16
+
17
+ import { getAssistantMode, getAssistantModeWarnings } from '../assistant-mode/registry.js'
18
+ import { assistantModeView, resolveModeSettings, type Translate } from '../assistant-mode/resolve.js'
19
+
20
+ export interface ModeRunResult {
21
+ /** 이 모드가 모델에게 실제로 건넨 도구 이름. */
22
+ offeredTools: string[]
23
+ /** 모델이 받은 시스템 프롬프트 — 서버의 것인지 확인할 수 있다. */
24
+ systemPrompt: string
25
+ /** 첫 턴에 도구를 강제했는가. */
26
+ requiredToolOnFirstTurn: boolean
27
+ reply: string
28
+ }
29
+
30
+ /** 시험용 번역기 — 키를 그대로 돌려준다. 문장이 나와야 할 자리에 키가 나오면 눈에 띈다. */
31
+ export const keyEchoTranslate: Translate = key => key
32
+
33
+ /**
34
+ * 등록된 모드로 한 턴을 돌린다 — 프롬프트도 도구도 **등록에서** 나온다.
35
+ *
36
+ * 시험이 자기 값을 넘기면 그 시험은 선언을 안 본 것이다. 그래서 여기서는 `kind` 만 받는다.
37
+ */
38
+ export async function runRegisteredMode(
39
+ kind: string,
40
+ options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string } = {} as any
41
+ ): Promise<ModeRunResult> {
42
+ const settings = resolveModeSettings({ kind }, keyEchoTranslate)
43
+ let requiredToolOnFirstTurn = false
44
+
45
+ const result = await registryAssistantChat({
46
+ client: options.client,
47
+ messages: [{ role: 'user', content: '한 턴' }] as any,
48
+ systemPrompt: settings.systemPrompt,
49
+ toolCategories: settings.toolCategories,
50
+ requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),
51
+ model: settings.model,
52
+ temperature: settings.temperature,
53
+ maxTokens: settings.maxTokens,
54
+ hostContext: options.hostContext,
55
+ toolAccess: options.toolAccess,
56
+ state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
57
+ })
58
+
59
+ return {
60
+ offeredTools: [...result.offeredTools],
61
+ systemPrompt: settings.systemPrompt,
62
+ requiredToolOnFirstTurn,
63
+ reply: result.reply
64
+ }
65
+ }
66
+
67
+ export interface ModeWording {
68
+ placeholder?: string
69
+ slash: { name: string; description: string; template: string }[]
70
+ footerNotice?: string
71
+ stagedNotice?: string
72
+ }
73
+
74
+ /** 화면에 낼 말 — 주어진 사전으로 옮긴 결과. 키가 그대로 남으면 사전에 없는 것이다. */
75
+ export function modeWording(kind: string, dictionary: Record<string, string>): ModeWording {
76
+ const view = assistantModeView(kind, key => dictionary[key] ?? key)
77
+ return {
78
+ placeholder: view.placeholder,
79
+ slash: view.slashTemplates,
80
+ footerNotice: view.footerNotice,
81
+ stagedNotice: view.stagedNotice
82
+ }
83
+ }
84
+
85
+ /** 사전에 없어서 키가 그대로 화면에 나갈 말들. 비어 있어야 한다. */
86
+ export function missingWording(kind: string, dictionary: Record<string, string>): string[] {
87
+ const missing: string[] = []
88
+ const spec = getAssistantMode(kind)
89
+ if (!spec) return [`mode:${kind}`]
90
+
91
+ for (const key of [spec.placeholderKey, spec.footerNoticeKey, spec.stagedNoticeKey]) {
92
+ if (key && !(key in dictionary)) missing.push(key)
93
+ }
94
+ for (const slash of spec.slashTemplates ?? []) {
95
+ for (const key of [slash.descriptionKey, slash.templateKey]) {
96
+ if (!(key in dictionary)) missing.push(key)
97
+ }
98
+ }
99
+ return missing
100
+ }
101
+
102
+ /** 이 모드가 자기 도구로 못 하는 일을 제안하는가 — 부팅 보고와 같은 답. */
103
+ export function modePromisesItCannotKeep(kind: string): string[] {
104
+ return getAssistantModeWarnings()
105
+ .filter(warning => warning.params.kind === kind)
106
+ .map(warning => `${warning.code}:${warning.params.slash}→${warning.params.tool}`)
107
+ }
@@ -1,5 +1,8 @@
1
1
  /*
2
- * At boot, say which registered tools are not yet what the tool contract requires (A1 stage one).
2
+ * At boot, say what registered but is not yet what the contracts require tools with no privilege
3
+ * (A1 stage one) and modes offering `/` sentences their tools cannot answer (ADR-0058 decision 3).
4
+ *
5
+ * One window, because it is one question: what state is this deployment's AI in (standard A7-a).
3
6
  *
4
7
  * Registration no longer refuses a tool that changes something without declaring its privilege —
5
8
  * refusing would stop apps whose tools predate the field. The registry records each omission as a
@@ -10,17 +13,37 @@
10
13
  import { logger } from '@things-factory/env'
11
14
  import { getToolRegistryWarnings, type ToolRegistryWarning } from '@things-factory/ai-client-base'
12
15
 
16
+ import { getAssistantModeWarnings, type AssistantModeWarning } from './service/assistant-mode/index.js'
17
+
13
18
  /** One line per code: the code, how many, and which tools. Empty when there is nothing to report. */
14
19
  export function formatToolRegistryWarnings(warnings: ReadonlyArray<ToolRegistryWarning>): string[] {
15
20
  const byCode = new Map<string, string[]>()
16
21
  for (const warning of warnings) {
17
22
  const tools = byCode.get(warning.code) ?? []
18
- tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})`)
23
+ /* 문 이름이 있으면 함께 적는다 — 「어느 문이 아직 선언이 없나」가 다음에 할 일이다. */
24
+ const doors = warning.params.doors?.length ? ` → ${warning.params.doors.join(' + ')}` : ''
25
+ tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})${doors}`)
19
26
  byCode.set(warning.code, tools)
20
27
  }
21
28
  return [...byCode].map(([code, tools]) => `[ai-assistant] ${code} ×${tools.length}: ${tools.join(', ')}`)
22
29
  }
23
30
 
31
+ /** One line per code: the code, how many, and which modes promised what. */
32
+ export function formatAssistantModeWarnings(warnings: ReadonlyArray<AssistantModeWarning>): string[] {
33
+ const byCode = new Map<string, string[]>()
34
+ for (const warning of warnings) {
35
+ const offers = byCode.get(warning.code) ?? []
36
+ offers.push(`${warning.params.kind}:/${warning.params.slash}→${warning.params.tool}`)
37
+ byCode.set(warning.code, offers)
38
+ }
39
+ return [...byCode].map(([code, offers]) => `[ai-assistant] ${code} ×${offers.length}: ${offers.join(', ')}`)
40
+ }
41
+
42
+ /*
43
+ * After every module has registered: a mode may declare its slash sentences before the package
44
+ * that registers the tools they need, so this cannot be checked when the mode registers.
45
+ */
24
46
  process.on('bootstrap-module-start' as any, () => {
25
47
  for (const line of formatToolRegistryWarnings(getToolRegistryWarnings())) logger.warn(line)
48
+ for (const line of formatAssistantModeWarnings(getAssistantModeWarnings())) logger.warn(line)
26
49
  })
@@ -0,0 +1,82 @@
1
+ /*
2
+ * What this guards: nothing sends the conversation's persona, tools, model or limits any more.
3
+ *
4
+ * ADR-0061 decision 7 took six fields off `assistantChat` with no compatibility field, on the
5
+ * condition that a test counts the callers still passing them. Types catch a caller that compiles
6
+ * against this source; an app compiled against an installed copy, or a hand-written GraphQL
7
+ * document, would not be caught by anything else.
8
+ */
9
+ import { readFileSync, readdirSync, statSync } from 'fs'
10
+ import path from 'path'
11
+
12
+ const PACKAGES = path.resolve(__dirname, '../..')
13
+ const MOVED = ['systemPrompt', 'toolCategories', 'model', 'temperature', 'maxTokens', 'requireGroundingTools']
14
+
15
+ /** Source files only — a built copy of yesterday's source is not a call site. */
16
+ function sources(dir: string, out: string[] = []): string[] {
17
+ for (const entry of readdirSync(dir)) {
18
+ if (entry === 'node_modules' || entry === 'dist-server' || entry === 'dist-client' || entry.startsWith('.')) continue
19
+ const full = path.join(dir, entry)
20
+ if (statSync(full).isDirectory()) sources(full, out)
21
+ else if (/\.(ts|js)$/.test(entry)) out.push(full)
22
+ }
23
+ return out
24
+ }
25
+
26
+ const files = sources(PACKAGES)
27
+ /* This file names the fields it looks for; counting itself would report itself. */
28
+ .filter(file => !file.endsWith('assistant-chat-call-sites.test.ts'))
29
+ .map(file => ({ file, text: readFileSync(file, 'utf-8') }))
30
+
31
+ it('★ one place builds the neutral door input, and it sends none of the moved fields', () => {
32
+ const builders = files.filter(f => f.text.includes('buildAssistantChatInput(')).map(f => path.relative(PACKAGES, f.file))
33
+
34
+ expect(builders.sort()).toEqual(
35
+ [
36
+ 'ai-assistant/client/components/assistant-chat.ts',
37
+ 'ai-assistant/client/components/chat-input-builder.test.ts',
38
+ 'ai-assistant/client/components/chat-input-builder.ts'
39
+ ].sort()
40
+ )
41
+
42
+ const builder = files.find(f => f.file.endsWith('chat-input-builder.ts'))!.text
43
+ const neutral = builder.slice(builder.indexOf('export function buildAssistantChatInput'))
44
+ for (const field of MOVED) expect(neutral).not.toContain(`args.${field}`)
45
+ })
46
+
47
+ it('★ no hand-written document sends the moved fields to assistantChat', () => {
48
+ const offenders: string[] = []
49
+ for (const { file, text } of files) {
50
+ /* The mutation document, wherever it is written. */
51
+ const at = text.indexOf('assistantChat(input:')
52
+ if (at < 0) continue
53
+ const document = text.slice(Math.max(0, at - 2000), at + 2000)
54
+ for (const field of MOVED) {
55
+ if (new RegExp(`${field}\\s*:`).test(document)) offenders.push(`${path.relative(PACKAGES, file)} → ${field}`)
56
+ }
57
+ }
58
+
59
+ expect(offenders).toEqual([])
60
+ })
61
+
62
+ /*
63
+ * `model` and `temperature` are left out here on purpose: a board surface passes `.model=` for the
64
+ * board it is editing, and matching that would report the wrong thing. The door test above covers
65
+ * them on the wire, which is where they mattered.
66
+ */
67
+ const ELEMENT_PROPERTIES = ['systemPrompt', 'toolCategories', 'requireGroundingTools']
68
+
69
+ it('★ no surface in this repo declares a conversation the server should own', () => {
70
+ const offenders: string[] = []
71
+ for (const { file, text } of files) {
72
+ if (!/<ox-assistant-chat/.test(text)) continue
73
+ if (file.includes('ai-assistant/client/components/')) continue
74
+ for (const field of ELEMENT_PROPERTIES) {
75
+ if (new RegExp(`\\.${field}=|${field.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}=`).test(text)) {
76
+ offenders.push(`${path.relative(PACKAGES, file)} → ${field}`)
77
+ }
78
+ }
79
+ }
80
+
81
+ expect(offenders).toEqual([])
82
+ })
@@ -59,12 +59,54 @@
59
59
  "ai-assistant.text.ungrounded-mention": "Mentions an unverified target — not present in the actual data:",
60
60
  "ai-assistant.text.toggle-system-note": "click to expand",
61
61
  "ai-assistant.button.run-proposal": "run",
62
- "ai-assistant.text.proposal-sent": "execution requested",
63
62
  "ai-assistant.text.history-folded": "this conversation grew long — the earlier {count} messages were left out. a new conversation is better for a different topic.",
64
63
  "ai-assistant.text.history-folded-summarized": "this conversation grew long — the earlier {count} messages were folded into a summary. a new conversation is better for a different topic.",
65
64
  "ai-assistant.button.attach": "Attach",
66
65
  "ai-assistant.tooltip.attach-file": "Attach images or files (drag & drop or paste supported)",
67
66
  "ai-assistant.text.drop-files-here": "Drop images or files here",
68
67
  "ai-assistant.button.remove-attachment": "Remove attachment",
69
- "ai-assistant.session.all": "All History"
68
+ "ai-assistant.session.all": "All History",
69
+ "ai-assistant.notice.stopped": "the request stopped partway, so only part of it was done.",
70
+ "ai-assistant.notice.provider-busy": "the ai service did not answer just now. try again in a moment.",
71
+ "ai-assistant.notice.provider-error": "something went wrong while handling this. tell an administrator if it keeps happening.",
72
+ "ai-assistant.notice.too-long": "this ran long and stopped at step {iter}. try asking in smaller pieces.",
73
+ "ai-assistant.notice.tool-kept-failing": "{tool} failed {count} times in a row, so it stopped. try saying it differently.",
74
+ "ai-assistant.text.no-match": "no match",
75
+ "ai-assistant.text.messages-unreadable": "could not read this conversation.",
76
+ "ai-assistant.text.sessions-unreadable": "could not load the conversation list.",
77
+ "ai-assistant.text.session-not-created": "could not start a conversation.",
78
+ "ai-assistant.text.proposal-choices": "follow-up choices",
79
+ "ai-assistant.text.attached-image-ask": "here is an image — go by it.",
80
+ "ai-assistant.text.attached-file-ask": "here is a file — take a look.",
81
+ "ai-assistant.text.other-figure-history": "this conversation belongs to another figure. open the conversation of the model you have open.",
82
+ "ai-assistant.text.application-vs-model-history": "this conversation is about the application, not about a model. open the one you want.",
83
+ "ai-assistant.text.other-station-history": "this conversation belongs to another station screen. open the conversation of the screen you are on.",
84
+ "ai-assistant.text.unknown-mode": "this screen asked for a conversation this server does not have ({kind}). reload the page.",
85
+ "ai-assistant.session.default-name": "conversation {n}",
86
+ "ai-assistant.match.id": "ID",
87
+ "ai-assistant.match.name": "Name",
88
+ "ai-assistant.match.tag": "Tag",
89
+ "ai-assistant.match.type": "Type",
90
+ "ai-assistant.match.class": "Class",
91
+ "ai-assistant.match.text": "Text",
92
+ "ai-assistant.match.data": "Data",
93
+ "ai-assistant.match.property": "Prop",
94
+ "ai-assistant.match.description": "Desc",
95
+ "ai-assistant.candidate.template": "phrase",
96
+ "ai-assistant.candidate.user": "person",
97
+ "ai-assistant.text.match-count": "{shown} / {all} matches",
98
+ "ai-assistant.text.match-overflow": "+{count} more — narrow the query",
99
+ "ai-assistant.verdict.accepted": "done",
100
+ "ai-assistant.verdict.refused": "refused",
101
+ "ai-assistant.verdict.unavailable": "could not run",
102
+ "ai-assistant.verdict.declined": "cancelled",
103
+ "ai-assistant.text.proposal-waiting": "sending — {seconds}s, no answer yet",
104
+ "ai-assistant.text.proposal-applied-count": " {applied} of {total}",
105
+ "ai-assistant.text.say-to-room-tooltip": "say this to the people here — the assistant does not answer",
106
+ "ai-assistant.text.no-room-yet": "this conversation is not saved yet, so there is nobody else in it to talk to",
107
+ "ai-assistant.text.will-be-invited": "{names} will join this conversation when you send",
108
+ "ai-assistant.text.cannot-invite": "{names} will not join — only the people already in this conversation can bring someone in",
109
+ "ai-assistant.text.answer-needs-permission": "you do not have permission to see this answer. ask an administrator for access.",
110
+ "ai-assistant.label.conversation": "conversation",
111
+ "ai-assistant.text.switch-conversation": "switch to {name}"
70
112
  }
@@ -59,12 +59,54 @@
59
59
  "ai-assistant.text.ungrounded-mention": "確認できない対象に言及しています — 実データにありません:",
60
60
  "ai-assistant.text.toggle-system-note": "クリックで展開",
61
61
  "ai-assistant.button.run-proposal": "実行",
62
- "ai-assistant.text.proposal-sent": "実行を依頼しました",
63
62
  "ai-assistant.text.history-folded": "会話が長くなり、前半の {count} 件は省略されました。別の話題なら新しい会話のほうが適しています。",
64
63
  "ai-assistant.text.history-folded-summarized": "会話が長くなり、前半の {count} 件は要約にまとめられました。別の話題なら新しい会話のほうが適しています。",
65
64
  "ai-assistant.button.attach": "添付",
66
65
  "ai-assistant.tooltip.attach-file": "画像またはファイルを添付(クリップボードの貼り付け・ドラッグ&ドロップ対応)",
67
66
  "ai-assistant.text.drop-files-here": "ここに画像やファイルをドロップしてください",
68
67
  "ai-assistant.button.remove-attachment": "添付を削除",
69
- "ai-assistant.session.all": "すべての履歴"
68
+ "ai-assistant.session.all": "すべての履歴",
69
+ "ai-assistant.notice.stopped": "リクエストが途中で止まり、一部だけ処理しました。",
70
+ "ai-assistant.notice.provider-busy": "AI サービスが今応答していません。少し待ってからもう一度お試しください。",
71
+ "ai-assistant.notice.provider-error": "処理中に問題が発生しました。同じことが続く場合は管理者にお知らせください。",
72
+ "ai-assistant.notice.too-long": "処理が長くなり {iter} 段階で止まりました。分けて依頼してみてください。",
73
+ "ai-assistant.notice.tool-kept-failing": "{tool} の呼び出しが {count} 回続けて失敗したため止まりました。言い方を変えてみてください。",
74
+ "ai-assistant.text.no-match": "一致するものがありません",
75
+ "ai-assistant.text.messages-unreadable": "この会話を読み取れませんでした。",
76
+ "ai-assistant.text.sessions-unreadable": "会話の一覧を読み込めませんでした。",
77
+ "ai-assistant.text.session-not-created": "会話を開始できませんでした。",
78
+ "ai-assistant.text.proposal-choices": "次にできること",
79
+ "ai-assistant.text.attached-image-ask": "画像を添付しました。これを見てお願いします。",
80
+ "ai-assistant.text.attached-file-ask": "ファイルを添付しました。確認してください。",
81
+ "ai-assistant.text.other-figure-history": "この会話は別の Figure のものです。今開いているモデルの会話を開いてください。",
82
+ "ai-assistant.text.application-vs-model-history": "この会話はモデルではなくアプリケーションについてのものです。目的の会話を開いてください。",
83
+ "ai-assistant.text.other-station-history": "この会話は別の現場画面のものです。今見ている画面の会話を開いてください。",
84
+ "ai-assistant.text.unknown-mode": "この画面が求める会話がサーバーにありません({kind})。ページを再読み込みしてください。",
85
+ "ai-assistant.session.default-name": "会話 {n}",
86
+ "ai-assistant.match.id": "ID",
87
+ "ai-assistant.match.name": "名前",
88
+ "ai-assistant.match.tag": "タグ",
89
+ "ai-assistant.match.type": "種類",
90
+ "ai-assistant.match.class": "クラス",
91
+ "ai-assistant.match.text": "テキスト",
92
+ "ai-assistant.match.data": "値",
93
+ "ai-assistant.match.property": "属性",
94
+ "ai-assistant.match.description": "説明",
95
+ "ai-assistant.candidate.template": "文",
96
+ "ai-assistant.candidate.user": "人",
97
+ "ai-assistant.text.match-count": "{shown} / {all} 件",
98
+ "ai-assistant.text.match-overflow": "他 {count} 件 — 検索語を絞ってください",
99
+ "ai-assistant.verdict.accepted": "適用しました",
100
+ "ai-assistant.verdict.refused": "拒否されました",
101
+ "ai-assistant.verdict.unavailable": "実行できませんでした",
102
+ "ai-assistant.verdict.declined": "取り消しました",
103
+ "ai-assistant.text.proposal-waiting": "送信中 — {seconds}秒、まだ返事がありません",
104
+ "ai-assistant.text.proposal-applied-count": " {total} 件中 {applied} 件",
105
+ "ai-assistant.text.say-to-room-tooltip": "ここにいる人たちに話します。AI は答えません",
106
+ "ai-assistant.text.no-room-yet": "まだ保存されていない会話なので、一緒にいる人がいません",
107
+ "ai-assistant.text.will-be-invited": "送信すると {names} がこの会話に加わります",
108
+ "ai-assistant.text.cannot-invite": "{names} は加わりません。この会話にいる人だけが他の人を呼べます",
109
+ "ai-assistant.text.answer-needs-permission": "この回答を見る権限がありません。管理者に申請してください。",
110
+ "ai-assistant.label.conversation": "会話",
111
+ "ai-assistant.text.switch-conversation": "{name} の会話に切り替える"
70
112
  }