@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.26",
3
+ "version": "10.1.28",
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.26",
33
- "@things-factory/auth-base": "^10.1.26",
32
+ "@things-factory/ai-client-base": "^10.1.28",
33
+ "@things-factory/auth-base": "^10.1.28",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.26",
35
+ "@things-factory/shell": "^10.1.28",
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": "151bda14d899442c6846532ddded1e2bfe23a2f5"
47
+ "gitHead": "3a260a0f79bc6b41a692a103955bfcc541aff60e"
48
48
  }
@@ -0,0 +1,56 @@
1
+ /*
2
+ * 모든 문이 **자기 형을 명시하는가** — 빠지면 부팅이 죽는다.
3
+ *
4
+ * ── 왜 이 시험이 있나 ──────────────────────────────────────────────────────
5
+ * `chatMessageActivity` 를 async 로 바꾸자(받는 사람마다 가리기 — ADR-0073 판정 1) 반환형이
6
+ * `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다. 스키마를
7
+ * 세울 때 NoExplicitTypeError 가 나고 **패키지 적재 자체가 실패한다** — 시험이 아니라 서버가 죽는다.
8
+ * 컴파일은 통과하므로 타입검사도 못 잡는다. plant 레인이 설치본을 올리다 잡아 알려 주었다(2026-09-18).
9
+ *
10
+ * ── 왜 스키마를 안 세우고 글자를 읽나 ─────────────────────────────────────
11
+ * 이 꾸러미의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
12
+ * 같은 오류가 나고, 그러면 이 시험은 아무것도 못 가른다. 대신 선언을 읽는다 — 데코레이터가 형을
13
+ * 인자로 받고 있는지만 본다. 그것이 빠진 자리가 정확히 터지는 자리다.
14
+ */
15
+ import { readFileSync, readdirSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ const root = join(__dirname, 'service')
19
+
20
+ function sources(dir: string): { path: string; text: string }[] {
21
+ const out: { path: string; text: string }[] = []
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ const path = join(dir, entry.name)
24
+ if (entry.isDirectory()) out.push(...sources(path))
25
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
26
+ out.push({ path, text: readFileSync(path, 'utf-8') })
27
+ }
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** `@Query(`, `@Mutation(`, `@Subscription(` 바로 다음 글자가 형을 주고 있나. */
33
+ function untyped(): string[] {
34
+ const out: string[] = []
35
+ for (const { path, text } of sources(root)) {
36
+ for (const match of text.matchAll(/@(Query|Mutation|Subscription)\(\s*([^\n]*)/g)) {
37
+ const [, kind, rest] = match
38
+ /* 형을 주는 두 모양: `() => X` 와 `type => X`. 그 밖은 옵션 객체부터 시작한 것이다. */
39
+ if (/^\(\s*\)\s*=>/.test(rest) || /^\w+\s*=>/.test(rest)) continue
40
+ const line = text.slice(0, match.index).split('\n').length
41
+ out.push(`${path.slice(root.length + 1)}:${line} @${kind}`)
42
+ }
43
+ }
44
+ return out
45
+ }
46
+
47
+ describe('every GraphQL door names its type', () => {
48
+ it('★ 형을 안 준 Query/Mutation/Subscription 이 없다 — 있으면 부팅에서 터진다', () => {
49
+ expect(untyped()).toEqual([])
50
+ })
51
+
52
+ it('이 시험이 실제로 문들을 보고 있다 — 정규식이 소스와 어긋나면 0건으로 조용히 통과한다', () => {
53
+ const doors = sources(root).flatMap(s => [...s.text.matchAll(/@(Query|Mutation|Subscription)\(/g)])
54
+ expect(doors.length).toBeGreaterThan(20)
55
+ })
56
+ })
@@ -0,0 +1,66 @@
1
+ /*
2
+ * One window for one question: what state is this deployment's AI in.
3
+ *
4
+ * Two things were being answered by failure or by a log line — whether a provider is configured at
5
+ * all (standard A7-a), and which registrations are not yet what the contracts ask for (A1 stage one,
6
+ * and the modes' `/` sentences). They are the same question asked by different people, so they come
7
+ * back together, as codes.
8
+ *
9
+ * A screen reads this **before** offering to ask the assistant. It is a query: reading what a
10
+ * deployment can do is reading.
11
+ */
12
+ import { Ctx, Directive, Field, ObjectType, Query, Resolver } from 'type-graphql'
13
+ import { GraphQLJSON } from 'graphql-scalars'
14
+ import '@things-factory/auth-base'
15
+
16
+ import { aiCapability, getToolRegistryWarnings } from '@things-factory/ai-client-base'
17
+
18
+ import { getAssistantModeWarnings } from './assistant-mode/index.js'
19
+
20
+ @ObjectType({ description: 'A registration that was accepted but is not yet what the contract asks for.' })
21
+ class AiRegistrationWarning {
22
+ @Field({ description: 'What is missing, as a code — never prose a screen has to parse.' })
23
+ code: string
24
+
25
+ @Field(() => GraphQLJSON, { description: 'Which registration it is about (category, tool, mode, slash).' })
26
+ params: any
27
+
28
+ @Field({ description: 'The same thing in a sentence, for whoever reads a boot log.' })
29
+ message: string
30
+ }
31
+
32
+ @ObjectType({ description: "Whether this deployment has an AI, and what its registrations are missing." })
33
+ class AiCapabilityView {
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
+ state: string
36
+
37
+ @Field({ nullable: true, description: 'Which provider answers.' })
38
+ provider?: string
39
+
40
+ @Field({ nullable: true, description: 'The default model this deployment would use.' })
41
+ model?: string
42
+
43
+ @Field({ nullable: true, description: 'Why it is not configured: no-config · no-provider · unknown-provider · no-adapter.' })
44
+ reason?: string
45
+
46
+ @Field(() => [AiRegistrationWarning], { description: 'Tools with no declared privilege, and modes offering work their tools cannot do.' })
47
+ warnings: AiRegistrationWarning[]
48
+ }
49
+
50
+ @Resolver()
51
+ export class AssistantCapabilityResolver {
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.'
54
+ })
55
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
56
+ async aiCapability(@Ctx() _context: any): Promise<AiCapabilityView> {
57
+ const capability = aiCapability()
58
+ const warnings = [...getToolRegistryWarnings(), ...getAssistantModeWarnings()].map(warning => ({
59
+ code: warning.code,
60
+ params: warning.params,
61
+ message: warning.message
62
+ }))
63
+
64
+ return { ...capability, warnings } as AiCapabilityView
65
+ }
66
+ }
@@ -28,16 +28,32 @@ import '@things-factory/auth-base'
28
28
 
29
29
  import { config } from '@things-factory/env'
30
30
  import { getRepository } from '@things-factory/shell'
31
- import { createAIClient, getDefaultAIClient, registryAssistantChat, type AIClient } from '@things-factory/ai-client-base'
31
+ import {
32
+ createAIClient,
33
+ findToolSpec,
34
+ getDefaultAIClient,
35
+ registryAssistantChat,
36
+ type AIClient
37
+ } from '@things-factory/ai-client-base'
32
38
 
33
39
  import { ChatSession } from './chat-session/chat-session.js'
34
40
  import { assertStationSession } from './chat-session/station-session-guard.js'
35
41
  import { ChatMessage } from './chat-message/chat-message.js'
36
42
  import { buildLlmHistory } from './chat-message/llm-history.js'
43
+ import { splitByCap } from './chat-message/history-summary.js'
44
+ import { foldSessionHistory } from './chat-message/fold-session-history.js'
45
+ import { groundingSourcesFor } from './grounding-sources.js'
37
46
  import { publishChatMessage } from './chat-message/chat-message-publish.js'
38
47
  import { publishSessionActivity } from './chat-session/session-activity-publish.js'
39
48
  import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
40
49
  import { toolAccessFor } from './tool-access.js'
50
+ import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
51
+ import { doorsForAnswer } from './chat-message/message-doors.js'
52
+ import { readerGate } from './chat-message/reader-gate.js'
53
+ import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
54
+ import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
55
+ import { resolveModeSettings } from './assistant-mode/resolve.js'
56
+ import { requestTranslator } from './request-translator.js'
41
57
 
42
58
  /**
43
59
  * How many stored messages reach the model.
@@ -57,6 +73,19 @@ class AssistantMessageInput {
57
73
  content: string
58
74
  }
59
75
 
76
+ @InputType({ description: 'Which conversation this is, and what it is about.' })
77
+ class AssistantModeInput {
78
+ @Field({ description: 'A mode kind the app server registered — `<app>.<surface>`, e.g. "twin.space".' })
79
+ kind: string
80
+
81
+ @Field({
82
+ nullable: true,
83
+ description:
84
+ 'What this conversation is about — the space, the station, the model being edited. With `kind` it is the conversation\'s lifetime: when either changes the screen closes the conversation and starts another.'
85
+ })
86
+ target?: string
87
+ }
88
+
60
89
  @InputType({ description: 'One turn of a conversation that has no document of its own.' })
61
90
  class AssistantChatInput {
62
91
  @Field({
@@ -71,18 +100,11 @@ class AssistantChatInput {
71
100
  })
72
101
  messages: AssistantMessageInput[]
73
102
 
74
- @Field({
103
+ @Field(() => AssistantModeInput, {
75
104
  description:
76
- '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.'
105
+ 'Which conversation this is. The persona, the tool categories, the model and the limits come from the mode the app server registered (ADR-0061 decision 7) a caller choosing its own tool categories would be a caller choosing its own authority.'
77
106
  })
78
- systemPrompt: string
79
-
80
- @Field(() => [String], {
81
- nullable: true,
82
- description:
83
- 'Registered tool categories this conversation may use (registerToolCategory names, e.g. ["figure-authoring"]). Omitted or empty allows none — a plain conversation. A tool that declares a privilege is offered only to a caller who holds it.'
84
- })
85
- toolCategories?: string[]
107
+ mode: AssistantModeInput
86
108
 
87
109
  @Field(() => GraphQLJSON, {
88
110
  nullable: true,
@@ -98,21 +120,6 @@ class AssistantChatInput {
98
120
  })
99
121
  truncateAfterMessageId?: string
100
122
 
101
- @Field({
102
- nullable: true,
103
- description:
104
- '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.'
105
- })
106
- requireGroundingTools?: boolean
107
-
108
- @Field({ nullable: true, description: 'Model identifier override. Falls back to the configured default.' })
109
- model?: string
110
-
111
- @Field({ nullable: true, description: 'Sampling temperature override.' })
112
- temperature?: number
113
-
114
- @Field({ nullable: true, description: 'Max output tokens per call.' })
115
- maxTokens?: number
116
123
  }
117
124
 
118
125
  @ObjectType()
@@ -132,6 +139,13 @@ class AssistantChatOutput {
132
139
  @Field({ nullable: true, description: 'Stored ChatMessage id of the reply.' })
133
140
  assistantMessageId?: string
134
141
 
142
+ @Field(() => GraphQLJSON, {
143
+ nullable: true,
144
+ description:
145
+ 'Set when this turn did not fit: { omitted, summarized } — how many earlier messages the model did not see, and whether a summary stood in for them. Without it the person watches the conversation lose its beginning in silence, while the model is told.'
146
+ })
147
+ historyFolded?: any
148
+
135
149
  @Field(() => GraphQLJSON, {
136
150
  nullable: true,
137
151
  description: 'Tool usages during the turn — {name, arguments, result, kind}. The surface shows them foldably.'
@@ -194,6 +208,14 @@ export class AssistantChatResolver {
194
208
  )
195
209
  }
196
210
 
211
+ /*
212
+ * Read the declaration before anything is stored. A kind this server does not have is a
213
+ * mismatched deployment, and the person should see that instead of a conversation with no
214
+ * persona and no tools.
215
+ */
216
+ const t = requestTranslator(context)
217
+ const settings = resolveModeSettings(input.mode, t)
218
+
197
219
  const { domain, user } = context.state
198
220
  const sessionRepo = getRepository(ChatSession)
199
221
  const messageRepo = getRepository(ChatMessage)
@@ -203,15 +225,23 @@ export class AssistantChatResolver {
203
225
  const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
204
226
  if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
205
227
  if (found.anchorType === 'figure' && input.hostContext?.figureId !== found.anchorId) {
206
- throw new Error('이 대화는 다른 Figure의 이력입니다. 현재 모델의 대화를 다시 여세요.')
228
+ throw new Error(
229
+ context.t('ai-assistant.text.other-figure-history', {
230
+ defaultValue: 'this conversation belongs to another figure. open the conversation of the model you have open.'
231
+ })
232
+ )
207
233
  }
208
234
  if (found.anchorType === 'application' && (input.hostContext?.app !== found.anchorId || input.hostContext?.figureId)) {
209
- throw new Error('애플리케이션 대화와 모델 대화를 구분해서 다시 여세요.')
235
+ throw new Error(
236
+ context.t('ai-assistant.text.application-vs-model-history', {
237
+ defaultValue: 'this conversation is about the application, not about a model. open the one you want.'
238
+ })
239
+ )
210
240
  }
211
241
  session = found
212
242
  }
213
243
 
214
- assertStationSession(session, input.hostContext)
244
+ assertStationSession(session, input.hostContext, t)
215
245
 
216
246
  const lastUserMessage = input.messages[input.messages.length - 1]
217
247
  let userMessageId: string | undefined
@@ -225,6 +255,12 @@ export class AssistantChatResolver {
225
255
  session: { id: session.id } as any,
226
256
  role: 'user',
227
257
  content: lastUserMessage.content,
258
+ /*
259
+ * This door is the one people call to ask the assistant, so a turn stored here was for the
260
+ * assistant (ADR-0070 A15 decision ①). The value comes from which door was called, never
261
+ * from the request — two sources for one fact can disagree.
262
+ */
263
+ addressee: 'assistant',
228
264
  creator: user,
229
265
  updater: user,
230
266
  parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
@@ -244,6 +280,27 @@ export class AssistantChatResolver {
244
280
  /* noop */
245
281
  }
246
282
 
283
+ /*
284
+ * 사람이 `@이름` 으로 부른 사람은 이 대화에 들어온다(ADR-0071 결정 1). 이 문은 **사람의 턴**을
285
+ * 저장하는 문이라 그 일을 한다 — 모델의 답이나 도구 결과는 아무도 초대하지 않는다.
286
+ */
287
+ const invite = await inviteMentionedUsers({
288
+ content: lastUserMessage.content,
289
+ sessionId: session.id!,
290
+ domainId: domain.id,
291
+ inviter: user,
292
+ participants: getRepository(ChatSessionParticipant),
293
+ visibility: (session as any).visibility
294
+ })
295
+ if (invite.invited.length > 0) {
296
+ const payload = buildInviteNotification(invite.invited, undefined, user)
297
+ try {
298
+ if (payload) await (context.state as any)?.notify?.(payload)
299
+ } catch {
300
+ /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
301
+ }
302
+ }
303
+
247
304
  publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved } as any)
248
305
  publishSessionActivity({
249
306
  domainId: domain.id,
@@ -256,13 +313,52 @@ export class AssistantChatResolver {
256
313
  })
257
314
  }
258
315
 
316
+ /*
317
+ * 이번 턴에 상한 밖으로 밀려난 것이 있는가 — 화면이 「대화가 길어졌다」를 알릴 근거다.
318
+ *
319
+ * 이 문은 그 사실을 내지 않았다. 대화창에는 그 안내를 그리는 자리가 있고(`historyFolded`), 보드
320
+ * 문만 그 값을 채웠다. 그래서 앱의 대화는 앞부분이 잘려 나가도 **사람에게는 아무 말도 하지 않았다** —
321
+ * 모델에게는 「N 개 생략」을 적어 보내면서.
322
+ */
323
+ const readRows = session
324
+ ? await messageRepo.find({
325
+ where: { session: { id: session.id } as any },
326
+ order: { createdAt: 'ASC' },
327
+ relations: ['creator']
328
+ })
329
+ : []
330
+ /*
331
+ * 모델이 읽는 이력도 **부른 사람** 기준으로 가린다 — ADR-0073 판정 1. 가리지 않으면, 문을 한 번도
332
+ * 안 지난 사람이 "아까 그 수 다시 말해 줘" 한 마디로 값을 가져간다. 모델이 옮겨 적는 순간 그 값은
333
+ * 문 없는 새 답이 된다. 가린 자리를 지우지 않고 한 줄로 남기는 이유도 같다 — 지우면 모델이 그런
334
+ * 말이 없었다고 답하고, 그것은 거짓말이다.
335
+ */
336
+ const storedRows = await readerGate(context)(readRows)
337
+ const capped = splitByCap(
338
+ storedRows.map(row => ({ id: row.id, role: row.role, content: row.content ?? '' })),
339
+ LLM_HISTORY_MAX_TURNS
340
+ )
341
+ const historyFolded =
342
+ capped.dropped.length > 0
343
+ ? { omitted: capped.dropped.length, summarized: Boolean((session as any)?.lastSummary) }
344
+ : undefined
345
+
259
346
  const conversation = session
260
347
  ? buildLlmHistory(
261
- await messageRepo.find({
262
- where: { session: { id: session.id } as any },
263
- order: { createdAt: 'ASC' },
264
- relations: ['creator']
265
- }),
348
+ /*
349
+ * The model reads who spoke and who they spoke to. The entity carries the speaker as a
350
+ * relation and the addressee as a column; the history wants them as plain fields, and
351
+ * without this mapping every line arrived unattributed — a room of people read as one
352
+ * voice (ADR-0070 A15 decision ②).
353
+ */
354
+ storedRows.map(message => ({
355
+ id: message.id,
356
+ role: message.role,
357
+ content: message.content,
358
+ senderId: (message as any).creator?.id,
359
+ senderName: (message as any).creator?.name,
360
+ addressee: (message as any).addressee
361
+ })),
266
362
  {
267
363
  maxTurns: LLM_HISTORY_MAX_TURNS,
268
364
  truncateAfterMessageId: input.truncateAfterMessageId,
@@ -291,23 +387,51 @@ export class AssistantChatResolver {
291
387
  const result = await registryAssistantChat({
292
388
  client,
293
389
  messages: conversation as any,
294
- systemPrompt: input.systemPrompt,
295
- toolCategories: input.toolCategories,
390
+ systemPrompt: settings.systemPrompt,
391
+ toolCategories: settings.toolCategories,
296
392
  toolAccess: toolAccessFor(context),
393
+ /* The stop notice is a sentence a person reads — the server writes it in their language (ADR-0054). */
394
+ t,
297
395
  hostContext: input.hostContext,
396
+ /*
397
+ * 근거는 도구 결과만이 아니다 — 시스템 프롬프트 · 이 대화의 말 · 화면이 보낸 스냅숏도 근거다
398
+ * (`ai-client-base/utils/grounding`). 이 문은 그 셋을 넘기지 않아, **사람이 방금 타이핑한
399
+ * 식별자**를 되받아 적은 답이 「근거 없음」으로 표시됐다. 그런 경고가 몇 번 나오면 사람이 경고
400
+ * 자체를 안 보게 된다.
401
+ */
402
+ groundingSources: groundingSourcesFor({
403
+ systemPrompt: settings.systemPrompt,
404
+ messages: conversation as any,
405
+ hostContext: input.hostContext
406
+ }),
298
407
  state: context.state,
299
- model: input.model,
300
- temperature: input.temperature,
301
- maxTokens: input.maxTokens,
302
- requireToolOnFirstTurn: input.requireGroundingTools
408
+ model: settings.model,
409
+ temperature: settings.temperature,
410
+ maxTokens: settings.maxTokens,
411
+ requireToolOnFirstTurn: settings.requireToolOnFirstTurn
303
412
  })
304
413
 
414
+ /*
415
+ * 이 답이 지나온 문 — ADR-0073 판정 1. 행에도, 목록 미리보기에도 같은 값이 간다. 한쪽만 적으면
416
+ * 가려진 답이 목록에는 그대로 뜬다.
417
+ */
418
+ const answerDoors = doorsForAnswer(
419
+ (result.loop.toolUsages ?? []).map((usage: any) => usage?.name).filter(Boolean),
420
+ name => (findToolSpec(name, settings.toolCategories) as any)?.doors
421
+ )
422
+
305
423
  let assistantMessageId: string | undefined
306
424
  if (session) {
307
425
  const stored = await messageRepo.save({
308
426
  session: { id: session.id } as any,
309
427
  role: 'assistant',
310
428
  content: result.reply,
429
+ /*
430
+ * 이 답이 지나온 문을 함께 적는다 — ADR-0073 판정 1. 방은 도메인 안에서 공개이므로, 이 문장이
431
+ * 실어 나르는 값을 나중에 읽는 사람이 열 수 있는지 판정할 근거가 행에 있어야 한다. 적지 않으면
432
+ * 빈 목록이 되고, 빈 목록은 누구나 읽는다는 뜻이다.
433
+ */
434
+ doors: answerDoors,
311
435
  toolUsages: result.loop.toolUsages?.length ? JSON.stringify(result.loop.toolUsages) : undefined,
312
436
  groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : undefined,
313
437
  creator: user,
@@ -331,8 +455,25 @@ export class AssistantChatResolver {
331
455
  await sessionRepo.update(session.id!, {
332
456
  aiClientId: client.id,
333
457
  updater: user,
334
- ...activityPatch(result.reply)
458
+ ...activityPatch(result.reply, new Date(), answerDoors)
335
459
  } as any)
460
+
461
+ /*
462
+ * 상한 밖으로 밀려난 앞부분을 요약으로 접는다 — **답을 돌려준 뒤**에 한다(사용자 대기 시간에
463
+ * 요약 호출을 얹지 않는다). 기다리지 않으므로 실패해도 대화에는 영향이 없다: 요약이 없으면 다음
464
+ * 턴이 「N 개 생략」으로 정직하게 알린다.
465
+ *
466
+ * 이 문에는 이 호출이 없었다. 조각은 다 있었는데(상한 · 생략 알림 · `lastSummary` · 순수 규칙)
467
+ * 부르는 쪽이 보드 제품 하나뿐이어서, 앱의 대화는 며칠이 지나도 앞부분을 요약하지 않고 매 턴
468
+ * 「생략했다」고만 말했다 — 협의가 길어질수록 처음에 합의한 것이 먼저 사라진다.
469
+ */
470
+ void foldSessionHistory({
471
+ sessionId: session.id!,
472
+ domainId: domain.id,
473
+ client,
474
+ maxTurns: LLM_HISTORY_MAX_TURNS,
475
+ locale: context.state?.locale
476
+ })
336
477
  }
337
478
 
338
479
  return {
@@ -344,7 +485,8 @@ export class AssistantChatResolver {
344
485
  toolUsages: result.loop.toolUsages?.length ? result.loop.toolUsages : null,
345
486
  offeredTools: result.offeredTools,
346
487
  proposals: result.loop.proposals?.length ? result.loop.proposals : null,
347
- groundingWarnings: result.groundingWarnings?.length ? result.groundingWarnings : null
488
+ groundingWarnings: result.groundingWarnings?.length ? result.groundingWarnings : null,
489
+ historyFolded: historyFolded ?? null
348
490
  }
349
491
  }
350
492
  }
@@ -0,0 +1,102 @@
1
+ /*
2
+ * The screen asks what its conversation offers.
3
+ *
4
+ * The `/` sentences, the `@` catalog and the two notices used to travel from the browser into the
5
+ * request, which meant the screen and the door could describe the same conversation differently.
6
+ * They are declared once, in the mode, as i18n keys — and this query hands them over already
7
+ * written in the request's locale (ADR-0054, and the same rule A11 applied to the stop notice).
8
+ * The persona, the tool categories and the limits stay on the server: nothing here returns them.
9
+ */
10
+ import { Arg, Ctx, Directive, Field, ObjectType, Query, Resolver } from 'type-graphql'
11
+ import { GraphQLJSON } from 'graphql-scalars'
12
+ import '@things-factory/auth-base'
13
+
14
+ import { assistantModeView } from './resolve.js'
15
+ import { assistantModesForSubject } from './registry.js'
16
+ import { requestTranslator } from '../request-translator.js'
17
+
18
+ @ObjectType({ description: 'One `/` sentence, in the words of this request.' })
19
+ class AssistantModeSlashView {
20
+ @Field({ description: 'Short name shown in the list.' })
21
+ name: string
22
+
23
+ @Field({ description: 'One line about what it does.' })
24
+ description: string
25
+
26
+ @Field({ description: 'The sentence that lands in the input.' })
27
+ template: string
28
+ }
29
+
30
+ @ObjectType({ description: 'What a conversation offers on screen.' })
31
+ class AssistantModeView {
32
+ @Field({ description: 'The mode kind this describes.' })
33
+ kind: string
34
+
35
+ @Field(() => [AssistantModeSlashView], { description: 'What `/` offers.' })
36
+ slashTemplates: AssistantModeSlashView[]
37
+
38
+ @Field(() => GraphQLJSON, { description: 'What `@` offers.' })
39
+ catalogEntries: any[]
40
+
41
+ @Field(() => GraphQLJSON, { description: 'Follow-up chips for a proposal that arrived without its own.' })
42
+ proposalChoices: any[]
43
+
44
+ @Field({ nullable: true, description: 'What the empty input says — what this conversation is for.' })
45
+ placeholder?: string
46
+
47
+ @Field({ nullable: true, description: 'The line under an empty conversation.' })
48
+ footerNotice?: string
49
+
50
+ @Field({ nullable: true, description: 'The line shown once the host reports a proposal staged.' })
51
+ stagedNotice?: string
52
+ }
53
+
54
+ @ObjectType({ description: 'A conversation a screen may open for what it is showing.' })
55
+ class AssistantModeChoice {
56
+ @Field({ description: 'The mode kind to open.' })
57
+ kind: string
58
+
59
+ @Field({ description: 'What to call it where a person chooses — already in the words of this request.' })
60
+ label: string
61
+
62
+ @Field({ description: 'Whether this is the one to open when the person has not chosen.' })
63
+ isDefault: boolean
64
+ }
65
+
66
+ @Resolver()
67
+ export class AssistantModeResolver {
68
+ /*
69
+ * 이 화면이 보여 주는 것으로 열 수 있는 대화들 — ADR-0072 A17 ①.
70
+ *
71
+ * 화면은 자기가 무엇을 보여 주는지(`subject`)만 안다. 그 위에 어느 대화가 서는지는 앱 서버가
72
+ * 선언한다. 화면이 종류 목록을 손으로 들고 있으면, 대화를 하나 더 세운 날 그 화면만 모른 채로 남는다.
73
+ *
74
+ * 하나만 오면 고르개를 내지 않는다 — 고를 것이 없는 고르개는 자리만 차지한다.
75
+ */
76
+ @Query(() => [AssistantModeChoice], {
77
+ description:
78
+ 'Conversations that stand for this kind of subject, the default first. A screen showing one subject opens the head of this list and offers the rest; a single entry means there is nothing to choose.'
79
+ })
80
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
81
+ async assistantModes(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice[]> {
82
+ const t = requestTranslator(context)
83
+
84
+ return assistantModesForSubject(subject).map(mode => ({
85
+ kind: mode.kind,
86
+ /*
87
+ * 이름이 없는 대화는 종류를 그대로 쓴다. 부팅 보고가 이미 그것을 경고로 말하므로 여기서 숨기지
88
+ * 않는다 — 화면에 `twin.space` 가 뜨면 그것이 빠진 선언을 가리킨다.
89
+ */
90
+ label: mode.labelKey ? t(mode.labelKey) : mode.kind,
91
+ isDefault: (mode.defaultFor ?? []).includes(subject)
92
+ }))
93
+ }
94
+ @Query(() => AssistantModeView, {
95
+ description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
96
+ })
97
+ /* Reading what a conversation offers is reading, and the same door the session list uses. */
98
+ @Directive('@privilege(category: "ai-assistant", privilege: "query")')
99
+ async assistantMode(@Arg('kind') kind: string, @Ctx() context: any): Promise<AssistantModeView> {
100
+ return assistantModeView(kind, requestTranslator(context)) as AssistantModeView
101
+ }
102
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Modes — what each conversation is, declared by the app server that owns the screen.
3
+ *
4
+ * Design: ADR-0061 decision 7 (`operato-twin/design/04-decisions.md`) and the AI standard's A2.
5
+ */
6
+ export {
7
+ registerAssistantMode,
8
+ getAssistantMode,
9
+ listAssistantModes,
10
+ assistantModesForSubject,
11
+ getAssistantModeWarnings,
12
+ clearAssistantModes
13
+ } from './registry.js'
14
+ export { resolveModeSettings, assistantModeView } from './resolve.js'
15
+ export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning } from './types.js'
16
+ export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'