@things-factory/board-ai 10.0.2 → 10.0.4

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 (60) hide show
  1. package/client/components/board-ai-chat.ts +458 -19
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +29 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +63 -0
  5. package/dist-client/client/components/board-ai-chat.js +438 -16
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.js +28 -0
  8. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  9. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  10. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  11. package/dist-client/server/service/agentic-loop.d.ts +15 -0
  12. package/dist-client/server/service/agentic-loop.js +70 -9
  13. package/dist-client/server/service/agentic-loop.js.map +1 -1
  14. package/dist-client/server/service/assistant.js +18 -3
  15. package/dist-client/server/service/assistant.js.map +1 -1
  16. package/dist-client/server/service/types.d.ts +23 -0
  17. package/dist-client/server/service/types.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-server/service/agentic-loop.d.ts +15 -0
  20. package/dist-server/service/agentic-loop.js +71 -9
  21. package/dist-server/service/agentic-loop.js.map +1 -1
  22. package/dist-server/service/assistant.js +17 -2
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.d.ts +13 -0
  25. package/dist-server/service/board-ai-resolver.js +99 -1
  26. package/dist-server/service/board-ai-resolver.js.map +1 -1
  27. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  28. package/dist-server/service/chat-message/fold-history.js +29 -0
  29. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  30. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  31. package/dist-server/service/chat-message/history-summary.js +77 -0
  32. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  33. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  34. package/dist-server/service/chat-message/llm-history.js +31 -1
  35. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  36. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  37. package/dist-server/service/chat-session/chat-session.js +5 -0
  38. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  39. package/dist-server/service/types.d.ts +23 -0
  40. package/dist-server/service/types.js.map +1 -1
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +6 -6
  43. package/server/service/agentic-loop.test.ts +91 -0
  44. package/server/service/agentic-loop.ts +80 -9
  45. package/server/service/assistant.ts +21 -3
  46. package/server/service/board-ai-resolver.ts +108 -1
  47. package/server/service/chat-message/fold-history.test.ts +98 -0
  48. package/server/service/chat-message/fold-history.ts +60 -0
  49. package/server/service/chat-message/history-summary.test.ts +127 -0
  50. package/server/service/chat-message/history-summary.ts +100 -0
  51. package/server/service/chat-message/llm-history.test.ts +65 -0
  52. package/server/service/chat-message/llm-history.ts +48 -1
  53. package/server/service/chat-session/chat-session.ts +11 -0
  54. package/server/service/dock-contract.test.ts +305 -0
  55. package/server/service/types.ts +23 -0
  56. package/translations/en.json +14 -1
  57. package/translations/ja.json +14 -1
  58. package/translations/ko.json +13 -0
  59. package/translations/ms.json +14 -1
  60. package/translations/zh.json +14 -1
@@ -135,6 +135,18 @@ export declare class OxBoardAIChat extends LitElement {
135
135
  private toastMessage?;
136
136
  /** 복사 confirmation feedback (메시지 idx → 짧은 시간 동안 ✓ 표시) */
137
137
  private copiedIdx?;
138
+ /** 원본 JSON 을 펼친 단계(라인:단계) — 목록은 한 줄 요약으로 훑고 필요한 것만 펼친다. */
139
+ private openToolSteps;
140
+ /** 이미 실행을 요청한 제안 키 — 같은 카드를 두 번 눌러 되돌릴 수 없는 명령이 두 번 나가지 않게. */
141
+ private sentProposals;
142
+ /**
143
+ * 이 대화가 길어져 앞부분이 프롬프트에서 접혔다는 **사실**(서버 보고). 주제 이탈을 판정한 것이
144
+ * 아니다 — 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를 무시한다.
145
+ * 사실만 알리고 "새 대화로 시작할지" 는 사용자가 정한다.
146
+ */
147
+ private historyFolded?;
148
+ /** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
149
+ private foldNoticeDismissed;
138
150
  private busy;
139
151
  private errorMessage?;
140
152
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
@@ -268,6 +280,42 @@ export declare class OxBoardAIChat extends LitElement {
268
280
  * system : 없음
269
281
  */
270
282
  private renderMessageActions;
283
+ /**
284
+ * "대화가 길어졌습니다" 안내 — **사실만** 알리고 결정은 사용자에게 맡긴다.
285
+ *
286
+ * 주제가 바뀌었는지 판정하지 않는다: 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를
287
+ * 무시한다(접지 경고에서 배운 것과 같다). 근거는 "앞부분이 프롬프트에서 접혔다" 는 객관적 사실뿐이다.
288
+ *
289
+ * 왜 알려야 하는가: 한 대화에 주제가 여럿 섞이면 (1) 옛 맥락이 새 답을 오염시키고 (2) 요약이
290
+ * 뭉개지고 (3) 목록의 대화 이름이 거짓말이 된다. 그걸 아는 사람은 사용자뿐이다.
291
+ */
292
+ private renderFoldNotice;
293
+ /**
294
+ * 새 대화로 — **맥락을 옮기지 않는다.** 주제를 바꾸려고 새로 시작하는 것이므로, 옮기면 그 이유가
295
+ * 사라진다(승격은 이어가려는 것이고 이건 끊으려는 것이다). 세션 생성은 호스트의 몫이다.
296
+ */
297
+ private startNewFromNotice;
298
+ /**
299
+ * 조치 제안 카드 — AI 는 제안까지, **실행은 사용자**.
300
+ *
301
+ * 왜 카드인가: 되돌릴 수 없는 현장 명령을 문장 속 링크로 흘리면 실수로 눌린다. 무엇을·왜 하는지
302
+ * 한 줄로 보이고 실행이 **분명한 한 번의 행동**이어야 한다.
303
+ * 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
304
+ */
305
+ private renderProposals;
306
+ /**
307
+ * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
308
+ *
309
+ * 인자는 **키 순서를 정렬**해 직렬화한다: 같은 인자를 다른 순서로 받았을 뿐인데 키가 달라지면
310
+ * 한 카드를 실행한 뒤에도 다른 카드가 살아 있어 **같은 명령이 두 번 나간다**(실제로 그랬다).
311
+ * 설명 문구(label·reason)는 키에 넣지 않는다 — 문구가 달라도 같은 조치다.
312
+ */
313
+ private proposalKey;
314
+ /**
315
+ * 실행은 호스트가 한다 — 이 컴포넌트는 도메인(트윈 명령 채널)을 모른다.
316
+ * 확인 대화·명령 전송·결과 기록은 호스트의 몫이고, 여기서는 중복 클릭만 막는다.
317
+ */
318
+ private executeProposal;
271
319
  /**
272
320
  * 접지 경고 — 답이 언급했지만 **근거에 없던** 대상을 알린다.
273
321
  *
@@ -286,6 +334,21 @@ export declare class OxBoardAIChat extends LitElement {
286
334
  private renderToolUsages;
287
335
  /** 시스템 기록 펼치기/접기 — 곁줄이 길어도 대화가 가려지지 않게. */
288
336
  private toggleSystemNote;
337
+ /**
338
+ * 판단 과정의 한 단계 — 턴 구분 · 도구 · 배지 · **한 줄 요약**.
339
+ *
340
+ * 원본 JSON 을 처음부터 펼쳐 두면 눈이 흐려져 정작 중요한 것(무엇이 거절됐는지, 무엇이 접혔는지)이
341
+ * 안 보인다. 한 줄로 줄이고 원본은 항목별로 눌러서 본다.
342
+ */
343
+ private renderToolStep;
344
+ /** 배지 문구 — 코드가 아니라 사람 말로. */
345
+ private outcomeLabel;
346
+ /**
347
+ * 결과 한 줄 — 무엇을 얻었는지/왜 막혔는지만. 목록에서 훑어 읽을 수 있어야 한다.
348
+ * 거절이면 사유를, 조회면 대표적인 개수를, 제안이면 무엇을 제안했는지.
349
+ */
350
+ private stepSummary;
351
+ private toggleToolStep;
289
352
  private toggleToolUsages;
290
353
  private formatJson;
291
354
  private copyMessage;
@@ -44,7 +44,9 @@ const BOARD_AI_CHAT_MUTATION = gql `
44
44
  patchId
45
45
  toolUsages
46
46
  actions
47
+ proposals
47
48
  groundingWarnings
49
+ historyFolded
48
50
  }
49
51
  }
50
52
  `;
@@ -153,6 +155,12 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
153
155
  this.revertedPatchIds = new Set();
154
156
  /** mini action — 인라인 예시 토글 */
155
157
  this.examplesOpen = false;
158
+ /** 원본 JSON 을 펼친 단계(라인:단계) — 목록은 한 줄 요약으로 훑고 필요한 것만 펼친다. */
159
+ this.openToolSteps = new Set();
160
+ /** 이미 실행을 요청한 제안 키 — 같은 카드를 두 번 눌러 되돌릴 수 없는 명령이 두 번 나가지 않게. */
161
+ this.sentProposals = new Set();
162
+ /** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
163
+ this.foldNoticeDismissed = false;
156
164
  this.busy = false;
157
165
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
158
166
  this.participants = [];
@@ -229,6 +237,14 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
229
237
  composed: true
230
238
  }));
231
239
  };
240
+ /**
241
+ * 새 대화로 — **맥락을 옮기지 않는다.** 주제를 바꾸려고 새로 시작하는 것이므로, 옮기면 그 이유가
242
+ * 사라진다(승격은 이어가려는 것이고 이건 끊으려는 것이다). 세션 생성은 호스트의 몫이다.
243
+ */
244
+ this.startNewFromNotice = () => {
245
+ this.foldNoticeDismissed = true;
246
+ this.dispatchEvent(new CustomEvent('board-ai-start-new-session', { bubbles: true, composed: true }));
247
+ };
232
248
  }
233
249
  static { OxBoardAIChat_1 = this; }
234
250
  /** 현재 로그인 사용자 email — 그룹챗에서 내 메시지 vs 타인 메시지 구분.
@@ -902,6 +918,126 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
902
918
  line-height: 1.45;
903
919
  }
904
920
 
921
+ /* ── "대화가 길어졌습니다" 안내 — 조용한 한 줄, 강조하지 않는다 ───── */
922
+ .fold-notice {
923
+ display: flex;
924
+ align-items: center;
925
+ gap: 6px;
926
+ margin: 0 0 4px;
927
+ padding: 5px 8px;
928
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
929
+ border-radius: 8px;
930
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
931
+ color: var(--md-sys-color-on-surface-variant, #475569);
932
+ font-size: 10.5px;
933
+ line-height: 1.45;
934
+ }
935
+ .fold-notice md-icon {
936
+ --md-icon-size: 14px;
937
+ flex-shrink: 0;
938
+ }
939
+ .fold-notice .fn-text {
940
+ flex: 1;
941
+ min-width: 0;
942
+ }
943
+ /* 새 대화 — 권유이므로 강조색 글자만(채운 버튼은 지시처럼 읽힌다). */
944
+ .fold-notice .fn-new {
945
+ flex-shrink: 0;
946
+ border: 0;
947
+ background: none;
948
+ color: var(--md-sys-color-primary, #0f766e);
949
+ font-family: inherit;
950
+ font-size: 10.5px;
951
+ font-weight: 650;
952
+ cursor: pointer;
953
+ padding: 2px 4px;
954
+ border-radius: 5px;
955
+ white-space: nowrap;
956
+ }
957
+ .fold-notice .fn-new:hover {
958
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
959
+ }
960
+ .fold-notice .fn-close {
961
+ flex-shrink: 0;
962
+ border: 0;
963
+ background: none;
964
+ color: var(--md-sys-color-outline, #94a3b8);
965
+ cursor: pointer;
966
+ padding: 0;
967
+ display: inline-flex;
968
+ }
969
+ .fold-notice .fn-close md-icon {
970
+ --md-icon-size: 13px;
971
+ }
972
+
973
+ /* ── 조치 제안 — AI 는 제안까지, 실행은 사용자 ───── */
974
+ .proposals {
975
+ display: flex;
976
+ flex-direction: column;
977
+ gap: 4px;
978
+ margin-top: 6px;
979
+ }
980
+ .proposal {
981
+ display: flex;
982
+ align-items: center;
983
+ gap: 8px;
984
+ padding: 6px 8px 6px 7px;
985
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
986
+ /* 왼쪽 강조선 — 되돌릴 수 없는 행동임을 조용히 구분한다(색만으로 소리치지 않는다). */
987
+ border-left: 3px solid var(--md-sys-color-primary, #0f766e);
988
+ border-radius: 8px;
989
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
990
+ }
991
+ .proposal md-icon {
992
+ --md-icon-size: 16px;
993
+ color: var(--md-sys-color-primary, #0f766e);
994
+ flex-shrink: 0;
995
+ }
996
+ .proposal .pbody {
997
+ display: flex;
998
+ flex-direction: column;
999
+ gap: 1px;
1000
+ min-width: 0;
1001
+ flex: 1;
1002
+ }
1003
+ .proposal .plabel {
1004
+ font-size: 11.5px;
1005
+ font-weight: 650;
1006
+ color: var(--md-sys-color-on-surface, #0f172a);
1007
+ overflow-wrap: anywhere;
1008
+ }
1009
+ .proposal .preason {
1010
+ font-size: 10.5px;
1011
+ color: var(--md-sys-color-on-surface-variant, #475569);
1012
+ overflow-wrap: anywhere;
1013
+ }
1014
+ /* 실행 버튼 — 이 대화의 유일한 "되돌릴 수 없는" 행동이므로 채운 버튼 하나로 분명하게. */
1015
+ .proposal .run {
1016
+ flex-shrink: 0;
1017
+ padding: 4px 12px;
1018
+ border: 0;
1019
+ border-radius: 999px;
1020
+ background: var(--md-sys-color-primary, #0f766e);
1021
+ color: var(--md-sys-color-on-primary, #ffffff);
1022
+ font-family: inherit;
1023
+ font-size: 11px;
1024
+ font-weight: 650;
1025
+ letter-spacing: 0.01em;
1026
+ cursor: pointer;
1027
+ transition: filter 0.12s;
1028
+ }
1029
+ .proposal .run:hover:not(:disabled) {
1030
+ filter: brightness(1.08);
1031
+ }
1032
+ .proposal .run:disabled {
1033
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1034
+ color: var(--md-sys-color-on-surface-variant, #475569);
1035
+ cursor: default;
1036
+ }
1037
+ .proposal.sent {
1038
+ border-left-color: var(--md-sys-color-outline, #94a3b8);
1039
+ }
1040
+
905
1041
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
906
1042
  .grounding-warning {
907
1043
  display: flex;
@@ -1026,6 +1162,76 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
1026
1162
  color: var(--md-sys-color-on-error, #ffffff);
1027
1163
  background: var(--md-sys-color-error, #b91c1c);
1028
1164
  }
1165
+ /* 판단 파이프라인 — 훑어 읽는 목록. 배지로 결과를 구분하고 원본은 눌러서 본다. */
1166
+ .tool-usages.has-problem {
1167
+ border-color: var(--md-sys-color-error, #dc2626);
1168
+ }
1169
+ .tool-usages-counts .tu-flag {
1170
+ font-weight: 650;
1171
+ }
1172
+ .tool-usages-counts .tu-flag.bad {
1173
+ color: var(--md-sys-color-error, #dc2626);
1174
+ }
1175
+ .tu-turn {
1176
+ list-style: none;
1177
+ margin: 4px 0 2px;
1178
+ padding: 0 8px;
1179
+ font-size: 9.5px;
1180
+ font-weight: 650;
1181
+ letter-spacing: 0.04em;
1182
+ color: var(--md-sys-color-outline, #94a3b8);
1183
+ text-transform: uppercase;
1184
+ }
1185
+ .tool-usage-head {
1186
+ cursor: pointer;
1187
+ align-items: center;
1188
+ }
1189
+ .tool-usage-item.outcome-rejected,
1190
+ .tool-usage-item.outcome-error {
1191
+ border-left-color: var(--md-sys-color-error, #dc2626);
1192
+ }
1193
+ .tool-usage-item.outcome-proposed {
1194
+ border-left-color: var(--md-sys-color-primary, #0f766e);
1195
+ }
1196
+ .tool-usage-item.outcome-folded {
1197
+ opacity: 0.7;
1198
+ }
1199
+ .tu-badge {
1200
+ flex-shrink: 0;
1201
+ padding: 0 5px;
1202
+ border-radius: 999px;
1203
+ font-size: 9px;
1204
+ font-weight: 700;
1205
+ line-height: 15px;
1206
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1207
+ color: var(--md-sys-color-on-surface-variant, #475569);
1208
+ }
1209
+ .tu-badge.rejected,
1210
+ .tu-badge.error {
1211
+ background: var(--md-sys-color-error-container, #fef2f2);
1212
+ color: var(--md-sys-color-error, #dc2626);
1213
+ }
1214
+ .tu-badge.proposed {
1215
+ background: var(--md-sys-color-primary, #0f766e);
1216
+ color: var(--md-sys-color-on-primary, #fff);
1217
+ }
1218
+ /* 한 줄 요약 — 넘치면 자른다. 목록은 훑는 곳이고 읽는 곳이 아니다. */
1219
+ .tu-line {
1220
+ flex: 1;
1221
+ min-width: 0;
1222
+ overflow: hidden;
1223
+ text-overflow: ellipsis;
1224
+ white-space: nowrap;
1225
+ color: var(--md-sys-color-on-surface-variant, #475569);
1226
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1227
+ font-size: 9.5px;
1228
+ }
1229
+ .tu-more {
1230
+ --md-icon-size: 13px;
1231
+ flex-shrink: 0;
1232
+ color: var(--md-sys-color-outline, #94a3b8);
1233
+ }
1234
+
1029
1235
  .tool-usage-args,
1030
1236
  .tool-usage-result {
1031
1237
  margin: 2px 0 0;
@@ -1568,6 +1774,7 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
1568
1774
  ${line.followUp
1569
1775
  ? html `<div class="followup">${unsafeHTML(renderMarkdown(line.followUp.trim()))}</div>`
1570
1776
  : nothing}
1777
+ ${line.proposals && line.proposals.length > 0 ? this.renderProposals(line) : nothing}
1571
1778
  ${line.groundingWarnings && line.groundingWarnings.length > 0
1572
1779
  ? this.renderGroundingWarning(line)
1573
1780
  : nothing}
@@ -1595,6 +1802,7 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
1595
1802
  </div>
1596
1803
  `
1597
1804
  : nothing}
1805
+ ${this.renderFoldNotice()}
1598
1806
  <div class="composer ${hasMessages ? '' : 'no-actions'}">
1599
1807
  ${hasMessages
1600
1808
  ? html `
@@ -2148,6 +2356,115 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
2148
2356
  </div>
2149
2357
  `;
2150
2358
  }
2359
+ /**
2360
+ * "대화가 길어졌습니다" 안내 — **사실만** 알리고 결정은 사용자에게 맡긴다.
2361
+ *
2362
+ * 주제가 바뀌었는지 판정하지 않는다: 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를
2363
+ * 무시한다(접지 경고에서 배운 것과 같다). 근거는 "앞부분이 프롬프트에서 접혔다" 는 객관적 사실뿐이다.
2364
+ *
2365
+ * 왜 알려야 하는가: 한 대화에 주제가 여럿 섞이면 (1) 옛 맥락이 새 답을 오염시키고 (2) 요약이
2366
+ * 뭉개지고 (3) 목록의 대화 이름이 거짓말이 된다. 그걸 아는 사람은 사용자뿐이다.
2367
+ */
2368
+ renderFoldNotice() {
2369
+ const folded = this.historyFolded;
2370
+ if (!folded || this.foldNoticeDismissed)
2371
+ return nothing;
2372
+ return html `
2373
+ <div class="fold-notice">
2374
+ <md-icon>unfold_less</md-icon>
2375
+ <span class="fn-text">
2376
+ ${folded.summarized
2377
+ ? i18next.t('board-ai.text.history-folded-summarized', {
2378
+ count: folded.omitted,
2379
+ defaultValue: '대화가 길어져 앞부분 {count}개가 요약으로 접혔습니다. 다른 주제라면 새 대화가 낫습니다.'
2380
+ })
2381
+ : i18next.t('board-ai.text.history-folded', {
2382
+ count: folded.omitted,
2383
+ defaultValue: '대화가 길어져 앞부분 {count}개가 생략되었습니다. 다른 주제라면 새 대화가 낫습니다.'
2384
+ })}
2385
+ </span>
2386
+ <button class="fn-new" @click=${this.startNewFromNotice}>
2387
+ ${i18next.t('board-ai.button.new-chat')}
2388
+ </button>
2389
+ <button class="fn-close" title=${i18next.t('board-ai.button.close')} @click=${() => (this.foldNoticeDismissed = true)}>
2390
+ <md-icon>close</md-icon>
2391
+ </button>
2392
+ </div>
2393
+ `;
2394
+ }
2395
+ /**
2396
+ * 조치 제안 카드 — AI 는 제안까지, **실행은 사용자**.
2397
+ *
2398
+ * 왜 카드인가: 되돌릴 수 없는 현장 명령을 문장 속 링크로 흘리면 실수로 눌린다. 무엇을·왜 하는지
2399
+ * 한 줄로 보이고 실행이 **분명한 한 번의 행동**이어야 한다.
2400
+ * 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
2401
+ */
2402
+ renderProposals(line) {
2403
+ const items = line.proposals ?? [];
2404
+ if (items.length === 0)
2405
+ return nothing;
2406
+ return html `
2407
+ <div class="proposals">
2408
+ ${items.map(p => {
2409
+ const key = this.proposalKey(p);
2410
+ const sent = this.sentProposals.has(key);
2411
+ return html `
2412
+ <div class="proposal ${sent ? 'sent' : ''}">
2413
+ <md-icon>bolt</md-icon>
2414
+ <span class="pbody">
2415
+ <span class="plabel">${p.label || p.command}</span>
2416
+ ${p.reason ? html `<span class="preason">${p.reason}</span>` : nothing}
2417
+ </span>
2418
+ <button
2419
+ class="run"
2420
+ ?disabled=${sent}
2421
+ @click=${() => this.executeProposal(p, key)}>
2422
+ ${sent ? i18next.t('board-ai.text.proposal-sent', { defaultValue: '실행 요청됨' })
2423
+ : i18next.t('board-ai.button.run-proposal', { defaultValue: '실행' })}
2424
+ </button>
2425
+ </div>
2426
+ `;
2427
+ })}
2428
+ </div>
2429
+ `;
2430
+ }
2431
+ /**
2432
+ * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
2433
+ *
2434
+ * 인자는 **키 순서를 정렬**해 직렬화한다: 같은 인자를 다른 순서로 받았을 뿐인데 키가 달라지면
2435
+ * 한 카드를 실행한 뒤에도 다른 카드가 살아 있어 **같은 명령이 두 번 나간다**(실제로 그랬다).
2436
+ * 설명 문구(label·reason)는 키에 넣지 않는다 — 문구가 달라도 같은 조치다.
2437
+ */
2438
+ proposalKey(p) {
2439
+ const stable = (v) => {
2440
+ if (v === null || typeof v !== 'object')
2441
+ return JSON.stringify(v ?? null);
2442
+ if (Array.isArray(v))
2443
+ return `[${v.map(stable).join(',')}]`;
2444
+ return `{${Object.keys(v)
2445
+ .sort()
2446
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
2447
+ .join(',')}}`;
2448
+ };
2449
+ try {
2450
+ return `${p?.command ?? ''}|${p?.instanceId ?? ''}|${stable(p?.args)}`;
2451
+ }
2452
+ catch {
2453
+ return `${p?.command ?? ''}|${p?.instanceId ?? ''}`;
2454
+ }
2455
+ }
2456
+ /**
2457
+ * 실행은 호스트가 한다 — 이 컴포넌트는 도메인(트윈 명령 채널)을 모른다.
2458
+ * 확인 대화·명령 전송·결과 기록은 호스트의 몫이고, 여기서는 중복 클릭만 막는다.
2459
+ */
2460
+ executeProposal(p, key) {
2461
+ this.sentProposals = new Set(this.sentProposals).add(key);
2462
+ this.dispatchEvent(new CustomEvent('board-ai-proposal-execute', {
2463
+ detail: { proposal: p, sessionId: this.sessionId },
2464
+ bubbles: true,
2465
+ composed: true
2466
+ }));
2467
+ }
2151
2468
  /**
2152
2469
  * 접지 경고 — 답이 언급했지만 **근거에 없던** 대상을 알린다.
2153
2470
  *
@@ -2186,35 +2503,39 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
2186
2503
  const readCount = usages.filter(u => u.kind === 'read').length;
2187
2504
  const writeCount = usages.filter(u => u.kind === 'write').length;
2188
2505
  const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' });
2506
+ /* 눈에 걸려야 하는 것을 머리줄에 올린다 — 거절·접힘이 있으면 펼치기 전에 보인다.
2507
+ * (이번 사고들이 전부 그것이었다: 인자 빠진 호출이 거절됐고, 중복 제안이 접혔다.) */
2508
+ const problems = usages.filter(u => u.outcome === 'rejected' || u.outcome === 'error').length;
2509
+ const folded = usages.filter(u => u.outcome === 'folded').length;
2189
2510
  return html `
2190
- <div class="tool-usages ${open ? 'open' : ''}">
2511
+ <div class="tool-usages ${open ? 'open' : ''} ${problems ? 'has-problem' : ''}">
2191
2512
  <button
2192
2513
  class="tool-usages-header"
2193
2514
  aria-expanded=${open}
2194
2515
  @click=${() => this.toggleToolUsages(idx)}>
2195
- <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'build'}</md-icon>
2516
+ <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'account_tree'}</md-icon>
2196
2517
  <span class="tool-usages-summary">
2197
2518
  <strong>${usages.length}</strong> ${toolsLabel}
2198
- <span class="tool-usages-counts">· read ${readCount} · write ${writeCount}</span>
2519
+ <span class="tool-usages-counts">
2520
+ · read ${readCount} · write ${writeCount}${problems
2521
+ ? html ` · <span class="tu-flag bad">${i18next.t('board-ai.text.tool-rejected-count', {
2522
+ count: problems,
2523
+ defaultValue: '거절 {count}'
2524
+ })}</span>`
2525
+ : nothing}${folded
2526
+ ? html ` · <span class="tu-flag">${i18next.t('board-ai.text.tool-folded-count', {
2527
+ count: folded,
2528
+ defaultValue: '접힘 {count}'
2529
+ })}</span>`
2530
+ : nothing}
2531
+ </span>
2199
2532
  </span>
2200
2533
  <md-icon class="tool-usages-chevron">${open ? 'expand_less' : 'expand_more'}</md-icon>
2201
2534
  </button>
2202
2535
  ${open
2203
2536
  ? html `
2204
2537
  <ol class="tool-usages-list">
2205
- ${usages.map((u, i) => html `
2206
- <li class="tool-usage-item kind-${u.kind}">
2207
- <div class="tool-usage-head">
2208
- <span class="tool-usage-step">${i + 1}.</span>
2209
- <span class="tool-usage-name">${u.name}</span>
2210
- <span class="tool-usage-kind kind-${u.kind}">${u.kind}</span>
2211
- </div>
2212
- ${u.arguments && Object.keys(u.arguments).length > 0
2213
- ? html `<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
2214
- : nothing}
2215
- <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
2216
- </li>
2217
- `)}
2538
+ ${usages.map((u, i) => this.renderToolStep(idx, u, i, usages[i - 1]))}
2218
2539
  </ol>
2219
2540
  `
2220
2541
  : nothing}
@@ -2230,6 +2551,88 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
2230
2551
  next[idx] = { ...line, systemOpen: !line.systemOpen };
2231
2552
  this.lines = next;
2232
2553
  }
2554
+ /**
2555
+ * 판단 과정의 한 단계 — 턴 구분 · 도구 · 배지 · **한 줄 요약**.
2556
+ *
2557
+ * 원본 JSON 을 처음부터 펼쳐 두면 눈이 흐려져 정작 중요한 것(무엇이 거절됐는지, 무엇이 접혔는지)이
2558
+ * 안 보인다. 한 줄로 줄이고 원본은 항목별로 눌러서 본다.
2559
+ */
2560
+ renderToolStep(lineIdx, u, i, prev) {
2561
+ const outcome = u.outcome ?? 'ok';
2562
+ const openKey = `${lineIdx}:${i}`;
2563
+ const open = this.openToolSteps.has(openKey);
2564
+ /* 턴이 바뀌면 구분선 — "한 턴 안에서 정정했는지, 다음 턴에서 했는지" 가 진단의 절반이다. */
2565
+ const newTurn = typeof u.iter === 'number' && (!prev || prev.iter !== u.iter);
2566
+ return html `
2567
+ ${newTurn
2568
+ ? html `<li class="tu-turn">
2569
+ ${i18next.t('board-ai.text.tool-turn', { n: (u.iter ?? 0) + 1, defaultValue: '{n}번째 판단' })}
2570
+ </li>`
2571
+ : nothing}
2572
+ <li class="tool-usage-item kind-${u.kind} outcome-${outcome}">
2573
+ <div class="tool-usage-head" @click=${() => this.toggleToolStep(openKey)}>
2574
+ <span class="tool-usage-step">${i + 1}.</span>
2575
+ <span class="tool-usage-name">${u.name}</span>
2576
+ <span class="tu-badge ${outcome}">${this.outcomeLabel(outcome)}</span>
2577
+ <span class="tu-line" title=${this.stepSummary(u)}>${this.stepSummary(u)}</span>
2578
+ <md-icon class="tu-more">${open ? 'expand_less' : 'expand_more'}</md-icon>
2579
+ </div>
2580
+ ${open
2581
+ ? html `
2582
+ ${u.arguments && Object.keys(u.arguments).length > 0
2583
+ ? html `<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
2584
+ : nothing}
2585
+ <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
2586
+ `
2587
+ : nothing}
2588
+ </li>
2589
+ `;
2590
+ }
2591
+ /** 배지 문구 — 코드가 아니라 사람 말로. */
2592
+ outcomeLabel(outcome) {
2593
+ const map = {
2594
+ ok: ['board-ai.text.outcome-ok', '조회'],
2595
+ rejected: ['board-ai.text.outcome-rejected', '거절'],
2596
+ queued: ['board-ai.text.outcome-queued', '적용 예정'],
2597
+ proposed: ['board-ai.text.outcome-proposed', '제안'],
2598
+ folded: ['board-ai.text.outcome-folded', '중복 접힘'],
2599
+ error: ['board-ai.text.outcome-error', '오류']
2600
+ };
2601
+ const [key, fallback] = map[outcome] ?? map.ok;
2602
+ return i18next.t(key, { defaultValue: fallback });
2603
+ }
2604
+ /**
2605
+ * 결과 한 줄 — 무엇을 얻었는지/왜 막혔는지만. 목록에서 훑어 읽을 수 있어야 한다.
2606
+ * 거절이면 사유를, 조회면 대표적인 개수를, 제안이면 무엇을 제안했는지.
2607
+ */
2608
+ stepSummary(u) {
2609
+ const r = u?.result;
2610
+ if (!r || typeof r !== 'object')
2611
+ return typeof r === 'string' ? r.slice(0, 80) : '';
2612
+ if (u.outcome === 'rejected' || u.outcome === 'error') {
2613
+ return String(r.errorCode || r.error || r.issues?.[0]?.message || '').slice(0, 80);
2614
+ }
2615
+ if (u.outcome === 'proposed' || u.outcome === 'folded') {
2616
+ return [r.command, r.args && Object.keys(r.args).length ? this.formatJson(r.args) : '']
2617
+ .filter(Boolean)
2618
+ .join(' ')
2619
+ .slice(0, 80);
2620
+ }
2621
+ /* 조회 결과 — 배열 길이가 있는 필드를 골라 "무엇이 몇 개" 로 요약한다(전체 덤프 금지). */
2622
+ const counts = Object.keys(r)
2623
+ .filter(k => Array.isArray(r[k]) && r[k].length > 0)
2624
+ .slice(0, 2)
2625
+ .map(k => `${k} ${r[k].length}`);
2626
+ if (counts.length)
2627
+ return counts.join(' · ');
2628
+ const keys = Object.keys(r).slice(0, 4);
2629
+ return keys.map(k => (typeof r[k] === 'object' ? k : `${k} ${r[k]}`)).join(' · ').slice(0, 80);
2630
+ }
2631
+ toggleToolStep(key) {
2632
+ const next = new Set(this.openToolSteps);
2633
+ next.has(key) ? next.delete(key) : next.add(key);
2634
+ this.openToolSteps = next;
2635
+ }
2233
2636
  toggleToolUsages(idx) {
2234
2637
  const next = [...this.lines];
2235
2638
  const line = next[idx];
@@ -2383,6 +2786,8 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
2383
2786
  const out = result.data?.boardAIChat;
2384
2787
  if (!out)
2385
2788
  throw new Error(i18next.t('board-ai.text.empty-response'));
2789
+ /* 대화가 길어졌다는 서버 보고 — 닫지 않았다면 안내를 띄운다. */
2790
+ this.historyFolded = out.historyFolded ?? undefined;
2386
2791
  // 전송 성공 — picks 정리 (다음 메시지부터는 새 picks 만)
2387
2792
  this.pickedMentions.clear();
2388
2793
  this.pickedUserMentions.clear();
@@ -2397,6 +2802,7 @@ let OxBoardAIChat = class OxBoardAIChat extends LitElement {
2397
2802
  patchId: out.patchId || undefined,
2398
2803
  toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
2399
2804
  groundingWarnings: Array.isArray(out.groundingWarnings) ? out.groundingWarnings : undefined,
2805
+ proposals: Array.isArray(out.proposals) ? out.proposals : undefined,
2400
2806
  pending: false
2401
2807
  });
2402
2808
  // 호스트로 patch 이벤트 전파
@@ -2532,6 +2938,22 @@ __decorate([
2532
2938
  state(),
2533
2939
  __metadata("design:type", Number)
2534
2940
  ], OxBoardAIChat.prototype, "copiedIdx", void 0);
2941
+ __decorate([
2942
+ state(),
2943
+ __metadata("design:type", Object)
2944
+ ], OxBoardAIChat.prototype, "openToolSteps", void 0);
2945
+ __decorate([
2946
+ state(),
2947
+ __metadata("design:type", Object)
2948
+ ], OxBoardAIChat.prototype, "sentProposals", void 0);
2949
+ __decorate([
2950
+ state(),
2951
+ __metadata("design:type", Object)
2952
+ ], OxBoardAIChat.prototype, "historyFolded", void 0);
2953
+ __decorate([
2954
+ state(),
2955
+ __metadata("design:type", Object)
2956
+ ], OxBoardAIChat.prototype, "foldNoticeDismissed", void 0);
2535
2957
  __decorate([
2536
2958
  state(),
2537
2959
  __metadata("design:type", Object)