@things-factory/ai-assistant 10.1.6 → 10.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/client/components/board-ai-chat.ts +165 -100
  2. package/client/components/chat-defaults.ts +13 -0
  3. package/client/components/chat-input-builder.test.ts +81 -0
  4. package/client/components/chat-input-builder.ts +43 -0
  5. package/client/components/chat-triggers.test.ts +72 -0
  6. package/client/components/chat-triggers.ts +62 -0
  7. package/dist-client/components/board-ai-chat.d.ts +8 -0
  8. package/dist-client/components/board-ai-chat.js +160 -100
  9. package/dist-client/components/board-ai-chat.js.map +1 -1
  10. package/dist-client/components/chat-defaults.d.ts +12 -0
  11. package/dist-client/components/chat-defaults.js +2 -0
  12. package/dist-client/components/chat-defaults.js.map +1 -1
  13. package/dist-client/components/chat-input-builder.d.ts +29 -0
  14. package/dist-client/components/chat-input-builder.js +22 -0
  15. package/dist-client/components/chat-input-builder.js.map +1 -1
  16. package/dist-client/components/chat-input-builder.test.d.ts +1 -0
  17. package/dist-client/components/chat-input-builder.test.js +67 -0
  18. package/dist-client/components/chat-input-builder.test.js.map +1 -0
  19. package/dist-client/components/chat-triggers.d.ts +16 -0
  20. package/dist-client/components/chat-triggers.js +42 -0
  21. package/dist-client/components/chat-triggers.js.map +1 -0
  22. package/dist-client/components/chat-triggers.test.d.ts +1 -0
  23. package/dist-client/components/chat-triggers.test.js +61 -0
  24. package/dist-client/components/chat-triggers.test.js.map +1 -0
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/service/assistant-chat-resolver.d.ts +32 -0
  27. package/dist-server/service/assistant-chat-resolver.js +342 -0
  28. package/dist-server/service/assistant-chat-resolver.js.map +1 -0
  29. package/dist-server/service/chat-session-resolver.js +18 -18
  30. package/dist-server/service/chat-session-resolver.js.map +1 -1
  31. package/dist-server/service/index.d.ts +3 -1
  32. package/dist-server/service/index.js +4 -0
  33. package/dist-server/service/index.js.map +1 -1
  34. package/dist-server/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +5 -5
  36. package/server/service/assistant-chat-resolver.ts +326 -0
  37. package/server/service/chat-session-resolver.ts +18 -18
  38. package/server/service/index.ts +4 -0
  39. package/test/translations.test.ts +79 -0
  40. package/translations/en.json +59 -0
  41. package/translations/ja.json +58 -0
  42. package/translations/ko.json +59 -0
  43. package/translations/ms.json +58 -0
  44. package/translations/zh.json +58 -0
@@ -30,7 +30,7 @@ import {
30
30
  injectMentionUserIds,
31
31
  stripMentionRefids
32
32
  } from './markdown.js'
33
- import { buildChatMutationInput } from './chat-input-builder.js'
33
+ import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
34
34
  import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
35
35
  import './mention-popup.js'
36
36
  import {
@@ -57,6 +57,7 @@ import {
57
57
  * 뜨지 않는 것이 맞다.
58
58
  */
59
59
  import { chatDefaults } from './chat-defaults.js'
60
+ import { availableTriggers, type TriggerChip } from './chat-triggers.js'
60
61
 
61
62
  interface ChatLine {
62
63
  role: 'user' | 'assistant' | 'system'
@@ -125,6 +126,30 @@ const BOARD_AI_CHAT_MUTATION = gql`
125
126
  }
126
127
  `
127
128
 
129
+ /*
130
+ * The neutral door. Selected by `chatEndpoint`, and it asks for fewer fields because there is
131
+ * less to ask for: no patch, no scene actions, no patch id — this surface has no document.
132
+ *
133
+ * `offeredTools` has no counterpart on the board side. It is what the server actually put in
134
+ * front of the model this turn, so a surface that granted a category and sees it missing knows
135
+ * the registration never happened, instead of reading a vague answer as a refusal.
136
+ */
137
+ const ASSISTANT_CHAT_MUTATION = gql`
138
+ mutation AssistantChat($input: AssistantChatInput!) {
139
+ assistantChat(input: $input) {
140
+ reply
141
+ clientId
142
+ sessionId
143
+ userMessageId
144
+ assistantMessageId
145
+ toolUsages
146
+ offeredTools
147
+ proposals
148
+ groundingWarnings
149
+ }
150
+ }
151
+ `
152
+
128
153
  const CHAT_MESSAGES_QUERY = gql`
129
154
  query ChatMessages($sessionId: String!, $limit: Int, $offset: Int) {
130
155
  chatMessages(sessionId: $sessionId, limit: $limit, offset: $offset) {
@@ -291,6 +316,31 @@ export class OxBoardAIChat extends LitElement {
291
316
  @property({ type: Boolean, attribute: 'board-tools' })
292
317
  boardTools = true
293
318
 
319
+ /*
320
+ * Which server operation this surface talks to. A closed set of two, named after the operations
321
+ * themselves so a reader can grep either way.
322
+ *
323
+ * boardAIChat board authoring — lives in @things-factory/board-ai, carries the board model,
324
+ * returns patches and scene actions
325
+ * assistantChat a surface with no document of its own — lives here, runs registered tools only
326
+ *
327
+ * Default stays `boardAIChat`: every existing consumer is a board surface, and a default that
328
+ * changed under them would move their conversation to a door with no board tools behind it.
329
+ *
330
+ * A host that has no board must pass `assistantChat` **and** `systemPrompt`. Until it does, the
331
+ * chat calls a mutation that lives in a package it may not even load — which is how
332
+ * operato-figure got `Cannot query field "boardAIChat" on type "Mutation"` (2026-09-11).
333
+ */
334
+ @property({ type: String, attribute: 'chat-endpoint' })
335
+ chatEndpoint: 'boardAIChat' | 'assistantChat' = 'boardAIChat'
336
+
337
+ /**
338
+ * Who the assistant is on this surface, in the host's words. Required by `assistantChat`; the
339
+ * board conversation carries its own persona on the server and ignores this.
340
+ */
341
+ @property({ type: String, attribute: 'system-prompt' })
342
+ systemPrompt?: string
343
+
294
344
  /**
295
345
  * 라이브 상태를 다루는 대화면인가 — true 면 **첫 턴에 도구 호출을 강제**한다.
296
346
  *
@@ -1953,7 +2003,7 @@ export class OxBoardAIChat extends LitElement {
1953
2003
  // 로드된 메시지 id 를 seen 에 등록 — 이후 방송 에코 dedup 기준.
1954
2004
  this._seenMessageIds = new Set<string>(msgs.map((m: any) => m.id).filter(Boolean))
1955
2005
  } catch (e: any) {
1956
- this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`
2006
+ this.errorMessage = `${i18next.t('ai-assistant.text.failed-to-load-history')}: ${e.message ?? e}`
1957
2007
  }
1958
2008
  }
1959
2009
 
@@ -1984,9 +2034,9 @@ export class OxBoardAIChat extends LitElement {
1984
2034
  const showActions = line.role !== 'system' && !line.pending && content.length > 0
1985
2035
  return html`
1986
2036
  ${showLabel
1987
- ? html`<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
2037
+ ? html`<div class="msg-label">${i18next.t('ai-assistant.label.ai')}</div>`
1988
2038
  : showSenderLabel
1989
- ? html`<div class="msg-label sender">${line.senderName || i18next.t('board-ai.label.participant')}</div>`
2039
+ ? html`<div class="msg-label sender">${line.senderName || i18next.t('ai-assistant.label.participant')}</div>`
1990
2040
  : nothing}
1991
2041
  <div class="msg-wrap ${line.role} ${isOther ? 'other' : ''}">
1992
2042
  <div
@@ -2000,7 +2050,7 @@ export class OxBoardAIChat extends LitElement {
2000
2050
  : line.role === 'system'
2001
2051
  ? html`<span
2002
2052
  class="sys-text"
2003
- title=${i18next.t('board-ai.text.toggle-system-note', { defaultValue: '눌러서 펼치기' })}
2053
+ title=${i18next.t('ai-assistant.text.toggle-system-note', { defaultValue: '눌러서 펼치기' })}
2004
2054
  @click=${() => this.toggleSystemNote(idx)}
2005
2055
  >${content}</span
2006
2056
  >`
@@ -2011,14 +2061,14 @@ export class OxBoardAIChat extends LitElement {
2011
2061
  <md-icon>check_circle</md-icon>
2012
2062
  <span class="summary-text">
2013
2063
  ${this.revertedPatchIds.has(line.patchId)
2014
- ? i18next.t('board-ai.text.reverted')
2015
- : i18next.t('board-ai.text.applied-changes')}
2064
+ ? i18next.t('ai-assistant.text.reverted')
2065
+ : i18next.t('ai-assistant.text.applied-changes')}
2016
2066
  </span>
2017
2067
  <button
2018
2068
  class="revert-btn"
2019
2069
  ?disabled=${this.revertedPatchIds.has(line.patchId)}
2020
2070
  @click=${() => line.patchId && this.onRevertClick(line.patchId)}>
2021
- ${i18next.t('board-ai.button.revert')}
2071
+ ${i18next.t('ai-assistant.button.revert')}
2022
2072
  </button>
2023
2073
  </div>
2024
2074
  `
@@ -2047,7 +2097,7 @@ export class OxBoardAIChat extends LitElement {
2047
2097
  ${this.lastFailedInput
2048
2098
  ? html`
2049
2099
  <button class="retry-btn" @click=${this.retryLastSend}>
2050
- ${i18next.t('board-ai.button.retry')}
2100
+ ${i18next.t('ai-assistant.button.retry')}
2051
2101
  </button>
2052
2102
  `
2053
2103
  : nothing}
@@ -2062,11 +2112,11 @@ export class OxBoardAIChat extends LitElement {
2062
2112
  <button
2063
2113
  class="action"
2064
2114
  @click=${() => (this.examplesOpen = !this.examplesOpen)}
2065
- title=${i18next.t('board-ai.text.show-examples-tooltip')}>
2115
+ title=${i18next.t('ai-assistant.text.show-examples-tooltip')}>
2066
2116
  <md-icon>${this.examplesOpen ? 'close' : 'lightbulb'}</md-icon>
2067
2117
  ${this.examplesOpen
2068
- ? i18next.t('board-ai.button.close')
2069
- : i18next.t('board-ai.button.examples')}
2118
+ ? i18next.t('ai-assistant.button.close')
2119
+ : i18next.t('ai-assistant.button.examples')}
2070
2120
  </button>
2071
2121
  </div>
2072
2122
  ${this.examplesOpen ? this.renderInlineExamples() : nothing}
@@ -2085,7 +2135,7 @@ export class OxBoardAIChat extends LitElement {
2085
2135
  }}
2086
2136
  ?disabled=${this.busy}
2087
2137
  rows="1"
2088
- placeholder=${this.placeholder ?? i18next.t('board-ai.text.input-placeholder')}></textarea>
2138
+ placeholder=${this.placeholder ?? i18next.t('ai-assistant.text.input-placeholder')}></textarea>
2089
2139
  ${this.mentionOpen
2090
2140
  ? html`
2091
2141
  <ox-mention-popup
@@ -2109,7 +2159,7 @@ export class OxBoardAIChat extends LitElement {
2109
2159
  <button
2110
2160
  ?disabled=${this.busy || !this.input.trim()}
2111
2161
  @click=${this.send}
2112
- title=${i18next.t('board-ai.text.send-tooltip')}>
2162
+ title=${i18next.t('ai-assistant.text.send-tooltip')}>
2113
2163
  ${this.busy
2114
2164
  ? html`<span class="spinner"></span>`
2115
2165
  : html`<md-icon>arrow_upward</md-icon>`}
@@ -2343,7 +2393,7 @@ export class OxBoardAIChat extends LitElement {
2343
2393
  ])
2344
2394
  } else {
2345
2395
  this.showToast(
2346
- i18next.t('board-ai.text.mention-not-found', {
2396
+ i18next.t('ai-assistant.text.mention-not-found', {
2347
2397
  token,
2348
2398
  defaultValue: `#${token} 을(를) 찾을 수 없습니다`
2349
2399
  })
@@ -2438,29 +2488,38 @@ export class OxBoardAIChat extends LitElement {
2438
2488
  }))
2439
2489
  }
2440
2490
 
2491
+ /** 이 표면이 실제로 쓸 수 있는 트리거 — 판정은 `chat-triggers.ts` 가 하고 여기서는 사실만 읽는다. */
2492
+ private get triggerChips(): TriggerChip[] {
2493
+ return availableTriggers({
2494
+ hasMentionableThings: !!(this.boardProvider || this.currentBoard || this.knownTypes?.length),
2495
+ hasSession: !!this.sessionId,
2496
+ slashTemplateCount: chatDefaults.slashTemplates.length
2497
+ })
2498
+ }
2499
+
2441
2500
  private static readonly EXAMPLE_GROUPS: Array<{
2442
2501
  labelKey: string
2443
2502
  itemKeys: string[]
2444
2503
  }> = [
2445
2504
  {
2446
- labelKey: 'board-ai.label.create',
2505
+ labelKey: 'ai-assistant.label.create',
2447
2506
  itemKeys: [
2448
- 'board-ai.example.create-monitoring-dashboard',
2449
- 'board-ai.example.create-welcome-screen'
2507
+ 'ai-assistant.example.create-monitoring-dashboard',
2508
+ 'ai-assistant.example.create-welcome-screen'
2450
2509
  ]
2451
2510
  },
2452
2511
  {
2453
- labelKey: 'board-ai.label.edit',
2512
+ labelKey: 'ai-assistant.label.edit',
2454
2513
  itemKeys: [
2455
- 'board-ai.example.edit-align-distribute',
2456
- 'board-ai.example.edit-resize-board'
2514
+ 'ai-assistant.example.edit-align-distribute',
2515
+ 'ai-assistant.example.edit-resize-board'
2457
2516
  ]
2458
2517
  },
2459
2518
  {
2460
- labelKey: 'board-ai.label.style',
2519
+ labelKey: 'ai-assistant.label.style',
2461
2520
  itemKeys: [
2462
- 'board-ai.example.style-dark-mode',
2463
- 'board-ai.example.style-rounded-shadow'
2521
+ 'ai-assistant.example.style-dark-mode',
2522
+ 'ai-assistant.example.style-rounded-shadow'
2464
2523
  ]
2465
2524
  }
2466
2525
  ]
@@ -2473,7 +2532,7 @@ export class OxBoardAIChat extends LitElement {
2473
2532
  <div class="session-tabs" role="tablist">
2474
2533
  ${this.sessions.map((s, i) => {
2475
2534
  const isActive = s.id === this.sessionId
2476
- const fallback = i18next.t('board-ai.text.session-tab-label', {
2535
+ const fallback = i18next.t('ai-assistant.text.session-tab-label', {
2477
2536
  n: i + 1,
2478
2537
  defaultValue: `세션 ${i + 1}`
2479
2538
  })
@@ -2494,12 +2553,12 @@ export class OxBoardAIChat extends LitElement {
2494
2553
  })}
2495
2554
  <button
2496
2555
  class="session-tab-new"
2497
- title=${i18next.t('board-ai.button.new-chat', {
2556
+ title=${i18next.t('ai-assistant.button.new-chat', {
2498
2557
  defaultValue: '새 대화'
2499
2558
  })}
2500
2559
  @click=${this._onSessionCreateClick}>
2501
2560
  <md-icon>add</md-icon>
2502
- <span>${i18next.t('board-ai.button.new-chat', { defaultValue: '새 대화' })}</span>
2561
+ <span>${i18next.t('ai-assistant.button.new-chat', { defaultValue: '새 대화' })}</span>
2503
2562
  </button>
2504
2563
  </div>
2505
2564
  `
@@ -2552,36 +2611,30 @@ export class OxBoardAIChat extends LitElement {
2552
2611
  <div class="empty">
2553
2612
  <div class="header">
2554
2613
  <md-icon class="icon">auto_awesome</md-icon>
2555
- <div class="title">${this.intro?.title ?? i18next.t('board-ai.text.empty-title')}</div>
2556
- <div class="subtitle">${this.intro?.subtitle ?? i18next.t('board-ai.text.empty-subtitle')}</div>
2614
+ <div class="title">${this.intro?.title ?? i18next.t('ai-assistant.text.empty-title')}</div>
2615
+ <div class="subtitle">${this.intro?.subtitle ?? i18next.t('ai-assistant.text.empty-subtitle')}</div>
2557
2616
  </div>
2558
2617
 
2559
- <div class="trigger-row">
2560
- <div class="trigger-row-label">${i18next.t('board-ai.text.triggers-heading')}</div>
2561
- <div class="trigger-chips">
2562
- <button
2563
- class="trigger-chip"
2564
- title=${i18next.t('board-ai.text.trigger-component-tooltip')}
2565
- @click=${() => this.insertTrigger('#')}>
2566
- <span class="trigger-char">#</span>
2567
- <span class="trigger-desc">${i18next.t('board-ai.label.trigger-component')}</span>
2568
- </button>
2569
- <button
2570
- class="trigger-chip"
2571
- title=${i18next.t('board-ai.text.trigger-user-tooltip')}
2572
- @click=${() => this.insertTrigger('@')}>
2573
- <span class="trigger-char">@</span>
2574
- <span class="trigger-desc">${i18next.t('board-ai.label.trigger-user')}</span>
2575
- </button>
2576
- <button
2577
- class="trigger-chip"
2578
- title=${i18next.t('board-ai.text.trigger-insert-tooltip')}
2579
- @click=${() => this.insertTrigger('/')}>
2580
- <span class="trigger-char">/</span>
2581
- <span class="trigger-desc">${i18next.t('board-ai.label.trigger-insert')}</span>
2582
- </button>
2583
- </div>
2584
- </div>
2618
+ ${this.triggerChips.length > 0
2619
+ ? html`
2620
+ <div class="trigger-row">
2621
+ <div class="trigger-row-label">${i18next.t('ai-assistant.text.triggers-heading')}</div>
2622
+ <div class="trigger-chips">
2623
+ ${this.triggerChips.map(
2624
+ trigger => html`
2625
+ <button
2626
+ class="trigger-chip"
2627
+ title=${i18next.t(trigger.tooltipKey)}
2628
+ @click=${() => this.insertTrigger(trigger.char)}>
2629
+ <span class="trigger-char">${trigger.char}</span>
2630
+ <span class="trigger-desc">${i18next.t(trigger.labelKey)}</span>
2631
+ </button>
2632
+ `
2633
+ )}
2634
+ </div>
2635
+ </div>
2636
+ `
2637
+ : ''}
2585
2638
 
2586
2639
  ${this.exampleGroups.map(
2587
2640
  group => html`
@@ -2599,11 +2652,12 @@ export class OxBoardAIChat extends LitElement {
2599
2652
  )}
2600
2653
 
2601
2654
  <div class="footer">
2602
- <span class="badge">${i18next.t('board-ai.label.korean-supported')}</span>
2603
- <span class="badge">${i18next.t('board-ai.label.multi-command')}</span>
2604
- <span class="badge">${i18next.t('board-ai.label.review-able')}</span>
2605
- <br />
2606
- ${i18next.t('board-ai.text.empty-footer-notice')}
2655
+ <span class="badge">${i18next.t('ai-assistant.label.korean-supported')}</span>
2656
+ <span class="badge">${i18next.t('ai-assistant.label.multi-command')}</span>
2657
+ <span class="badge">${i18next.t('ai-assistant.label.review-able')}</span>
2658
+ ${chatDefaults.footerNoticeKey
2659
+ ? html`<br />${i18next.t(chatDefaults.footerNoticeKey)}`
2660
+ : ''}
2607
2661
  </div>
2608
2662
  </div>
2609
2663
  `
@@ -2680,15 +2734,15 @@ export class OxBoardAIChat extends LitElement {
2680
2734
  <button
2681
2735
  class="msg-action ${isCopied ? 'confirmed' : ''}"
2682
2736
  title=${isCopied
2683
- ? i18next.t('board-ai.text.copied')
2684
- : i18next.t('board-ai.button.copy')}
2737
+ ? i18next.t('ai-assistant.text.copied')
2738
+ : i18next.t('ai-assistant.button.copy')}
2685
2739
  @click=${() => this.copyMessage(idx, content)}>
2686
2740
  <md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
2687
2741
  </button>
2688
2742
  <button
2689
2743
  class="msg-action"
2690
2744
  ?disabled=${this.busy}
2691
- title=${i18next.t('board-ai.button.regenerate')}
2745
+ title=${i18next.t('ai-assistant.button.regenerate')}
2692
2746
  @click=${() => this.regenerateAssistant(idx)}>
2693
2747
  <md-icon>refresh</md-icon>
2694
2748
  </button>
@@ -2713,19 +2767,19 @@ export class OxBoardAIChat extends LitElement {
2713
2767
  <md-icon>unfold_less</md-icon>
2714
2768
  <span class="fn-text">
2715
2769
  ${folded.summarized
2716
- ? i18next.t('board-ai.text.history-folded-summarized', {
2770
+ ? i18next.t('ai-assistant.text.history-folded-summarized', {
2717
2771
  count: folded.omitted,
2718
2772
  defaultValue: '대화가 길어져 앞부분 {count}개가 요약으로 접혔습니다. 다른 주제라면 새 대화가 낫습니다.'
2719
2773
  })
2720
- : i18next.t('board-ai.text.history-folded', {
2774
+ : i18next.t('ai-assistant.text.history-folded', {
2721
2775
  count: folded.omitted,
2722
2776
  defaultValue: '대화가 길어져 앞부분 {count}개가 생략되었습니다. 다른 주제라면 새 대화가 낫습니다.'
2723
2777
  })}
2724
2778
  </span>
2725
2779
  <button class="fn-new" @click=${this.startNewFromNotice}>
2726
- ${i18next.t('board-ai.button.new-chat')}
2780
+ ${i18next.t('ai-assistant.button.new-chat')}
2727
2781
  </button>
2728
- <button class="fn-close" title=${i18next.t('board-ai.button.close')} @click=${() => (this.foldNoticeDismissed = true)}>
2782
+ <button class="fn-close" title=${i18next.t('ai-assistant.button.close')} @click=${() => (this.foldNoticeDismissed = true)}>
2729
2783
  <md-icon>close</md-icon>
2730
2784
  </button>
2731
2785
  </div>
@@ -2767,8 +2821,8 @@ export class OxBoardAIChat extends LitElement {
2767
2821
  class="run"
2768
2822
  ?disabled=${sent}
2769
2823
  @click=${() => this.executeProposal(p, key)}>
2770
- ${sent ? i18next.t('board-ai.text.proposal-sent', { defaultValue: '실행 요청됨' })
2771
- : i18next.t('board-ai.button.run-proposal', { defaultValue: '실행' })}
2824
+ ${sent ? i18next.t('ai-assistant.text.proposal-sent', { defaultValue: '실행 요청됨' })
2825
+ : i18next.t('ai-assistant.button.run-proposal', { defaultValue: '실행' })}
2772
2826
  </button>
2773
2827
  </div>
2774
2828
  `
@@ -2829,7 +2883,7 @@ export class OxBoardAIChat extends LitElement {
2829
2883
  <div class="grounding-warning" role="note">
2830
2884
  <md-icon>report</md-icon>
2831
2885
  <span>
2832
- ${i18next.t('board-ai.text.ungrounded-mention', {
2886
+ ${i18next.t('ai-assistant.text.ungrounded-mention', {
2833
2887
  defaultValue: '확인되지 않은 대상을 언급했습니다 — 실제 데이터에 없습니다:'
2834
2888
  })}
2835
2889
  <span class="ids">${ids.join(', ')}</span>
@@ -2851,7 +2905,7 @@ export class OxBoardAIChat extends LitElement {
2851
2905
  const open = !!line.toolUsagesOpen
2852
2906
  const readCount = usages.filter(u => u.kind === 'read').length
2853
2907
  const writeCount = usages.filter(u => u.kind === 'write').length
2854
- const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' })
2908
+ const toolsLabel = i18next.t('ai-assistant.text.tools-used', { defaultValue: '도구 사용' })
2855
2909
  /* 눈에 걸려야 하는 것을 머리줄에 올린다 — 거절·접힘이 있으면 펼치기 전에 보인다.
2856
2910
  * (이번 사고들이 전부 그것이었다: 인자 빠진 호출이 거절됐고, 중복 제안이 접혔다.) */
2857
2911
  const problems = usages.filter(u => u.outcome === 'rejected' || u.outcome === 'error').length
@@ -2867,12 +2921,12 @@ export class OxBoardAIChat extends LitElement {
2867
2921
  <strong>${usages.length}</strong> ${toolsLabel}
2868
2922
  <span class="tool-usages-counts">
2869
2923
  · read ${readCount} · write ${writeCount}${problems
2870
- ? html` · <span class="tu-flag bad">${i18next.t('board-ai.text.tool-rejected-count', {
2924
+ ? html` · <span class="tu-flag bad">${i18next.t('ai-assistant.text.tool-rejected-count', {
2871
2925
  count: problems,
2872
2926
  defaultValue: '거절 {count}'
2873
2927
  })}</span>`
2874
2928
  : nothing}${folded
2875
- ? html` · <span class="tu-flag">${i18next.t('board-ai.text.tool-folded-count', {
2929
+ ? html` · <span class="tu-flag">${i18next.t('ai-assistant.text.tool-folded-count', {
2876
2930
  count: folded,
2877
2931
  defaultValue: '접힘 {count}'
2878
2932
  })}</span>`
@@ -2916,7 +2970,7 @@ export class OxBoardAIChat extends LitElement {
2916
2970
  return html`
2917
2971
  ${newTurn
2918
2972
  ? html`<li class="tu-turn">
2919
- ${i18next.t('board-ai.text.tool-turn', { n: (u.iter ?? 0) + 1, defaultValue: '{n}번째 판단' })}
2973
+ ${i18next.t('ai-assistant.text.tool-turn', { n: (u.iter ?? 0) + 1, defaultValue: '{n}번째 판단' })}
2920
2974
  </li>`
2921
2975
  : nothing}
2922
2976
  <li class="tool-usage-item kind-${u.kind} outcome-${outcome}">
@@ -2942,12 +2996,12 @@ export class OxBoardAIChat extends LitElement {
2942
2996
  /** 배지 문구 — 코드가 아니라 사람 말로. */
2943
2997
  private outcomeLabel(outcome: string): string {
2944
2998
  const map: Record<string, [string, string]> = {
2945
- ok: ['board-ai.text.outcome-ok', '조회'],
2946
- rejected: ['board-ai.text.outcome-rejected', '거절'],
2947
- queued: ['board-ai.text.outcome-queued', '적용 예정'],
2948
- proposed: ['board-ai.text.outcome-proposed', '제안'],
2949
- folded: ['board-ai.text.outcome-folded', '중복 접힘'],
2950
- error: ['board-ai.text.outcome-error', '오류']
2999
+ ok: ['ai-assistant.text.outcome-ok', '조회'],
3000
+ rejected: ['ai-assistant.text.outcome-rejected', '거절'],
3001
+ queued: ['ai-assistant.text.outcome-queued', '적용 예정'],
3002
+ proposed: ['ai-assistant.text.outcome-proposed', '제안'],
3003
+ folded: ['ai-assistant.text.outcome-folded', '중복 접힘'],
3004
+ error: ['ai-assistant.text.outcome-error', '오류']
2951
3005
  }
2952
3006
  const [key, fallback] = map[outcome] ?? map.ok
2953
3007
  return i18next.t(key, { defaultValue: fallback })
@@ -3008,7 +3062,7 @@ export class OxBoardAIChat extends LitElement {
3008
3062
  // 클립보드에는 marker 제거된 깨끗한 텍스트
3009
3063
  await navigator.clipboard.writeText(stripMentionRefids(content))
3010
3064
  this.copiedIdx = idx
3011
- this.showToast(i18next.t('board-ai.text.copied'))
3065
+ this.showToast(i18next.t('ai-assistant.text.copied'))
3012
3066
  setTimeout(() => {
3013
3067
  if (this.copiedIdx === idx) this.copiedIdx = undefined
3014
3068
  }, 1500)
@@ -3128,30 +3182,41 @@ export class OxBoardAIChat extends LitElement {
3128
3182
  const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
3129
3183
 
3130
3184
  // mentions 는 위에서 이미 계산해 user line 에 thread — 그대로 mutation 입력으로 재사용.
3185
+ const neutral = this.chatEndpoint === 'assistantChat'
3131
3186
  const result = await client.mutate({
3132
- mutation: BOARD_AI_CHAT_MUTATION,
3187
+ mutation: neutral ? ASSISTANT_CHAT_MUTATION : BOARD_AI_CHAT_MUTATION,
3133
3188
  variables: {
3134
- input: buildChatMutationInput({
3135
- sessionId: this.sessionId,
3136
- history,
3137
- liveBoard,
3138
- scopes: this.scopes,
3139
- truncateAfterMessageId: this._truncateAfterId,
3140
- hostContext: this.hostContext,
3141
- toolCategories: this.toolCategories,
3142
- boardTools: this.boardTools,
3143
- requireGroundingTools: this.requireGroundingTools,
3144
- knownTypes: this.knownTypes,
3145
- categories: this.categories,
3146
- componentSchemas: this.componentSchemas,
3147
- selectedRefids: this.selectedRefids ?? [],
3148
- mentions
3149
- })
3189
+ input: neutral
3190
+ ? buildAssistantChatInput({
3191
+ sessionId: this.sessionId,
3192
+ history,
3193
+ systemPrompt: this.systemPrompt!,
3194
+ truncateAfterMessageId: this._truncateAfterId,
3195
+ hostContext: this.hostContext,
3196
+ toolCategories: this.toolCategories,
3197
+ requireGroundingTools: this.requireGroundingTools
3198
+ })
3199
+ : buildChatMutationInput({
3200
+ sessionId: this.sessionId,
3201
+ history,
3202
+ liveBoard,
3203
+ scopes: this.scopes,
3204
+ truncateAfterMessageId: this._truncateAfterId,
3205
+ hostContext: this.hostContext,
3206
+ toolCategories: this.toolCategories,
3207
+ boardTools: this.boardTools,
3208
+ requireGroundingTools: this.requireGroundingTools,
3209
+ knownTypes: this.knownTypes,
3210
+ categories: this.categories,
3211
+ componentSchemas: this.componentSchemas,
3212
+ selectedRefids: this.selectedRefids ?? [],
3213
+ mentions
3214
+ })
3150
3215
  }
3151
3216
  })
3152
3217
 
3153
- const out = result.data?.boardAIChat
3154
- if (!out) throw new Error(i18next.t('board-ai.text.empty-response'))
3218
+ const out = neutral ? result.data?.assistantChat : result.data?.boardAIChat
3219
+ if (!out) throw new Error(i18next.t('ai-assistant.text.empty-response'))
3155
3220
 
3156
3221
  /* 대화가 길어졌다는 서버 보고 — 닫지 않았다면 안내를 띄운다. */
3157
3222
  this.historyFolded = out.historyFolded ?? undefined
@@ -16,6 +16,18 @@
16
16
  export interface ChatDefaults {
17
17
  catalogEntries: any[]
18
18
  slashTemplates: any[]
19
+ /**
20
+ * 빈 화면 맨 아래 한 줄의 **i18n 키**. 없으면 그 줄을 안 그린다.
21
+ *
22
+ * 문장이 아니라 키를 받는 이유: 호스트의 client barrel 은 모듈 로드 시점에 돌고, 그때 i18next 가
23
+ * 아직 언어를 안 잡았을 수 있다. 키로 받아 **그릴 때** 번역한다.
24
+ *
25
+ * 왜 호스트 것인가: 여기 있던 기본 문장이 「AI 응답은 항상 **보드에** 적용 전 미리보기로 확인
26
+ * 가능합니다」였다. 보드가 없는 제품에서는 거짓이고(figure 에서 실제로 그렇게 떴다), 그 약속은
27
+ * 보드 저작기가 patch 를 미리보기로 거는 **기계가 있어서** 할 수 있는 말이다. 기계를 가진 쪽이
28
+ * 그 말을 한다.
29
+ */
30
+ footerNoticeKey?: string
19
31
  }
20
32
 
21
33
  export const chatDefaults: ChatDefaults = { catalogEntries: [], slashTemplates: [] }
@@ -24,4 +36,5 @@ export const chatDefaults: ChatDefaults = { catalogEntries: [], slashTemplates:
24
36
  export function registerChatDefaults(defaults: Partial<ChatDefaults>): void {
25
37
  if (defaults.catalogEntries) chatDefaults.catalogEntries = defaults.catalogEntries
26
38
  if (defaults.slashTemplates) chatDefaults.slashTemplates = defaults.slashTemplates
39
+ if (defaults.footerNoticeKey) chatDefaults.footerNoticeKey = defaults.footerNoticeKey
27
40
  }
@@ -0,0 +1,81 @@
1
+ /*
2
+ * What this guards: **the two doors take different inputs, and neither one leaks into the other.**
3
+ *
4
+ * The chat component talks to one of two server operations. `boardAIChat` is board authoring and
5
+ * carries the board model; `assistantChat` is for a surface with no document of its own. Sending
6
+ * a board field to the neutral door, or dropping a required one, produces a GraphQL error the
7
+ * user sees as "the assistant is broken".
8
+ *
9
+ * The required system prompt is the one that would be silent. A conversation with no persona
10
+ * still answers — as nobody, on a surface that never said what it is — and that reads as a poor
11
+ * model rather than a missing argument.
12
+ */
13
+ import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder'
14
+
15
+ const history = [{ role: 'user', content: 'what figures do we have' }]
16
+
17
+ const args = (over: any = {}) => ({ history, systemPrompt: 'you help someone draw a figure', ...over })
18
+
19
+ describe('the neutral door carries no document', () => {
20
+ it('★ no board field reaches assistantChat', () => {
21
+ const input = buildAssistantChatInput(args({ sessionId: 's-1' }))
22
+
23
+ for (const field of ['currentBoard', 'selectedRefids', 'boardTools', 'knownTypes', 'componentSchemas', 'scopes', 'mentions']) {
24
+ expect(input).not.toHaveProperty(field)
25
+ }
26
+ })
27
+
28
+ it('carries the conversation, the session and the persona', () => {
29
+ const input = buildAssistantChatInput(args({ sessionId: 's-1' }))
30
+
31
+ expect(input.sessionId).toBe('s-1')
32
+ expect(input.messages).toBe(history)
33
+ expect(input.systemPrompt).toBe('you help someone draw a figure')
34
+ })
35
+
36
+ it('a one-off question has no session — null, not absent, so the server reads it as ad-hoc', () => {
37
+ expect(buildAssistantChatInput(args()).sessionId).toBeNull()
38
+ })
39
+ })
40
+
41
+ describe('the system prompt is required', () => {
42
+ it('★ refuses an empty prompt instead of sending one', () => {
43
+ expect(() => buildAssistantChatInput(args({ systemPrompt: '' }))).toThrow(/systemPrompt is required/)
44
+ expect(() => buildAssistantChatInput(args({ systemPrompt: ' ' }))).toThrow(/systemPrompt is required/)
45
+ expect(() => buildAssistantChatInput(args({ systemPrompt: undefined }))).toThrow(/systemPrompt is required/)
46
+ })
47
+ })
48
+
49
+ describe('optional fields are sent only when they mean something', () => {
50
+ it('an unset tool category list is left out — the server default is every registered category', () => {
51
+ expect(buildAssistantChatInput(args())).not.toHaveProperty('toolCategories')
52
+ })
53
+
54
+ it('★ an empty tool category list is sent — it means none, which is a different answer', () => {
55
+ expect(buildAssistantChatInput(args({ toolCategories: [] })).toolCategories).toEqual([])
56
+ })
57
+
58
+ it('grounding is sent only when required — false is the server default', () => {
59
+ expect(buildAssistantChatInput(args())).not.toHaveProperty('requireGroundingTools')
60
+ expect(buildAssistantChatInput(args({ requireGroundingTools: true })).requireGroundingTools).toBe(true)
61
+ })
62
+
63
+ it('host context and truncation travel when given', () => {
64
+ const input = buildAssistantChatInput(args({ hostContext: { figureId: 'fig-1' }, truncateAfterMessageId: 'm-9' }))
65
+
66
+ expect(input.hostContext).toEqual({ figureId: 'fig-1' })
67
+ expect(input.truncateAfterMessageId).toBe('m-9')
68
+ })
69
+ })
70
+
71
+ describe('the board door is unchanged', () => {
72
+ it('★ still carries selectedRefids — a missing one was why the AI did not know what was selected', () => {
73
+ const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [3, 7] } as any)
74
+ expect(input.selectedRefids).toEqual([3, 7])
75
+ })
76
+
77
+ it('does not take a system prompt — the board conversation has its persona on the server', () => {
78
+ const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [] } as any)
79
+ expect(input).not.toHaveProperty('systemPrompt')
80
+ })
81
+ })