@things-factory/ai-assistant 10.1.154 → 10.1.155

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 (121) hide show
  1. package/client/bootstrap.ts +27 -0
  2. package/client/components/assistant-chat.ts +229 -178
  3. package/client/components/assistant-dock.ts +53 -48
  4. package/client/components/assistant-session-manager.ts +82 -100
  5. package/client/components/assistant-session-toolbar.ts +11 -3
  6. package/client/components/chat-input-builder.ts +8 -2
  7. package/client/settings/agent-settings-model.ts +164 -0
  8. package/client/settings/ai-agent-setting-let.ts +684 -0
  9. package/client/settings/ai-usage-setting-let.ts +252 -0
  10. package/client/settings/usage-model.ts +46 -0
  11. package/client/setup-assistant-dock.ts +2 -3
  12. package/client/utils/agent-choice.ts +56 -0
  13. package/client/utils/assistant-navigation.test.ts +14 -8
  14. package/client/utils/assistant-navigation.ts +8 -3
  15. package/client/utils/composer-frame.ts +28 -2
  16. package/client/utils/menu-room.ts +33 -0
  17. package/dist-client/bootstrap.d.ts +8 -0
  18. package/dist-client/bootstrap.js +26 -0
  19. package/dist-client/bootstrap.js.map +1 -0
  20. package/dist-client/components/assistant-chat.d.ts +32 -4
  21. package/dist-client/components/assistant-chat.js +234 -175
  22. package/dist-client/components/assistant-chat.js.map +1 -1
  23. package/dist-client/components/assistant-dock.d.ts +6 -8
  24. package/dist-client/components/assistant-dock.js +52 -60
  25. package/dist-client/components/assistant-dock.js.map +1 -1
  26. package/dist-client/components/assistant-session-manager.d.ts +8 -7
  27. package/dist-client/components/assistant-session-manager.js +84 -124
  28. package/dist-client/components/assistant-session-manager.js.map +1 -1
  29. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  30. package/dist-client/components/assistant-session-toolbar.js +10 -3
  31. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +4 -0
  33. package/dist-client/components/chat-input-builder.js +4 -2
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/settings/agent-settings-model.d.ts +93 -0
  36. package/dist-client/settings/agent-settings-model.js +95 -0
  37. package/dist-client/settings/agent-settings-model.js.map +1 -0
  38. package/dist-client/settings/ai-agent-setting-let.d.ts +51 -0
  39. package/dist-client/settings/ai-agent-setting-let.js +672 -0
  40. package/dist-client/settings/ai-agent-setting-let.js.map +1 -0
  41. package/dist-client/settings/ai-usage-setting-let.d.ts +26 -0
  42. package/dist-client/settings/ai-usage-setting-let.js +276 -0
  43. package/dist-client/settings/ai-usage-setting-let.js.map +1 -0
  44. package/dist-client/settings/usage-model.d.ts +32 -0
  45. package/dist-client/settings/usage-model.js +25 -0
  46. package/dist-client/settings/usage-model.js.map +1 -0
  47. package/dist-client/setup-assistant-dock.d.ts +0 -1
  48. package/dist-client/setup-assistant-dock.js +0 -1
  49. package/dist-client/setup-assistant-dock.js.map +1 -1
  50. package/dist-client/tsconfig.tsbuildinfo +1 -1
  51. package/dist-client/utils/agent-choice.d.ts +24 -0
  52. package/dist-client/utils/agent-choice.js +38 -0
  53. package/dist-client/utils/agent-choice.js.map +1 -0
  54. package/dist-client/utils/assistant-navigation.d.ts +8 -3
  55. package/dist-client/utils/assistant-navigation.js +7 -2
  56. package/dist-client/utils/assistant-navigation.js.map +1 -1
  57. package/dist-client/utils/assistant-navigation.test.js +14 -8
  58. package/dist-client/utils/assistant-navigation.test.js.map +1 -1
  59. package/dist-client/utils/composer-frame.d.ts +23 -2
  60. package/dist-client/utils/composer-frame.js +20 -2
  61. package/dist-client/utils/composer-frame.js.map +1 -1
  62. package/dist-client/utils/menu-room.d.ts +13 -0
  63. package/dist-client/utils/menu-room.js +26 -0
  64. package/dist-client/utils/menu-room.js.map +1 -0
  65. package/dist-server/service/ai-agent-resolver.d.ts +82 -0
  66. package/dist-server/service/ai-agent-resolver.js +417 -0
  67. package/dist-server/service/ai-agent-resolver.js.map +1 -0
  68. package/dist-server/service/assistant-capability-resolver.d.ts +1 -1
  69. package/dist-server/service/assistant-capability-resolver.js +7 -7
  70. package/dist-server/service/assistant-capability-resolver.js.map +1 -1
  71. package/dist-server/service/assistant-chat-resolver.d.ts +2 -0
  72. package/dist-server/service/assistant-chat-resolver.js +32 -15
  73. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  74. package/dist-server/service/assistant-mode/resolve.d.ts +0 -1
  75. package/dist-server/service/assistant-mode/resolve.js +0 -1
  76. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  77. package/dist-server/service/assistant-mode/types.d.ts +1 -2
  78. package/dist-server/service/assistant-mode/types.js.map +1 -1
  79. package/dist-server/service/chat-session/chat-session.d.ts +2 -0
  80. package/dist-server/service/chat-session/chat-session.js +5 -0
  81. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  82. package/dist-server/service/index.d.ts +2 -1
  83. package/dist-server/service/index.js +3 -0
  84. package/dist-server/service/index.js.map +1 -1
  85. package/dist-server/service/testing/mode-conformance.js +0 -1
  86. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +9 -7
  89. package/server/service/ai-agent-resolver.ts +330 -0
  90. package/server/service/assistant-capability-resolver.ts +7 -7
  91. package/server/service/assistant-chat-resolver.ts +32 -24
  92. package/server/service/assistant-mode/resolve.test.ts +0 -2
  93. package/server/service/assistant-mode/resolve.ts +0 -2
  94. package/server/service/assistant-mode/types.ts +1 -2
  95. package/server/service/chat-session/chat-session.ts +5 -0
  96. package/server/service/index.ts +3 -0
  97. package/server/service/testing/mode-conformance.ts +0 -1
  98. package/server/tool-registry-boot-report.test.ts +2 -0
  99. package/test/ai-agent-settings.test.ts +123 -0
  100. package/test/assistant-dock.test.ts +3 -2
  101. package/test/assistant-session-manager.test.ts +10 -4
  102. package/test/assistant-session-toolbar.test.ts +8 -0
  103. package/test/composer-frame.test.ts +40 -2
  104. package/test/menu-room.test.ts +33 -0
  105. package/test/open-doors.test.ts +3 -1
  106. package/test/session-discovery.test.ts +7 -6
  107. package/things-factory.config.js +2 -0
  108. package/translations/en.json +78 -12
  109. package/translations/es.json +78 -12
  110. package/translations/ja.json +78 -12
  111. package/translations/ko.json +78 -12
  112. package/translations/ms.json +78 -12
  113. package/translations/zh.json +78 -12
  114. package/client/components/chat-triggers.test.ts +0 -72
  115. package/client/components/chat-triggers.ts +0 -62
  116. package/dist-client/components/chat-triggers.d.ts +0 -16
  117. package/dist-client/components/chat-triggers.js +0 -42
  118. package/dist-client/components/chat-triggers.js.map +0 -1
  119. package/dist-client/components/chat-triggers.test.d.ts +0 -1
  120. package/dist-client/components/chat-triggers.test.js +0 -61
  121. package/dist-client/components/chat-triggers.test.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.154",
3
+ "version": "10.1.155",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -29,16 +29,18 @@
29
29
  "@material/web": "^2.0.0",
30
30
  "@operato/graphql": "^10.0.0",
31
31
  "@operato/i18n": "^10.0.0",
32
+ "@operato/input": "^10.0.0",
32
33
  "@operato/layout": "^10.20.0",
33
34
  "@operato/popup": "^10.16.0",
34
35
  "@operato/shell": "^10.0.0",
35
36
  "@operato/styles": "^10.0.0",
36
- "@things-factory/ai-client-base": "^10.1.149",
37
- "@things-factory/apptool-base": "^10.1.154",
38
- "@things-factory/attachment-base": "^10.1.154",
39
- "@things-factory/auth-base": "^10.1.154",
37
+ "@things-factory/ai-client-base": "^10.1.155",
38
+ "@things-factory/apptool-base": "^10.1.155",
39
+ "@things-factory/attachment-base": "^10.1.155",
40
+ "@things-factory/auth-base": "^10.1.155",
40
41
  "@things-factory/env": "^10.1.149",
41
- "@things-factory/shell": "^10.1.154",
42
+ "@things-factory/setting-base": "^10.1.155",
43
+ "@things-factory/shell": "^10.1.155",
42
44
  "dompurify": "^3.0.0",
43
45
  "exceljs": "4.2.0",
44
46
  "graphql-scalars": "^1.22.0",
@@ -52,5 +54,5 @@
52
54
  "copyfiles": "^2.4.1",
53
55
  "rimraf": "^5.0.0"
54
56
  },
55
- "gitHead": "f28579aa7131cbb282eb8813f8b285d9d29bb525"
57
+ "gitHead": "0211c9c0764a26c10151fdc7d60094fc334905ff"
56
58
  }
@@ -0,0 +1,330 @@
1
+ /*
2
+ * The doors of a domain's AI agents (design/ai/agent-settings.md).
3
+ *
4
+ * The settings card reads and saves the agents of the domain it is in; the dock reads the agents a person may pick.
5
+ * Everything here is a thin door over ai-client-base, which owns the agents, their keys and their usage. A key never
6
+ * leaves the server: reads carry a hint of this domain's own key and nothing of a key from above.
7
+ */
8
+ import { Arg, Ctx, Directive, Field, ID, InputType, Int, Mutation, ObjectType, Query, Resolver, UseMiddleware } from 'type-graphql'
9
+ import '@things-factory/auth-base'
10
+ import { SpeakRefusals } from '@things-factory/shell'
11
+ import {
12
+ type AiAgentEntry,
13
+ capabilityOf,
14
+ listProviderModels,
15
+ readAgentUsersBelow,
16
+ readAiAgentDirectory,
17
+ saveAiAgents,
18
+ summarizeAiUsage
19
+ } from '@things-factory/ai-client-base'
20
+
21
+ @ObjectType({ description: 'A domain, by id and name.' })
22
+ class AiAgentDomain {
23
+ @Field(() => ID, { description: 'Domain id.' })
24
+ id: string
25
+
26
+ @Field({ description: 'Domain name.' })
27
+ name: string
28
+ }
29
+
30
+ @ObjectType({ description: 'One AI agent as the settings card shows it. The key itself is never here.' })
31
+ class AiAgentView {
32
+ @Field(() => ID, { description: 'Agent id.' })
33
+ id: string
34
+
35
+ @Field({ description: 'The name a person picks it by.' })
36
+ name: string
37
+
38
+ @Field({ description: 'openai, anthropic or gemini.' })
39
+ provider: string
40
+
41
+ @Field({ description: 'The model it answers with.' })
42
+ model: string
43
+
44
+ @Field({ nullable: true, description: 'A gateway or proxy the provider is reached through.' })
45
+ baseURL?: string
46
+
47
+ @Field({ nullable: true, description: 'OpenAI organization.' })
48
+ organization?: string
49
+
50
+ @Field({ description: 'Off takes it out of this domain and every domain below.' })
51
+ active: boolean
52
+
53
+ @Field(() => AiAgentDomain, { description: 'The domain that declared it.' })
54
+ owner: AiAgentDomain
55
+
56
+ @Field({ description: 'Declared by a domain above. Read-only here.' })
57
+ inherited: boolean
58
+
59
+ @Field({ description: 'An inherited agent this domain does not use.' })
60
+ turnedOff: boolean
61
+
62
+ @Field({ description: 'own, inherited, overridden or missing - where the key that answers comes from.' })
63
+ keyState: string
64
+
65
+ @Field(() => AiAgentDomain, { nullable: true, description: 'The domain whose key answers.' })
66
+ keyDomain?: AiAgentDomain
67
+
68
+ @Field({ nullable: true, description: 'A hint of this domain\'s own key, such as ••••abcd. Never given for a key from above.' })
69
+ keyHint?: string
70
+
71
+ @Field({ description: 'Active, used here, and has a key - a person can pick it.' })
72
+ usable: boolean
73
+
74
+ @Field({ description: 'The agent a person gets before picking one.' })
75
+ isDefault: boolean
76
+
77
+ @Field(() => [AiAgentDomain], { description: 'For this domain\'s own agents: the domains below that use it.' })
78
+ usedBelow: AiAgentDomain[]
79
+ }
80
+
81
+ @ObjectType({ description: 'A domain\'s AI agents and how its AI stands.' })
82
+ class AiAgentSettings {
83
+ @Field(() => [AiAgentDomain], { description: 'This domain and the domains above it, closest first.' })
84
+ chain: AiAgentDomain[]
85
+
86
+ @Field(() => [AiAgentView], { description: 'This domain\'s agents, then each domain above\'s, closest first.' })
87
+ agents: AiAgentView[]
88
+
89
+ @Field({ nullable: true, description: 'The agent a person gets before picking one.' })
90
+ defaultAgentId?: string
91
+
92
+ @Field({ description: 'own, inherited, first or none - how the default was decided.' })
93
+ defaultFrom: string
94
+
95
+ @Field({ description: 'configured or not-configured.' })
96
+ state: string
97
+
98
+ @Field({ nullable: true, description: 'Why it is not configured: no-agent, no-agent-key or no-usable-agent.' })
99
+ reason?: string
100
+ }
101
+
102
+ @ObjectType({ description: 'An AI agent a person may pick in the dock.' })
103
+ class AiAgentChoice {
104
+ @Field(() => ID, { description: 'Agent id - what assistantChat takes as agentId.' })
105
+ id: string
106
+
107
+ @Field({ description: 'The name a person picks it by.' })
108
+ name: string
109
+
110
+ @Field({ description: 'openai, anthropic or gemini.' })
111
+ provider: string
112
+
113
+ @Field({ description: 'The model it answers with.' })
114
+ model: string
115
+
116
+ @Field({ description: 'The agent a person gets before picking one.' })
117
+ isDefault: boolean
118
+ }
119
+
120
+ @InputType({ description: 'One of this domain\'s own agents as the settings card saves it.' })
121
+ class AiAgentInput {
122
+ @Field(() => ID, { nullable: true, description: 'Empty for a new agent.' })
123
+ id?: string
124
+
125
+ @Field({ description: 'The name a person picks it by.' })
126
+ name: string
127
+
128
+ @Field({ description: 'openai, anthropic or gemini.' })
129
+ provider: string
130
+
131
+ @Field({ description: 'A model from aiProviderModels.' })
132
+ model: string
133
+
134
+ @Field({ nullable: true, description: 'A gateway or proxy the provider is reached through.' })
135
+ baseURL?: string
136
+
137
+ @Field({ nullable: true, description: 'OpenAI organization.' })
138
+ organization?: string
139
+
140
+ @Field({ description: 'Off takes it out of this domain and every domain below.' })
141
+ active: boolean
142
+
143
+ @Field({ nullable: true, description: 'A new API key. Left out, the stored key stays.' })
144
+ apiKey?: string
145
+ }
146
+
147
+ @InputType({ description: 'This domain\'s key for an agent it inherits.' })
148
+ class InheritedKeyInput {
149
+ @Field(() => ID, { description: 'The inherited agent.' })
150
+ agentId: string
151
+
152
+ @Field({ nullable: true, description: 'A key of this domain to answer the agent with.' })
153
+ apiKey?: string
154
+
155
+ @Field({ nullable: true, description: 'Go back to the key of the domain above.' })
156
+ clear?: boolean
157
+ }
158
+
159
+ @InputType({ description: 'A domain\'s agents as the settings card saves them.' })
160
+ class AiAgentsInput {
161
+ @Field(() => [AiAgentInput], { description: 'The whole list of this domain\'s own agents. One left out is deleted.' })
162
+ agents: AiAgentInput[]
163
+
164
+ @Field({ nullable: true, description: 'This domain\'s default - its own agent or an inherited one.' })
165
+ defaultAgentId?: string
166
+
167
+ @Field(() => [String], { nullable: true, description: 'Inherited agents this domain does not use.' })
168
+ turnedOff?: string[]
169
+
170
+ @Field(() => [InheritedKeyInput], { nullable: true, description: 'This domain\'s keys for inherited agents.' })
171
+ inheritedKeys?: InheritedKeyInput[]
172
+ }
173
+
174
+ @ObjectType({ description: 'AI usage of one bucket, agent and person.' })
175
+ class AiUsageBucket {
176
+ @Field({ description: 'The hour, day or month in the domain\'s time zone.' })
177
+ bucket: string
178
+
179
+ @Field({ nullable: true, description: 'Agent id.' })
180
+ agentId?: string
181
+
182
+ @Field({ nullable: true, description: 'Agent name. Empty for a deleted agent.' })
183
+ agentName?: string
184
+
185
+ @Field({ description: 'openai, anthropic or gemini.' })
186
+ provider: string
187
+
188
+ @Field({ description: 'The model the provider reported.' })
189
+ model: string
190
+
191
+ @Field({ description: 'The domain the calls were made in.' })
192
+ domainId: string
193
+
194
+ @Field({ description: 'Its name.' })
195
+ domainName: string
196
+
197
+ @Field({ nullable: true, description: 'The person. Empty for a domain below and for calls with no person.' })
198
+ userId?: string
199
+
200
+ @Field({ nullable: true, description: 'The person\'s name.' })
201
+ userName?: string
202
+
203
+ @Field(() => Int, { description: 'Provider requests.' })
204
+ calls: number
205
+
206
+ @Field(() => Int, { description: 'Input tokens.' })
207
+ inputTokens: number
208
+
209
+ @Field(() => Int, { description: 'Output tokens.' })
210
+ outputTokens: number
211
+
212
+ @Field(() => Int, { description: 'Requests that failed.' })
213
+ failures: number
214
+ }
215
+
216
+ @Resolver()
217
+ @UseMiddleware(SpeakRefusals)
218
+ export class AiAgentResolver {
219
+ @Query(() => AiAgentSettings, { description: 'This domain\'s AI agents, the ones it inherits, and how its AI stands.' })
220
+ @Directive('@privilege(category: "setting", privilege: "query", domainOwnerGranted: true)')
221
+ async aiAgentSettings(@Ctx() context: ResolverContext): Promise<AiAgentSettings> {
222
+ const { domain } = context.state
223
+ const directory = await readAiAgentDirectory(domain)
224
+ const nameOf = new Map(directory.chain.map(one => [one.id, one.name]))
225
+ const ownIds = directory.entries.filter(one => !one.inherited).map(one => one.spec.id)
226
+ const usedBelow = await readAgentUsersBelow(domain, ownIds)
227
+ const capability = capabilityOf(directory)
228
+
229
+ return {
230
+ chain: directory.chain,
231
+ agents: directory.entries.map(entry => agentView(entry, directory.defaultAgentId, nameOf, usedBelow.get(entry.spec.id) ?? [])),
232
+ defaultAgentId: directory.defaultAgentId,
233
+ defaultFrom: directory.defaultFrom,
234
+ state: capability.state,
235
+ reason: capability.reason
236
+ }
237
+ }
238
+
239
+ @Query(() => [AiAgentChoice], { description: 'The AI agents a person may pick in the dock - usable ones only.' })
240
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
241
+ async aiAgentChoices(@Ctx() context: ResolverContext): Promise<AiAgentChoice[]> {
242
+ const directory = await readAiAgentDirectory(context.state.domain)
243
+ return directory.entries
244
+ .filter(one => one.usable)
245
+ .map(one => ({
246
+ id: one.spec.id,
247
+ name: one.spec.name,
248
+ provider: one.spec.provider,
249
+ model: one.spec.model,
250
+ isDefault: one.spec.id === directory.defaultAgentId
251
+ }))
252
+ }
253
+
254
+ @Query(() => [String], {
255
+ description:
256
+ 'The conversation models a provider offers to a key - a typed key, or the stored key of one of this domain\'s agents. A model is only ever picked from this list.'
257
+ })
258
+ @Directive('@privilege(category: "setting", privilege: "query", domainOwnerGranted: true)')
259
+ async aiProviderModels(
260
+ @Arg('provider') provider: string,
261
+ @Arg('apiKey', { nullable: true }) apiKey: string,
262
+ @Arg('agentId', { nullable: true }) agentId: string,
263
+ @Arg('baseURL', { nullable: true }) baseURL: string,
264
+ @Arg('organization', { nullable: true }) organization: string,
265
+ @Ctx() context: ResolverContext
266
+ ): Promise<string[]> {
267
+ let key = apiKey?.trim()
268
+ if (!key && agentId) {
269
+ const directory = await readAiAgentDirectory(context.state.domain)
270
+ key = directory.entries.find(one => one.spec.id === agentId && !one.inherited)?.apiKey
271
+ }
272
+ return listProviderModels({ provider, apiKey: key ?? '', baseURL, organization })
273
+ }
274
+
275
+ @Mutation(() => Boolean, {
276
+ description: 'Saves this domain\'s AI agents and their keys in one transaction. Read aiAgentSettings again for how they now stand.'
277
+ })
278
+ @Directive('@transaction')
279
+ @Directive('@privilege(category: "setting", privilege: "mutation", domainOwnerGranted: true)')
280
+ async saveAiAgents(@Arg('input', () => AiAgentsInput) input: AiAgentsInput, @Ctx() context: ResolverContext): Promise<boolean> {
281
+ const { tx, domain, user } = context.state
282
+ await saveAiAgents(input, { tx, domain, user })
283
+ return true
284
+ }
285
+
286
+ @Query(() => [AiUsageBucket], {
287
+ description:
288
+ 'AI usage per bucket, agent and person, in this domain\'s time zone. With includeDescendants, what the domains below did with this domain\'s agents is added - per domain, without their people.'
289
+ })
290
+ @Directive('@privilege(category: "setting", privilege: "query", domainOwnerGranted: true)')
291
+ async aiUsageSummary(
292
+ @Arg('from') from: Date,
293
+ @Arg('to') to: Date,
294
+ @Arg('bucket', { defaultValue: 'day' }) bucket: string,
295
+ @Arg('agentIds', () => [String], { nullable: true }) agentIds: string[],
296
+ @Arg('userIds', () => [String], { nullable: true }) userIds: string[],
297
+ @Arg('includeDescendants', { nullable: true }) includeDescendants: boolean,
298
+ @Ctx() context: ResolverContext
299
+ ): Promise<AiUsageBucket[]> {
300
+ const size = bucket === 'hour' || bucket === 'month' ? bucket : 'day'
301
+ return summarizeAiUsage(context.state.domain, { from, to, bucket: size, agentIds, userIds, includeDescendants: !!includeDescendants })
302
+ }
303
+ }
304
+
305
+ function agentView(
306
+ entry: AiAgentEntry,
307
+ defaultAgentId: string | undefined,
308
+ nameOf: Map<string, string>,
309
+ usedBelow: { id: string; name: string }[]
310
+ ): AiAgentView {
311
+ const domainOf = (id?: string) => (id ? { id, name: nameOf.get(id) ?? id } : undefined)
312
+ return {
313
+ id: entry.spec.id,
314
+ name: entry.spec.name,
315
+ provider: entry.spec.provider,
316
+ model: entry.spec.model,
317
+ baseURL: entry.spec.baseURL,
318
+ organization: entry.spec.organization,
319
+ active: entry.spec.active,
320
+ owner: domainOf(entry.ownerDomainId)!,
321
+ inherited: entry.inherited,
322
+ turnedOff: entry.turnedOff,
323
+ keyState: entry.keyState,
324
+ keyDomain: domainOf(entry.keyDomainId),
325
+ keyHint: entry.keyHint,
326
+ usable: entry.usable,
327
+ isDefault: entry.spec.id === defaultAgentId,
328
+ usedBelow: entry.inherited ? [] : usedBelow
329
+ }
330
+ }
@@ -29,18 +29,18 @@ class AiRegistrationWarning {
29
29
  message: string
30
30
  }
31
31
 
32
- @ObjectType({ description: "Whether this deployment has an AI, and what its registrations are missing." })
32
+ @ObjectType({ description: "Whether this domain has an AI, and what the registrations are missing." })
33
33
  class AiCapabilityView {
34
34
  @Field({ description: "'configured' or 'not-configured'. A screen that has not asked yet knows a third state of its own: it does not know." })
35
35
  state: string
36
36
 
37
- @Field({ nullable: true, description: 'Which provider answers.' })
37
+ @Field({ nullable: true, description: 'The provider of the domain\'s default AI agent.' })
38
38
  provider?: string
39
39
 
40
- @Field({ nullable: true, description: 'The default model this deployment would use.' })
40
+ @Field({ nullable: true, description: 'The model of the domain\'s default AI agent.' })
41
41
  model?: string
42
42
 
43
- @Field({ nullable: true, description: 'Why it is not configured: no-config · no-provider · unknown-provider · no-adapter.' })
43
+ @Field({ nullable: true, description: 'Why it is not configured: no-agent · no-agent-key · no-usable-agent.' })
44
44
  reason?: string
45
45
 
46
46
  @Field(() => [AiRegistrationWarning], { description: 'Tools with no declared privilege, and modes offering work their tools cannot do.' })
@@ -50,11 +50,11 @@ class AiCapabilityView {
50
50
  @Resolver()
51
51
  export class AssistantCapabilityResolver {
52
52
  @Query(() => AiCapabilityView, {
53
- description: 'Whether this deployment has an AI configured, and which registrations are incomplete. Read this before offering to ask the assistant — do not find out by failing.'
53
+ description: 'Whether this domain has a usable AI agent, and which registrations are incomplete. Read this before offering to ask the assistant — do not find out by failing.'
54
54
  })
55
55
  @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
56
- async aiCapability(@Ctx() _context: any): Promise<AiCapabilityView> {
57
- const capability = aiCapability()
56
+ async aiCapability(@Ctx() context: any): Promise<AiCapabilityView> {
57
+ const capability = await aiCapability(context.state.domain)
58
58
  const warnings = [...getToolRegistryWarnings(), ...getAssistantModeWarnings()].map(warning => ({
59
59
  code: warning.code,
60
60
  params: warning.params,
@@ -26,15 +26,8 @@ import { Arg, Ctx, Directive, Field, InputType, Mutation, ObjectType, Resolver }
26
26
  import { GraphQLJSON } from 'graphql-scalars'
27
27
  import '@things-factory/auth-base'
28
28
 
29
- import { config } from '@things-factory/env'
30
- import { getRepository } from '@things-factory/shell'
31
- import {
32
- createAIClient,
33
- findToolSpec,
34
- getDefaultAIClient,
35
- registryAssistantChat,
36
- type AIClient
37
- } from '@things-factory/ai-client-base'
29
+ import { getRepository, rethrowSpoken } from '@things-factory/shell'
30
+ import { aiClientFor, findToolSpec, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
38
31
 
39
32
  import { ChatSession } from './chat-session/chat-session.js'
40
33
  import { assertStationSession } from './chat-session/station-session-guard.js'
@@ -132,6 +125,12 @@ class AssistantChatInput {
132
125
  })
133
126
  attachmentIds?: string[]
134
127
 
128
+ @Field({
129
+ nullable: true,
130
+ description:
131
+ 'The AI agent the person picked for this turn, from aiAgentChoices. Omitted takes the domain default. An agent this domain cannot use is refused - the provider, the model and the key are always the server\'s.'
132
+ })
133
+ agentId?: string
135
134
  }
136
135
 
137
136
  @ObjectType()
@@ -142,6 +141,9 @@ class AssistantChatOutput {
142
141
  @Field({ description: 'AI client identifier (provider:model).' })
143
142
  clientId: string
144
143
 
144
+ @Field({ nullable: true, description: 'The AI agent that answered.' })
145
+ agentId?: string
146
+
145
147
  @Field({ nullable: true, description: 'Echo of the session id, when persisted.' })
146
148
  sessionId?: string
147
149
 
@@ -186,14 +188,6 @@ class AssistantChatOutput {
186
188
  groundingWarnings?: any
187
189
  }
188
190
 
189
- function resolveAIClient(): AIClient | undefined {
190
- const existing = getDefaultAIClient()
191
- if (existing) return existing
192
- const cfg = config.get('aiClient', null) as any
193
- if (cfg && cfg.provider) return createAIClient(cfg)
194
- return undefined
195
- }
196
-
197
191
  @Resolver()
198
192
  export class AssistantChatResolver {
199
193
  /*
@@ -213,12 +207,26 @@ export class AssistantChatResolver {
213
207
  */
214
208
  @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
215
209
  async assistantChat(@Arg('input') input: AssistantChatInput, @Ctx() context: any): Promise<AssistantChatOutput> {
216
- const client = resolveAIClient()
217
- if (!client) {
218
- throw new Error(
219
- 'No AI client configured. Set config.aiClient = { provider, model, apiKey } or call setDefaultAIClient(...).'
220
- )
210
+ const { domain, user } = context.state
211
+
212
+ /*
213
+ * The domain's agent - the one picked, or the default (design/ai/agent-settings.md §4). Asked before anything is
214
+ * stored: a domain with no AI, or an agent it cannot use, is refused as itself.
215
+ */
216
+ let client: AgentClient
217
+ try {
218
+ client = await aiClientFor({
219
+ domain,
220
+ user,
221
+ agentId: input.agentId,
222
+ modeKind: input.mode.kind,
223
+ sessionId: input.sessionId
224
+ })
225
+ } catch (error) {
226
+ rethrowSpoken(error, context)
221
227
  }
228
+ /* A tool that asks a model of its own within this turn answers with the same agent (ToolCallContext.state). */
229
+ context.state.aiAgentId = client.agent.id
222
230
 
223
231
  /*
224
232
  * Read the declaration before anything is stored. A kind this server does not have is a
@@ -228,7 +236,6 @@ export class AssistantChatResolver {
228
236
  const t = requestTranslator(context)
229
237
  const settings = resolveModeSettings(input.mode, t)
230
238
 
231
- const { domain, user } = context.state
232
239
  const sessionRepo = getRepository(ChatSession)
233
240
  const messageRepo = getRepository(ChatMessage)
234
241
 
@@ -493,7 +500,6 @@ export class AssistantChatResolver {
493
500
  hostContext: input.hostContext
494
501
  }),
495
502
  state: context.state,
496
- model: settings.model,
497
503
  temperature: settings.temperature,
498
504
  maxTokens: settings.maxTokens,
499
505
  requireToolOnFirstTurn: settings.requireToolOnFirstTurn
@@ -543,6 +549,7 @@ export class AssistantChatResolver {
543
549
 
544
550
  await sessionRepo.update(session.id!, {
545
551
  aiClientId: client.id,
552
+ agentId: client.agent.id,
546
553
  updater: user,
547
554
  ...activityPatch(result.reply, new Date(), answerDoors)
548
555
  } as any)
@@ -568,6 +575,7 @@ export class AssistantChatResolver {
568
575
  return {
569
576
  reply: result.reply,
570
577
  clientId: client.id,
578
+ agentId: client.agent.id,
571
579
  sessionId: session?.id,
572
580
  userMessageId,
573
581
  assistantMessageId,
@@ -19,7 +19,6 @@ beforeEach(() => {
19
19
  systemPrompt: 'you answer about one space',
20
20
  toolCategories: ['twin-ops'],
21
21
  requireToolOnFirstTurn: true,
22
- model: 'claude-sonnet-5',
23
22
  maxTokens: 2048,
24
23
  temperature: 0.2,
25
24
  slashTemplates: [{ name: 'summarize', descriptionKey: 'twin.slash.summarize', templateKey: 'twin.slash.summarize.template' }],
@@ -37,7 +36,6 @@ it('★ the settings come from the registered mode', () => {
37
36
  /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
38
37
  toolCategories: ['twin-ops', 'assistant-workspace'],
39
38
  requireToolOnFirstTurn: true,
40
- model: 'claude-sonnet-5',
41
39
  temperature: 0.2,
42
40
  maxTokens: 2048
43
41
  })
@@ -38,7 +38,6 @@ export interface ModeSettings {
38
38
  systemPrompt: string
39
39
  toolCategories?: string[]
40
40
  requireToolOnFirstTurn?: boolean
41
- model?: string
42
41
  temperature?: number
43
42
  maxTokens?: number
44
43
  }
@@ -70,7 +69,6 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
70
69
  /* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
71
70
  toolCategories: modeToolCategories(mode),
72
71
  requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
73
- model: mode.model,
74
72
  temperature: mode.temperature,
75
73
  maxTokens: mode.maxTokens
76
74
  }
@@ -62,8 +62,7 @@ export interface AssistantModeSpec {
62
62
  toolCategories?: string[]
63
63
  /** Make the model read before it answers — for surfaces whose subject is live state. */
64
64
  requireToolOnFirstTurn?: boolean
65
- /** Model and limits are policy, not a request field. */
66
- model?: string
65
+ /** Limits are policy, not a request field. The model is the domain's AI agent's (design/ai/agent-settings.md). */
67
66
  temperature?: number
68
67
  maxTokens?: number
69
68
  /** What `/` offers. Declared as keys; the server translates. */
@@ -206,6 +206,11 @@ export class ChatSession {
206
206
  @Field({ nullable: true })
207
207
  aiClientId?: string
208
208
 
209
+ /** The agent that answered the last turn. Every turn's agent is in the AI usage rows. */
210
+ @Column({ nullable: true })
211
+ @Field({ nullable: true, description: 'The AI agent that answered the last turn.' })
212
+ agentId?: string
213
+
209
214
  @CreateDateColumn()
210
215
  @Field({ nullable: true })
211
216
  createdAt?: Date
@@ -2,6 +2,7 @@ import { ChatSessionResolver } from './chat-session-resolver.js'
2
2
  import { AssistantChatResolver } from './assistant-chat-resolver.js'
3
3
  import { AssistantModeResolver } from './assistant-mode/assistant-mode-resolver.js'
4
4
  import { AssistantCapabilityResolver } from './assistant-capability-resolver.js'
5
+ import { AiAgentResolver } from './ai-agent-resolver.js'
5
6
  import { MySessionActivitySubscription } from './chat-session/session-activity-subscription.js'
6
7
  import { ChatMessageSubscription } from './chat-message/chat-message-subscription.js'
7
8
  import { entities as ChatSessionEntities } from './chat-session/index.js'
@@ -72,6 +73,8 @@ export const schema = {
72
73
  AssistantModeResolver,
73
74
  /* 「이 배포에 AI 가 있나 · 무엇이 선언 없이 등록됐나」 — 창구 하나(표준 A7-a). */
74
75
  AssistantCapabilityResolver,
76
+ /* A domain's AI agents - the settings card and the dock's picker (design/ai/agent-settings.md). */
77
+ AiAgentResolver,
75
78
  ChatMessageSubscription,
76
79
  /* 내가 참여한 모든 세션의 활동 — 창을 닫아 둔 표면이 배지·목록을 갱신할 수 있게. */
77
80
  MySessionActivitySubscription,
@@ -48,7 +48,6 @@ export async function runRegisteredMode(
48
48
  systemPrompt: settings.systemPrompt,
49
49
  toolCategories: settings.toolCategories,
50
50
  requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),
51
- model: settings.model,
52
51
  temperature: settings.temperature,
53
52
  maxTokens: settings.maxTokens,
54
53
  hostContext: options.hostContext,
@@ -9,6 +9,8 @@
9
9
  */
10
10
  jest.mock('@things-factory/env', () => ({ logger: { warn: jest.fn() } }))
11
11
  jest.mock('@things-factory/auth-base', () => ({ privilegeOfField: () => ({ known: false }) }))
12
+ /* ai-client-base keeps the AI usage table since 2026-10-12, so its root brings shell too - the same stand-in. */
13
+ jest.mock('@things-factory/ai-client-base', () => ({ getToolRegistryWarnings: () => [] }))
12
14
 
13
15
  import { formatToolRegistryWarnings } from './tool-registry-boot-report'
14
16