@things-factory/ai-assistant 10.1.28 → 10.1.31

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 (110) hide show
  1. package/client/components/assistant-chat.ts +51 -7
  2. package/client/components/assistant-mode.test.ts +20 -1
  3. package/client/components/assistant-mode.ts +20 -0
  4. package/client/components/panel-aria.test.ts +20 -0
  5. package/client/utils/assistant-session-controller.ts +14 -1
  6. package/client/utils/assistant-session-transport.ts +2 -2
  7. package/dist-client/components/assistant-chat.d.ts +12 -0
  8. package/dist-client/components/assistant-chat.js +48 -6
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-mode.d.ts +22 -0
  11. package/dist-client/components/assistant-mode.js +16 -0
  12. package/dist-client/components/assistant-mode.js.map +1 -1
  13. package/dist-client/components/assistant-mode.test.js +17 -1
  14. package/dist-client/components/assistant-mode.test.js.map +1 -1
  15. package/dist-client/components/panel-aria.test.js +18 -0
  16. package/dist-client/components/panel-aria.test.js.map +1 -1
  17. package/dist-client/tsconfig.tsbuildinfo +1 -1
  18. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  19. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  20. package/dist-client/utils/assistant-session-transport.js +2 -2
  21. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  22. package/dist-server/door-declaration-report.d.ts +14 -0
  23. package/dist-server/door-declaration-report.js +54 -0
  24. package/dist-server/door-declaration-report.js.map +1 -0
  25. package/dist-server/service/assistant-chat-resolver.js +19 -0
  26. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  27. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +8 -0
  28. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +8 -0
  29. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  30. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  31. package/dist-server/service/assistant-mode/index.js +1 -1
  32. package/dist-server/service/assistant-mode/index.js.map +1 -1
  33. package/dist-server/service/assistant-mode/resolve.d.ts +10 -0
  34. package/dist-server/service/assistant-mode/resolve.js +5 -0
  35. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  36. package/dist-server/service/assistant-mode/types.d.ts +21 -0
  37. package/dist-server/service/assistant-mode/types.js.map +1 -1
  38. package/dist-server/service/chat-message/carried-turns.js +14 -2
  39. package/dist-server/service/chat-message/carried-turns.js.map +1 -1
  40. package/dist-server/service/chat-message/door-access.d.ts +19 -7
  41. package/dist-server/service/chat-message/door-access.js +19 -11
  42. package/dist-server/service/chat-message/door-access.js.map +1 -1
  43. package/dist-server/service/chat-message/reader-gate.js +6 -4
  44. package/dist-server/service/chat-message/reader-gate.js.map +1 -1
  45. package/dist-server/service/chat-session/chat-session.d.ts +12 -0
  46. package/dist-server/service/chat-session/chat-session.js +12 -11
  47. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  48. package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
  49. package/dist-server/service/chat-session/session-anchor.js +12 -4
  50. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  51. package/dist-server/service/chat-session/session-inbox.d.ts +2 -0
  52. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  53. package/dist-server/service/chat-session/session-kind.d.ts +14 -0
  54. package/dist-server/service/chat-session/session-kind.js +30 -0
  55. package/dist-server/service/chat-session/session-kind.js.map +1 -0
  56. package/dist-server/service/chat-session/session-visibility.js +8 -1
  57. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  58. package/dist-server/service/chat-session-resolver.d.ts +2 -0
  59. package/dist-server/service/chat-session-resolver.js +4 -0
  60. package/dist-server/service/chat-session-resolver.js.map +1 -1
  61. package/dist-server/service/index.d.ts +1 -0
  62. package/dist-server/service/index.js +2 -0
  63. package/dist-server/service/index.js.map +1 -1
  64. package/dist-server/service/testing/conversation-script.d.ts +7 -0
  65. package/dist-server/service/testing/conversation-script.js +1 -0
  66. package/dist-server/service/testing/conversation-script.js.map +1 -1
  67. package/dist-server/service/testing/mode-conformance.d.ts +10 -0
  68. package/dist-server/service/testing/mode-conformance.js +13 -0
  69. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  70. package/dist-server/service/tool-access.js +20 -7
  71. package/dist-server/service/tool-access.js.map +1 -1
  72. package/dist-server/tool-registry-boot-report.js +15 -9
  73. package/dist-server/tool-registry-boot-report.js.map +1 -1
  74. package/dist-server/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +5 -5
  76. package/server/door-declaration-report.test.ts +53 -0
  77. package/server/door-declaration-report.ts +61 -0
  78. package/server/service/assistant-chat-resolver.ts +21 -0
  79. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  80. package/server/service/assistant-mode/index.ts +1 -1
  81. package/server/service/assistant-mode/resolve.test.ts +42 -0
  82. package/server/service/assistant-mode/resolve.ts +9 -0
  83. package/server/service/assistant-mode/types.ts +18 -0
  84. package/server/service/chat-message/carried-turns.test.ts +13 -2
  85. package/server/service/chat-message/carried-turns.ts +16 -3
  86. package/server/service/chat-message/door-access.test.ts +72 -0
  87. package/server/service/chat-message/door-access.ts +43 -23
  88. package/server/service/chat-message/fold-history-window.test.ts +184 -0
  89. package/server/service/chat-message/reader-gate.ts +6 -4
  90. package/server/service/chat-session/chat-session.ts +15 -0
  91. package/server/service/chat-session/session-anchor.ts +12 -3
  92. package/server/service/chat-session/session-inbox.ts +2 -0
  93. package/server/service/chat-session/session-kind.test.ts +24 -0
  94. package/server/service/chat-session/session-kind.ts +32 -0
  95. package/server/service/chat-session/session-visibility.test.ts +16 -0
  96. package/server/service/chat-session/session-visibility.ts +9 -1
  97. package/server/service/chat-session-resolver.ts +4 -0
  98. package/server/service/index.ts +2 -0
  99. package/server/service/testing/conversation-scenario.test.ts +2 -1
  100. package/server/service/testing/conversation-script.test.ts +4 -2
  101. package/server/service/testing/conversation-script.ts +8 -0
  102. package/server/service/testing/mode-conformance.ts +15 -0
  103. package/server/service/tool-access.test.ts +67 -19
  104. package/server/service/tool-access.ts +24 -15
  105. package/server/tool-registry-boot-report.ts +14 -9
  106. package/translations/en.json +3 -2
  107. package/translations/ja.json +3 -2
  108. package/translations/ko.json +3 -2
  109. package/translations/ms.json +3 -2
  110. package/translations/zh.json +3 -2
@@ -219,6 +219,8 @@ const ASSISTANT_MODE_QUERY = gql`
219
219
  catalogEntries
220
220
  proposalChoices
221
221
  placeholder
222
+ intro
223
+ examples
222
224
  footerNotice
223
225
  stagedNotice
224
226
  }
@@ -563,6 +565,22 @@ export class OxAssistantChat extends LitElement {
563
565
  @state()
564
566
  private dragOver = false
565
567
 
568
+ /*
569
+ * ── 왜 이미지만 받나 ──────────────────────────────────────────────────────
570
+ * `.json` · `.txt` · `.csv` 도 받고 있었다. 그 파일들은 `hostContext.attachments` 로 실려 갔고
571
+ * **그것을 읽어 모델에 붙이는 코드가 없었다.** 사람이 CSV 를 붙이고 「이 표 좀 봐줘」라고 하면 화면에
572
+ * 파일 칩이 뜨고 답도 왔다. 그 답은 파일을 본 적이 없고, 오류도 안 났다(2026-09-18 표면 조사).
573
+ *
574
+ * 이미지는 길이 있다 — `assistant-chat-resolver.ts` 가 `hostContext.image` 를 마지막 사람 턴에
575
+ * 붙인다. 그래서 되는 것만 남기고 좁혔다. 못 하는 일을 화면이 권하지 않는다.
576
+ *
577
+ * ── 글 파일을 청하는 제품이 설 때의 모양 (수석 아키텍트, ADR-0071 보탬) ───
578
+ * 이미지와 **같은 자리**(사람 턴)에 붙인다. 크기 상한은 모드가 선언하고, 넘으면 보내기 전에 사실 한
579
+ * 줄로 거절한다 — 조용히 자르지 않는다. 비용은 사용량의 축이다(ADR-0061 결정 6).
580
+ *
581
+ * `attachments` 짐 자체는 그대로 둔다: figure 의 도구가 거기서 이미지를 찾는다
582
+ * (`figure-tools.ts` — `ctx.host.image` 가 없을 때의 갈래).
583
+ */
566
584
  private async onAttachFiles(files: FileList | File[] | null | undefined) {
567
585
  if (!files || files.length === 0) return
568
586
  const list = Array.from(files)
@@ -875,13 +893,29 @@ export class OxAssistantChat extends LitElement {
875
893
  */
876
894
  private onModePick(kind: string) {
877
895
  if (!this.mode || kind === this.mode.kind) return
878
- this.mode = { ...this.mode, kind }
879
896
  try {
880
897
  if (this.subject) localStorage.setItem(`assistant-mode:${this.subject}`, kind)
881
898
  } catch {
882
899
  /* 저장이 막힌 브라우저에서도 고르기 자체는 된다 — 다음에 다시 고르면 될 뿐이다. */
883
900
  }
884
- this.dispatchEvent(new CustomEvent('assistant-mode-change', { detail: { kind, subject: this.subject }, bubbles: true, composed: true }))
901
+ this.askHostForMode(kind)
902
+ }
903
+
904
+ /**
905
+ * **고르는 것은 청하는 일이다** — `mode` 를 여기서 바꾸지 않는다.
906
+ *
907
+ * host 는 렌더마다 `mode` 를 다시 내려 준다. 패널이 자기 손으로 고쳐 두면 다음 렌더가 그것을 덮고,
908
+ * 더 나쁜 것은 host 가 **자기가 무엇을 열었는지 모르는 채로 남는다**는 것이다. host 는 대화의 세션을
909
+ * 대상별로 들고 있으므로, 대화가 바뀐 것을 모르면 앞 대화의 세션을 그대로 넘긴다 — 그러면 새 말이 앞
910
+ * 대화에 붙는다(2026-09-18 Figure 레인이 :3300 에서 본 것이 이것이다).
911
+ *
912
+ * 그래서 주인은 하나다: host 가 `mode` 를 갖고, 패널은 「이것으로 바꿔 달라」고 말한다. 기억은 패널이
913
+ * 진다(브라우저마다 다른 값이라 host 가 질 것이 아니다) — 기억한 것을 되살릴 때도 같은 청으로 나간다.
914
+ */
915
+ private askHostForMode(kind: string) {
916
+ this.dispatchEvent(
917
+ new CustomEvent('assistant-mode-change', { detail: { kind, subject: this.subject }, bubbles: true, composed: true })
918
+ )
885
919
  }
886
920
 
887
921
  /** 이 subject 에서 지난번에 고른 대화 — 없으면 undefined. */
@@ -2541,7 +2575,7 @@ export class OxAssistantChat extends LitElement {
2541
2575
  if (this.modeChoices.length < 2 || !this.subject || !this.mode) return
2542
2576
 
2543
2577
  const kind = pickInitialMode(this.mode.kind, this.modeChoices, this.rememberedMode(this.subject))
2544
- if (kind && kind !== this.mode.kind) this.mode = { ...this.mode, kind }
2578
+ if (kind && kind !== this.mode.kind) this.askHostForMode(kind)
2545
2579
  }
2546
2580
 
2547
2581
  updated(changed: Map<string, any>) {
@@ -2905,7 +2939,7 @@ export class OxAssistantChat extends LitElement {
2905
2939
  id="chat-file-input"
2906
2940
  type="file"
2907
2941
  multiple
2908
- accept="image/*,.json,.txt,.csv"
2942
+ accept="image/*"
2909
2943
  style="display: none"
2910
2944
  @change=${(e: Event) => {
2911
2945
  const input = e.target as HTMLInputElement
@@ -2919,7 +2953,7 @@ export class OxAssistantChat extends LitElement {
2919
2953
  type="button"
2920
2954
  class="action"
2921
2955
  @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}
2922
- title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'Attach images or files (drag & drop or paste supported)' })}>
2956
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
2923
2957
  <md-icon>attach_file</md-icon>
2924
2958
  ${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
2925
2959
  </button>
@@ -3444,6 +3478,11 @@ export class OxAssistantChat extends LitElement {
3444
3478
  * 읽었고, 그러면 호스트 값을 한쪽만 반영하는 상태가 만들어질 수 있었다.
3445
3479
  */
3446
3480
  private get exampleGroups(): Array<{ label: string; items: string[] }> {
3481
+ /*
3482
+ * 대화가 자기 예시를 가지면 그것이 먼저다 — 자리표·`/` 와 같은 차례(ADR-0061 결정 7). host 속성은
3483
+ * 대화를 모르므로, 한 화면에 대화가 둘 서면 한쪽에 맞는 글이 다른 쪽에도 뜬다.
3484
+ */
3485
+ if (this.modeView?.examples?.length) return this.modeView.examples
3447
3486
  if (this.examples?.length) return this.examples
3448
3487
  return OxAssistantChat.EXAMPLE_GROUPS.map(group => ({
3449
3488
  label: i18next.t(group.labelKey),
@@ -3605,8 +3644,13 @@ export class OxAssistantChat extends LitElement {
3605
3644
  <div class="empty">
3606
3645
  <div class="header">
3607
3646
  <md-icon class="icon">auto_awesome</md-icon>
3608
- <div class="title">${this.intro?.title ?? i18next.t('ai-assistant.text.empty-title')}</div>
3609
- <div class="subtitle">${this.intro?.subtitle ?? i18next.t('ai-assistant.text.empty-subtitle')}</div>
3647
+ <!-- 대화가 자기 글을 가지면 그것이 먼저다 — host 속성은 대화를 모른다. -->
3648
+ <div class="title">
3649
+ ${this.modeView?.intro?.title ?? this.intro?.title ?? i18next.t('ai-assistant.text.empty-title')}
3650
+ </div>
3651
+ <div class="subtitle">
3652
+ ${this.modeView?.intro?.subtitle ?? this.intro?.subtitle ?? i18next.t('ai-assistant.text.empty-subtitle')}
3653
+ </div>
3610
3654
  </div>
3611
3655
 
3612
3656
  ${this.triggerChips.length > 0
@@ -9,7 +9,7 @@
9
9
  * the slash list was a module global. A mode's wording now comes from the same declaration the
10
10
  * door reads, so the two cannot describe different conversations.
11
11
  */
12
- import { isDifferentConversation, resolveConversation, type AssistantMode, pickInitialMode } from './assistant-mode'
12
+ import { isDifferentConversation, resolveConversation, type AssistantMode, pickInitialMode, followPickedConversation } from './assistant-mode'
13
13
 
14
14
  const MODE: AssistantMode = { kind: 'twin.space', target: 'S-12', hostContext: { spaceId: 'S-12' } }
15
15
 
@@ -126,3 +126,22 @@ describe('pickInitialMode', () => {
126
126
  expect(pickInitialMode('twin.board', choices, undefined)).toBe('twin.board')
127
127
  })
128
128
  })
129
+
130
+ /*
131
+ * ADR-0072 결정 3 — 목록에서 고른 대화로 화면이 따라간다.
132
+ */
133
+ describe('followPickedConversation', () => {
134
+ it('★ 다른 대화의 세션을 고르면 그 대화로 간다 — 안 따라가면 문이 거절한다', () => {
135
+ expect(followPickedConversation('figure.catalog', 'figure.modeller')).toBe('figure.catalog')
136
+ })
137
+
138
+ it('같은 대화면 그대로 둔다', () => {
139
+ expect(followPickedConversation('figure.modeller', 'figure.modeller')).toBeUndefined()
140
+ })
141
+
142
+ it('★ 칸이 서기 전 세션은 지금 대화로 둔다 — 서버가 다음 턴에 찍는다', () => {
143
+ expect(followPickedConversation(undefined, 'figure.modeller')).toBeUndefined()
144
+ expect(followPickedConversation(null, 'figure.modeller')).toBeUndefined()
145
+ expect(followPickedConversation('', 'figure.modeller')).toBeUndefined()
146
+ })
147
+ })
@@ -47,6 +47,10 @@ export interface AssistantModeView {
47
47
  catalogEntries: any[]
48
48
  proposalChoices: any[]
49
49
  placeholder?: string
50
+ /** 빈 대화의 제목과 한 줄 — 이 대화가 무엇을 하는 자리인지. host 속성보다 앞선다. */
51
+ intro?: { title: string; subtitle: string }
52
+ /** 빈 대화의 예시 — 이 대화에서 실제로 할 수 있는 말. host 속성보다 앞선다. */
53
+ examples?: Array<{ label: string; items: string[] }>
50
54
  footerNotice?: string
51
55
  stagedNotice?: string
52
56
  }
@@ -170,3 +174,19 @@ export function pickInitialMode(
170
174
  if (!remembered || remembered === current) return current
171
175
  return choices.some(choice => choice.kind === remembered) ? remembered : current
172
176
  }
177
+
178
+ /**
179
+ * 목록에서 고른 대화로 따라갈 것인가 — ADR-0072 결정 3.
180
+ *
181
+ * 목록은 한 대상의 대화를 **모두** 보여 주고, 그 대상 위에는 대화가 둘 설 수 있다(A17). 고른 것이 지금
182
+ * 열린 대화와 다른데 화면이 그대로 있으면 문이 거절하고(`session-kind-mismatch`), 목록은 눌리는데 안
183
+ * 열리는 목록이 된다 — 선언은 있고 길이 없는 그 모양이다.
184
+ *
185
+ * 칸이 서기 전에 난 세션에는 값이 없다. 그때는 지금 대화로 둔다 — 서버가 다음 턴에 지금 대화로 찍는다.
186
+ *
187
+ * `undefined` 는 「그대로 둔다」는 뜻이다.
188
+ */
189
+ export function followPickedConversation(pickedKind: string | undefined | null, currentKind: string | undefined): string | undefined {
190
+ if (!pickedKind || pickedKind === currentKind) return undefined
191
+ return pickedKind
192
+ }
@@ -32,3 +32,23 @@ describe('the panel does not raise a second tab axis', () => {
32
32
  expect(bare('assistant-session-toolbar.ts')).toContain('role="tablist"')
33
33
  })
34
34
  })
35
+
36
+ /*
37
+ * 첨부는 **되는 것만** 권한다 — ADR-0071 보탬(2026-09-18).
38
+ *
39
+ * `.json` · `.txt` · `.csv` 도 받고 있었는데, 그 파일들은 실려만 가고 모델에 붙는 코드가 없었다. 사람이
40
+ * CSV 를 붙이고 물으면 파일 칩이 뜨고 답도 왔다 — 그 답은 파일을 본 적이 없고 오류도 안 났다.
41
+ * 이미지는 `hostContext.image` 로 사람 턴에 붙는 길이 있다.
42
+ */
43
+ describe('the attach control offers only what has a path', () => {
44
+ it('★ 이미지만 받는다 — 읽히지 않는 것을 화면이 권하지 않는다', () => {
45
+ /*
46
+ * 여기서는 주석을 안 벗긴다. `image/*` 자체가 `/*` 를 품고 있어서, 주석 벗기개가 그 자리를 주석의
47
+ * 시작으로 읽고 다음 `*` + `/` 까지 지워 버린다 — 값이 사라지고 시험은 「없다」고 답한다.
48
+ * 이 값은 주석에 적힐 일이 없으므로 원문에서 그대로 읽는다.
49
+ */
50
+ const accept = readFileSync(join(__dirname, 'assistant-chat.ts'), 'utf-8').match(/accept="([^"]*)"/)
51
+
52
+ expect(accept?.[1]).toBe('image/*')
53
+ })
54
+ })
@@ -1,4 +1,17 @@
1
- export interface AssistantSession { id: string; name?: string }
1
+ export interface AssistantSession {
2
+ id: string
3
+ name?: string
4
+ /**
5
+ * 어느 대화인가 — 등록된 모드의 kind (ADR-0072 결정 3).
6
+ *
7
+ * 목록은 한 대상의 대화를 **모두** 보여 준다. 그 대상 위에 대화가 둘 설 수 있으므로(A17), 고른 것이
8
+ * 지금 열려 있는 대화와 다를 수 있다. 그때 화면은 그 대화로 따라가야 한다 — 안 그러면 문이 거절하고
9
+ * (`session-kind-mismatch`), 목록은 눌리는데 안 열리는 목록이 된다.
10
+ *
11
+ * 칸이 서기 전에 난 세션에는 값이 없다. 그때는 지금 대화로 두고, 서버가 다음 턴에 찍는다.
12
+ */
13
+ kind?: string
14
+ }
2
15
  export interface AssistantSessionTarget {
3
16
  /** Account/domain boundary supplied by the authenticated host. */
4
17
  scope: string
@@ -16,7 +16,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
16
16
  },
17
17
  async list(target) {
18
18
  const result = await client.query({
19
- query: gql`query($type:String!,$id:String!){chatSessionInbox(anchorType:$type,anchorId:$id,mineOnly:true,limit:100){id name}}`,
19
+ query: gql`query($type:String!,$id:String!){chatSessionInbox(anchorType:$type,anchorId:$id,mineOnly:true,limit:100){id name kind}}`,
20
20
  variables: { type: target.anchorType, id: target.anchorId }, fetchPolicy: 'no-cache'
21
21
  })
22
22
  if (result.errors?.length) throw new Error(result.errors[0].message)
@@ -32,7 +32,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
32
32
  },
33
33
  async create(target) {
34
34
  const result = await client.mutate({
35
- mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name}}`,
35
+ mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name kind}}`,
36
36
  variables: { type: target.anchorType, id: target.anchorId }
37
37
  })
38
38
  if (result.errors?.length) throw new Error(result.errors[0].message)
@@ -326,6 +326,18 @@ export declare class OxAssistantChat extends LitElement {
326
326
  * (서버에 칸을 만들지 않는다 — A17 ①). 세션 자체는 자기가 어느 대화였는지 이미 안다.
327
327
  */
328
328
  private onModePick;
329
+ /**
330
+ * **고르는 것은 청하는 일이다** — `mode` 를 여기서 바꾸지 않는다.
331
+ *
332
+ * host 는 렌더마다 `mode` 를 다시 내려 준다. 패널이 자기 손으로 고쳐 두면 다음 렌더가 그것을 덮고,
333
+ * 더 나쁜 것은 host 가 **자기가 무엇을 열었는지 모르는 채로 남는다**는 것이다. host 는 대화의 세션을
334
+ * 대상별로 들고 있으므로, 대화가 바뀐 것을 모르면 앞 대화의 세션을 그대로 넘긴다 — 그러면 새 말이 앞
335
+ * 대화에 붙는다(2026-09-18 Figure 레인이 :3300 에서 본 것이 이것이다).
336
+ *
337
+ * 그래서 주인은 하나다: host 가 `mode` 를 갖고, 패널은 「이것으로 바꿔 달라」고 말한다. 기억은 패널이
338
+ * 진다(브라우저마다 다른 값이라 host 가 질 것이 아니다) — 기억한 것을 되살릴 때도 같은 청으로 나간다.
339
+ */
340
+ private askHostForMode;
329
341
  /** 이 subject 에서 지난번에 고른 대화 — 없으면 undefined. */
330
342
  private rememberedMode;
331
343
  private loadModeView;
@@ -129,6 +129,8 @@ const ASSISTANT_MODE_QUERY = gql `
129
129
  catalogEntries
130
130
  proposalChoices
131
131
  placeholder
132
+ intro
133
+ examples
132
134
  footerNotice
133
135
  stagedNotice
134
136
  }
@@ -387,6 +389,22 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
387
389
  this.proposalSentAt = new Map();
388
390
  }
389
391
  static { OxAssistantChat_1 = this; }
392
+ /*
393
+ * ── 왜 이미지만 받나 ──────────────────────────────────────────────────────
394
+ * `.json` · `.txt` · `.csv` 도 받고 있었다. 그 파일들은 `hostContext.attachments` 로 실려 갔고
395
+ * **그것을 읽어 모델에 붙이는 코드가 없었다.** 사람이 CSV 를 붙이고 「이 표 좀 봐줘」라고 하면 화면에
396
+ * 파일 칩이 뜨고 답도 왔다. 그 답은 파일을 본 적이 없고, 오류도 안 났다(2026-09-18 표면 조사).
397
+ *
398
+ * 이미지는 길이 있다 — `assistant-chat-resolver.ts` 가 `hostContext.image` 를 마지막 사람 턴에
399
+ * 붙인다. 그래서 되는 것만 남기고 좁혔다. 못 하는 일을 화면이 권하지 않는다.
400
+ *
401
+ * ── 글 파일을 청하는 제품이 설 때의 모양 (수석 아키텍트, ADR-0071 보탬) ───
402
+ * 이미지와 **같은 자리**(사람 턴)에 붙인다. 크기 상한은 모드가 선언하고, 넘으면 보내기 전에 사실 한
403
+ * 줄로 거절한다 — 조용히 자르지 않는다. 비용은 사용량의 축이다(ADR-0061 결정 6).
404
+ *
405
+ * `attachments` 짐 자체는 그대로 둔다: figure 의 도구가 거기서 이미지를 찾는다
406
+ * (`figure-tools.ts` — `ctx.host.image` 가 없을 때의 갈래).
407
+ */
390
408
  async onAttachFiles(files) {
391
409
  if (!files || files.length === 0)
392
410
  return;
@@ -567,7 +585,6 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
567
585
  onModePick(kind) {
568
586
  if (!this.mode || kind === this.mode.kind)
569
587
  return;
570
- this.mode = { ...this.mode, kind };
571
588
  try {
572
589
  if (this.subject)
573
590
  localStorage.setItem(`assistant-mode:${this.subject}`, kind);
@@ -575,6 +592,20 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
575
592
  catch {
576
593
  /* 저장이 막힌 브라우저에서도 고르기 자체는 된다 — 다음에 다시 고르면 될 뿐이다. */
577
594
  }
595
+ this.askHostForMode(kind);
596
+ }
597
+ /**
598
+ * **고르는 것은 청하는 일이다** — `mode` 를 여기서 바꾸지 않는다.
599
+ *
600
+ * host 는 렌더마다 `mode` 를 다시 내려 준다. 패널이 자기 손으로 고쳐 두면 다음 렌더가 그것을 덮고,
601
+ * 더 나쁜 것은 host 가 **자기가 무엇을 열었는지 모르는 채로 남는다**는 것이다. host 는 대화의 세션을
602
+ * 대상별로 들고 있으므로, 대화가 바뀐 것을 모르면 앞 대화의 세션을 그대로 넘긴다 — 그러면 새 말이 앞
603
+ * 대화에 붙는다(2026-09-18 Figure 레인이 :3300 에서 본 것이 이것이다).
604
+ *
605
+ * 그래서 주인은 하나다: host 가 `mode` 를 갖고, 패널은 「이것으로 바꿔 달라」고 말한다. 기억은 패널이
606
+ * 진다(브라우저마다 다른 값이라 host 가 질 것이 아니다) — 기억한 것을 되살릴 때도 같은 청으로 나간다.
607
+ */
608
+ askHostForMode(kind) {
578
609
  this.dispatchEvent(new CustomEvent('assistant-mode-change', { detail: { kind, subject: this.subject }, bubbles: true, composed: true }));
579
610
  }
580
611
  /** 이 subject 에서 지난번에 고른 대화 — 없으면 undefined. */
@@ -2219,7 +2250,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2219
2250
  return;
2220
2251
  const kind = pickInitialMode(this.mode.kind, this.modeChoices, this.rememberedMode(this.subject));
2221
2252
  if (kind && kind !== this.mode.kind)
2222
- this.mode = { ...this.mode, kind };
2253
+ this.askHostForMode(kind);
2223
2254
  }
2224
2255
  updated(changed) {
2225
2256
  /*
@@ -2578,7 +2609,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2578
2609
  id="chat-file-input"
2579
2610
  type="file"
2580
2611
  multiple
2581
- accept="image/*,.json,.txt,.csv"
2612
+ accept="image/*"
2582
2613
  style="display: none"
2583
2614
  @change=${(e) => {
2584
2615
  const input = e.target;
@@ -2592,7 +2623,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2592
2623
  type="button"
2593
2624
  class="action"
2594
2625
  @click=${() => this.renderRoot.querySelector('#chat-file-input')?.click()}
2595
- title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'Attach images or files (drag & drop or paste supported)' })}>
2626
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
2596
2627
  <md-icon>attach_file</md-icon>
2597
2628
  ${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
2598
2629
  </button>
@@ -3061,6 +3092,12 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3061
3092
  * 읽었고, 그러면 호스트 값을 한쪽만 반영하는 상태가 만들어질 수 있었다.
3062
3093
  */
3063
3094
  get exampleGroups() {
3095
+ /*
3096
+ * 대화가 자기 예시를 가지면 그것이 먼저다 — 자리표·`/` 와 같은 차례(ADR-0061 결정 7). host 속성은
3097
+ * 대화를 모르므로, 한 화면에 대화가 둘 서면 한쪽에 맞는 글이 다른 쪽에도 뜬다.
3098
+ */
3099
+ if (this.modeView?.examples?.length)
3100
+ return this.modeView.examples;
3064
3101
  if (this.examples?.length)
3065
3102
  return this.examples;
3066
3103
  return OxAssistantChat_1.EXAMPLE_GROUPS.map(group => ({
@@ -3201,8 +3238,13 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3201
3238
  <div class="empty">
3202
3239
  <div class="header">
3203
3240
  <md-icon class="icon">auto_awesome</md-icon>
3204
- <div class="title">${this.intro?.title ?? i18next.t('ai-assistant.text.empty-title')}</div>
3205
- <div class="subtitle">${this.intro?.subtitle ?? i18next.t('ai-assistant.text.empty-subtitle')}</div>
3241
+ <!-- 대화가 자기 글을 가지면 그것이 먼저다 — host 속성은 대화를 모른다. -->
3242
+ <div class="title">
3243
+ ${this.modeView?.intro?.title ?? this.intro?.title ?? i18next.t('ai-assistant.text.empty-title')}
3244
+ </div>
3245
+ <div class="subtitle">
3246
+ ${this.modeView?.intro?.subtitle ?? this.intro?.subtitle ?? i18next.t('ai-assistant.text.empty-subtitle')}
3247
+ </div>
3206
3248
  </div>
3207
3249
 
3208
3250
  ${this.triggerChips.length > 0