@things-factory/board-ai 10.0.2 → 10.0.3

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
@@ -1,4 +1,5 @@
1
1
  import type { LLMMessage } from '../types.js';
2
+ import { type StoredSummary } from './history-summary.js';
2
3
  /** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */
3
4
  export interface HistoryRow {
4
5
  id?: string;
@@ -17,6 +18,24 @@ export interface BuildLlmHistoryOptions {
17
18
  keepMessageIds?: string[];
18
19
  /** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */
19
20
  labelSenders?: boolean;
21
+ /**
22
+ * 모델에 넘길 **최근 메시지 수 상한**. 초과분(오래된 앞부분)은 버리고 생략 표시를 남긴다.
23
+ *
24
+ * 왜 필요한가: 협의는 며칠에 걸친다. 상한이 없으면 매 턴 전체 이력을 보내 프롬프트가 무한히
25
+ * 자라고(비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 미지정이면 전부 보낸다(기존 동작).
26
+ *
27
+ * **생략은 반드시 알린다** — 조용히 버리면 모델은 남은 첫 줄을 대화의 시작으로 오해한다.
28
+ * 이건 상한일 뿐 요약이 아니다. 버린 내용을 요약해 실어 보내는 것은 다음 단계다(ChatSession.lastSummary).
29
+ */
30
+ maxTurns?: number;
31
+ /**
32
+ * 상한으로 밀려난 앞부분을 대신할 **요약**(세션에 저장된 것).
33
+ *
34
+ * 있으면 "N개 생략" 대신 요약을 첫 줄로 싣는다 — 며칠 이어지는 협의에서 앞에서 합의한 것이
35
+ * 없던 일이 되지 않게. 요약이 밀려난 구간을 **전부 덮지 못하면** 덮지 못한 개수도 함께 밝힌다
36
+ * (요약이 최신이 아닐 수 있다는 사실을 숨기지 않는다).
37
+ */
38
+ summary?: StoredSummary;
20
39
  }
21
40
  /**
22
41
  * 저장된 이력 → LLM 대화.
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildLlmHistory = buildLlmHistory;
4
+ const history_summary_js_1 = require("./history-summary.js");
4
5
  /** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */
5
6
  const SYSTEM_LABEL = '시스템';
6
7
  /** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */
7
8
  const UNKNOWN_LABEL = '참여자';
9
+ /** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */
10
+ const OMITTED_LABEL = '시스템';
8
11
  /**
9
12
  * 저장된 이력 → LLM 대화.
10
13
  *
@@ -13,9 +16,36 @@ const UNKNOWN_LABEL = '참여자';
13
16
  */
14
17
  function buildLlmHistory(rows, options = {}) {
15
18
  const keep = new Set(options.keepMessageIds ?? []);
16
- const kept = truncate(rows, options.truncateAfterMessageId, keep);
19
+ const truncated = truncate(rows, options.truncateAfterMessageId, keep);
20
+ /* 상한 — 최근 것부터 남긴다. 오래된 말보다 최근 상황이 답을 좌우한다. */
21
+ const limit = options.maxTurns && options.maxTurns > 0 ? options.maxTurns : 0;
22
+ const omitted = limit && truncated.length > limit ? truncated.length - limit : 0;
23
+ const kept = omitted ? truncated.slice(-limit) : truncated;
17
24
  const label = options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1;
18
25
  const out = [];
26
+ if (omitted) {
27
+ const dropped = truncated.slice(0, truncated.length - kept.length);
28
+ const summaryText = (options.summary?.text ?? '').trim();
29
+ if (summaryText) {
30
+ /* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.
31
+ * 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */
32
+ const uncovered = (0, history_summary_js_1.pendingForSummary)(dropped, options.summary).length;
33
+ const tail = uncovered
34
+ ? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`
35
+ : '';
36
+ out.push({
37
+ role: 'user',
38
+ content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\n${summaryText}`
39
+ });
40
+ }
41
+ else {
42
+ /* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */
43
+ out.push({
44
+ role: 'user',
45
+ content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`
46
+ });
47
+ }
48
+ }
19
49
  for (const row of kept) {
20
50
  const content = (row.content ?? '').trim();
21
51
  if (!content)
@@ -1 +1 @@
1
- {"version":3,"file":"llm-history.js","sourceRoot":"","sources":["../../../server/service/chat-message/llm-history.ts"],"names":[],"mappings":";;AAsEA,0CAsBC;AAjCD,kCAAkC;AAClC,MAAM,YAAY,GAAG,KAAK,CAAA;AAC1B,wCAAwC;AACxC,MAAM,aAAa,GAAG,KAAK,CAAA;AAE3B;;;;;GAKG;AACH,SAAgB,eAAe,CAAC,IAAkB,EAAE,UAAkC,EAAE;IACtF,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,cAAc,IAAI,EAAE,CAAC,CAAA;IAClD,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,sBAAsB,EAAE,IAAI,CAAC,CAAA;IAEjE,MAAM,KAAK,GACT,OAAO,CAAC,YAAY,IAAI,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAA;IAEnG,MAAM,GAAG,GAAiB,EAAE,CAAA;IAC5B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QAC1C,IAAI,CAAC,OAAO;YAAE,SAAQ,CAAC,mCAAmC;QAC1D,IAAI,GAAG,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;YAC7B,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,CAAA;YACxC,SAAQ;QACV,CAAC;QACD,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC1B,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,YAAY,KAAK,OAAO,EAAE,EAAE,CAAC,CAAA;YACnE,SAAQ;QACV,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAA;IACvF,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,oDAAoD;AACpD,SAAS,OAAO,CAAC,GAAe;IAC9B,OAAO,GAAG,CAAC,IAAI,KAAK,WAAW,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAA;AAC1D,CAAC;AAED,SAAS,OAAO,CAAC,GAAe;IAC9B,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAC1C,OAAO,IAAI,IAAI,aAAa,CAAA;AAC9B,CAAC;AAED;;;GAGG;AACH,SAAS,QAAQ,CAAC,IAAkB,EAAE,OAA2B,EAAE,IAAiB;IAClF,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAA;IACzB,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,OAAO,CAAC,CAAA;IACjD,IAAI,GAAG,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACxB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAA;IACnC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IACpE,OAAO,CAAC,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC,CAAA;AAC3B,CAAC","sourcesContent":["/*\n * 에이전트에게 넘길 대화 이력 조립 — 순수 로직(리졸버와 분리해 단위 테스트 가능).\n *\n * ── 왜 서버 정본인가 ─────────────────────────────────────────────────────────\n * 예전에는 클라이언트가 화면에 들고 있던 줄들을 그대로 LLM 에 넘겼다. 협의(여러 참여자)에서는\n * 참여자마다 화면이 달라 **에이전트가 사람마다 다른 대화를 본다**. 서버는 ChatMessage 에 정본을\n * 갖고 있으므로 그것을 쓴다. 세션이 없는 즉석 대화(ad-hoc)만 클라이언트 이력을 쓴다.\n *\n * ── 발신자 표기 ─────────────────────────────────────────────────────────────\n * LLMMessage 는 `{ role, content }` 뿐이다 — 발신자 칸이 없다(Anthropic·Gemini 는 message.name 을\n * 지원하지 않아 이식성이 없다). 그래서 발신자는 **본문 맨 앞 표기**로만 전달할 수 있다.\n *\n * [김철수] 승인합니다\n *\n * **이 표기는 추론용 문맥이며 권한 판정 근거가 아니다.** 사용자가 본문에 같은 형식을 직접 적을 수\n * 있으므로, 되돌릴 수 없는 조치의 승인 여부는 반드시 서버 게이트(요청자 신원 기반)가 판정한다.\n * 표기는 \"누가 무슨 맥락에서 말했는지\"를 모델이 이해하게 하는 용도로만 쓴다.\n *\n * 발신자가 **한 명뿐이면 표기하지 않는다** — 혼자 쓰는 대화(대다수)에 노이즈와 토큰을 늘리지 않고,\n * 기존 동작과 같은 프롬프트를 유지한다.\n *\n * ── system 행 ───────────────────────────────────────────────────────────────\n * 캔버스 직접 편집 기록(`User directly edited the board: …`)은 원래 에이전트에게 알리려고 남긴 것인데,\n * 클라이언트가 role='system' 을 걸러내 사장돼 있었다. LLMMessage 에 system 역할이 없으므로\n * `[시스템]` 표기를 붙인 user 메시지로 넘긴다 — 사람이 아니므로 발신자 수 계산에는 넣지 않는다.\n *\n * ── 편집·재생성으로 접은 구간 ───────────────────────────────────────────────\n * 클라이언트의 메시지 편집은 화면에서만 접고 **서버 이력은 유지**한다(board-ai-chat.editUserMessage).\n * 서버 정본으로 바꾸면 접은 말이 에이전트 문맥에 되살아난다. 그래서 호출자가 `truncateAfterMessageId`\n * (마지막으로 남긴 메시지 id)를 주면 그 뒤 행을 버린다. 단 `keepMessageIds` 로 지정한 행(방금 저장한\n * 새 사용자 메시지)은 시간상 뒤에 있어도 항상 남긴다.\n *\n * 알려진 한계(현행 동작과 동일): 공유 세션에서 접은 구간에 **다른 사람의 발언**이 있으면 그것도\n * 함께 제외된다. 오늘 클라이언트가 보내는 이력도 같으므로 이 단계에서는 동등성을 유지하고,\n * \"내 발언만 접기\"는 별도 개선으로 다룬다.\n */\nimport type { LLMMessage } from '../types.js'\n\n/** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */\nexport interface HistoryRow {\n id?: string\n role: string\n /** 멘션 마커(#token{refid:N})는 호출자가 미리 제거해 넘긴다 — 모델 주의 분산·토큰 낭비 방지. */\n content: string\n /** 발신자 식별자(서버 내부용 — 발신자 수 계산에만 쓰고 프롬프트에 넣지 않는다). */\n senderId?: string\n /** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */\n senderName?: string\n}\n\nexport interface BuildLlmHistoryOptions {\n /** 이 메시지까지만 유지하고 이후는 버린다(편집·재생성으로 접은 구간). 미지정이면 전부 사용. */\n truncateAfterMessageId?: string\n /** 잘라내기와 무관하게 항상 유지할 메시지 id(방금 저장한 새 사용자 메시지 등). */\n keepMessageIds?: string[]\n /** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */\n labelSenders?: boolean\n}\n\n/** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */\nconst SYSTEM_LABEL = '시스템'\n/** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */\nconst UNKNOWN_LABEL = '참여자'\n\n/**\n * 저장된 이력 → LLM 대화.\n *\n * 입력은 **시간 오름차순**을 가정한다(호출자가 createdAt ASC 로 조회). 정렬을 다시 하지 않는다 —\n * 같은 시각의 순서까지 보장하려면 조회 쪽에서 결정해야 하고, 여기서 추측하면 두 곳이 어긋난다.\n */\nexport function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOptions = {}): LLMMessage[] {\n const keep = new Set(options.keepMessageIds ?? [])\n const kept = truncate(rows, options.truncateAfterMessageId, keep)\n\n const label =\n options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1\n\n const out: LLMMessage[] = []\n for (const row of kept) {\n const content = (row.content ?? '').trim()\n if (!content) continue // 빈 본문(pending 자리 등)은 모델에 넘길 것이 없다\n if (row.role === 'assistant') {\n out.push({ role: 'assistant', content })\n continue\n }\n if (row.role === 'system') {\n out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` })\n continue\n }\n out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content })\n }\n return out\n}\n\n/** 사람이 보낸 줄인가 — 발신자 수 계산 대상. system 기록은 사람이 아니다. */\nfunction isHuman(row: HistoryRow): boolean {\n return row.role !== 'assistant' && row.role !== 'system'\n}\n\nfunction speaker(row: HistoryRow): string {\n const name = (row.senderName ?? '').trim()\n return name || UNKNOWN_LABEL\n}\n\n/**\n * 접은 구간 제거. `truncateAfterMessageId` 가 없거나 이력에서 못 찾으면 **전부 유지**한다\n * (조용히 다 버리는 것보다 다 보내는 쪽이 안전하다 — 문맥 상실은 답을 망친다).\n */\nfunction truncate(rows: HistoryRow[], afterId: string | undefined, keep: Set<string>): HistoryRow[] {\n if (!afterId) return rows\n const idx = rows.findIndex(r => r.id === afterId)\n if (idx < 0) return rows\n const head = rows.slice(0, idx + 1)\n const tail = rows.slice(idx + 1).filter(r => r.id && keep.has(r.id))\n return [...head, ...tail]\n}\n"]}
1
+ {"version":3,"file":"llm-history.js","sourceRoot":"","sources":["../../../server/service/chat-message/llm-history.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 에이전트에게 넘길 대화 이력 조립 — 순수 로직(리졸버와 분리해 단위 테스트 가능).\n *\n * ── 왜 서버 정본인가 ─────────────────────────────────────────────────────────\n * 예전에는 클라이언트가 화면에 들고 있던 줄들을 그대로 LLM 에 넘겼다. 협의(여러 참여자)에서는\n * 참여자마다 화면이 달라 **에이전트가 사람마다 다른 대화를 본다**. 서버는 ChatMessage 에 정본을\n * 갖고 있으므로 그것을 쓴다. 세션이 없는 즉석 대화(ad-hoc)만 클라이언트 이력을 쓴다.\n *\n * ── 발신자 표기 ─────────────────────────────────────────────────────────────\n * LLMMessage 는 `{ role, content }` 뿐이다 — 발신자 칸이 없다(Anthropic·Gemini 는 message.name 을\n * 지원하지 않아 이식성이 없다). 그래서 발신자는 **본문 맨 앞 표기**로만 전달할 수 있다.\n *\n * [김철수] 승인합니다\n *\n * **이 표기는 추론용 문맥이며 권한 판정 근거가 아니다.** 사용자가 본문에 같은 형식을 직접 적을 수\n * 있으므로, 되돌릴 수 없는 조치의 승인 여부는 반드시 서버 게이트(요청자 신원 기반)가 판정한다.\n * 표기는 \"누가 무슨 맥락에서 말했는지\"를 모델이 이해하게 하는 용도로만 쓴다.\n *\n * 발신자가 **한 명뿐이면 표기하지 않는다** — 혼자 쓰는 대화(대다수)에 노이즈와 토큰을 늘리지 않고,\n * 기존 동작과 같은 프롬프트를 유지한다.\n *\n * ── system 행 ───────────────────────────────────────────────────────────────\n * 캔버스 직접 편집 기록(`User directly edited the board: …`)은 원래 에이전트에게 알리려고 남긴 것인데,\n * 클라이언트가 role='system' 을 걸러내 사장돼 있었다. LLMMessage 에 system 역할이 없으므로\n * `[시스템]` 표기를 붙인 user 메시지로 넘긴다 — 사람이 아니므로 발신자 수 계산에는 넣지 않는다.\n *\n * ── 편집·재생성으로 접은 구간 ───────────────────────────────────────────────\n * 클라이언트의 메시지 편집은 화면에서만 접고 **서버 이력은 유지**한다(board-ai-chat.editUserMessage).\n * 서버 정본으로 바꾸면 접은 말이 에이전트 문맥에 되살아난다. 그래서 호출자가 `truncateAfterMessageId`\n * (마지막으로 남긴 메시지 id)를 주면 그 뒤 행을 버린다. 단 `keepMessageIds` 로 지정한 행(방금 저장한\n * 새 사용자 메시지)은 시간상 뒤에 있어도 항상 남긴다.\n *\n * 알려진 한계(현행 동작과 동일): 공유 세션에서 접은 구간에 **다른 사람의 발언**이 있으면 그것도\n * 함께 제외된다. 오늘 클라이언트가 보내는 이력도 같으므로 이 단계에서는 동등성을 유지하고,\n * \"내 발언만 접기\"는 별도 개선으로 다룬다.\n */\nimport type { LLMMessage } from '../types.js'\nimport { pendingForSummary, type StoredSummary } from './history-summary.js'\n\n/** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */\nexport interface HistoryRow {\n id?: string\n role: string\n /** 멘션 마커(#token{refid:N})는 호출자가 미리 제거해 넘긴다 — 모델 주의 분산·토큰 낭비 방지. */\n content: string\n /** 발신자 식별자(서버 내부용 — 발신자 수 계산에만 쓰고 프롬프트에 넣지 않는다). */\n senderId?: string\n /** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */\n senderName?: string\n}\n\nexport interface BuildLlmHistoryOptions {\n /** 이 메시지까지만 유지하고 이후는 버린다(편집·재생성으로 접은 구간). 미지정이면 전부 사용. */\n truncateAfterMessageId?: string\n /** 잘라내기와 무관하게 항상 유지할 메시지 id(방금 저장한 새 사용자 메시지 등). */\n keepMessageIds?: string[]\n /** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */\n labelSenders?: boolean\n /**\n * 모델에 넘길 **최근 메시지 수 상한**. 초과분(오래된 앞부분)은 버리고 생략 표시를 남긴다.\n *\n * 왜 필요한가: 협의는 며칠에 걸친다. 상한이 없으면 매 턴 전체 이력을 보내 프롬프트가 무한히\n * 자라고(비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 미지정이면 전부 보낸다(기존 동작).\n *\n * **생략은 반드시 알린다** — 조용히 버리면 모델은 남은 첫 줄을 대화의 시작으로 오해한다.\n * 이건 상한일 뿐 요약이 아니다. 버린 내용을 요약해 실어 보내는 것은 다음 단계다(ChatSession.lastSummary).\n */\n maxTurns?: number\n /**\n * 상한으로 밀려난 앞부분을 대신할 **요약**(세션에 저장된 것).\n *\n * 있으면 \"N개 생략\" 대신 요약을 첫 줄로 싣는다 — 며칠 이어지는 협의에서 앞에서 합의한 것이\n * 없던 일이 되지 않게. 요약이 밀려난 구간을 **전부 덮지 못하면** 덮지 못한 개수도 함께 밝힌다\n * (요약이 최신이 아닐 수 있다는 사실을 숨기지 않는다).\n */\n summary?: StoredSummary\n}\n\n/** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */\nconst SYSTEM_LABEL = '시스템'\n/** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */\nconst UNKNOWN_LABEL = '참여자'\n/** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */\nconst OMITTED_LABEL = '시스템'\n\n/**\n * 저장된 이력 → LLM 대화.\n *\n * 입력은 **시간 오름차순**을 가정한다(호출자가 createdAt ASC 로 조회). 정렬을 다시 하지 않는다 —\n * 같은 시각의 순서까지 보장하려면 조회 쪽에서 결정해야 하고, 여기서 추측하면 두 곳이 어긋난다.\n */\nexport function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOptions = {}): LLMMessage[] {\n const keep = new Set(options.keepMessageIds ?? [])\n const truncated = truncate(rows, options.truncateAfterMessageId, keep)\n /* 상한 — 최근 것부터 남긴다. 오래된 말보다 최근 상황이 답을 좌우한다. */\n const limit = options.maxTurns && options.maxTurns > 0 ? options.maxTurns : 0\n const omitted = limit && truncated.length > limit ? truncated.length - limit : 0\n const kept = omitted ? truncated.slice(-limit) : truncated\n\n const label =\n options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1\n\n const out: LLMMessage[] = []\n if (omitted) {\n const dropped = truncated.slice(0, truncated.length - kept.length)\n const summaryText = (options.summary?.text ?? '').trim()\n if (summaryText) {\n /* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.\n * 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */\n const uncovered = pendingForSummary(dropped, options.summary).length\n const tail = uncovered\n ? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`\n : ''\n out.push({\n role: 'user',\n content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\\n${summaryText}`\n })\n } else {\n /* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */\n out.push({\n role: 'user',\n content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`\n })\n }\n }\n for (const row of kept) {\n const content = (row.content ?? '').trim()\n if (!content) continue // 빈 본문(pending 자리 등)은 모델에 넘길 것이 없다\n if (row.role === 'assistant') {\n out.push({ role: 'assistant', content })\n continue\n }\n if (row.role === 'system') {\n out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` })\n continue\n }\n out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content })\n }\n return out\n}\n\n/** 사람이 보낸 줄인가 — 발신자 수 계산 대상. system 기록은 사람이 아니다. */\nfunction isHuman(row: HistoryRow): boolean {\n return row.role !== 'assistant' && row.role !== 'system'\n}\n\nfunction speaker(row: HistoryRow): string {\n const name = (row.senderName ?? '').trim()\n return name || UNKNOWN_LABEL\n}\n\n/**\n * 접은 구간 제거. `truncateAfterMessageId` 가 없거나 이력에서 못 찾으면 **전부 유지**한다\n * (조용히 다 버리는 것보다 다 보내는 쪽이 안전하다 — 문맥 상실은 답을 망친다).\n */\nfunction truncate(rows: HistoryRow[], afterId: string | undefined, keep: Set<string>): HistoryRow[] {\n if (!afterId) return rows\n const idx = rows.findIndex(r => r.id === afterId)\n if (idx < 0) return rows\n const head = rows.slice(0, idx + 1)\n const tail = rows.slice(idx + 1).filter(r => r.id && keep.has(r.id))\n return [...head, ...tail]\n}\n"]}
@@ -44,6 +44,14 @@ export declare class ChatSession {
44
44
  /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */
45
45
  lastMessagePreview?: string;
46
46
  lastSummary?: string;
47
+ /**
48
+ * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.
49
+ *
50
+ * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을
51
+ * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로
52
+ * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.
53
+ */
54
+ summaryUpToMessageId?: string;
47
55
  /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */
48
56
  aiClientId?: string;
49
57
  createdAt?: Date;
@@ -114,6 +114,11 @@ tslib_1.__decorate([
114
114
  (0, type_graphql_1.Field)({ nullable: true, description: 'Compressed summary of older messages (for token saving).' }),
115
115
  tslib_1.__metadata("design:type", String)
116
116
  ], ChatSession.prototype, "lastSummary", void 0);
117
+ tslib_1.__decorate([
118
+ (0, typeorm_1.Column)({ nullable: true }),
119
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' }),
120
+ tslib_1.__metadata("design:type", String)
121
+ ], ChatSession.prototype, "summaryUpToMessageId", void 0);
117
122
  tslib_1.__decorate([
118
123
  (0, typeorm_1.Column)({ nullable: true }),
119
124
  (0, type_graphql_1.Field)({ nullable: true }),
@@ -1 +1 @@
1
- {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\nimport { PatchEntry } from '../patch-entry/patch-entry.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'AI 협력 세션 — Board 와 결합. 한 보드에 여러 세션 가능 (thread / 사용자별 / 컨텍스트별 등 미래 확장).'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' (authoring a board) | 'space' (a site and the twins running there) | 'instance' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n @OneToMany(type => PatchEntry, patch => patch.session)\n patches?: PatchEntry[]\n}\n"]}
1
+ {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\nimport { PatchEntry } from '../patch-entry/patch-entry.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'AI 협력 세션 — Board 와 결합. 한 보드에 여러 세션 가능 (thread / 사용자별 / 컨텍스트별 등 미래 확장).'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' (authoring a board) | 'space' (a site and the twins running there) | 'instance' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n @OneToMany(type => PatchEntry, patch => patch.session)\n patches?: PatchEntry[]\n}\n"]}
@@ -125,6 +125,22 @@ export interface ToolUsage {
125
125
  result: any;
126
126
  /** read tool / write tool 분류 */
127
127
  kind: 'read' | 'write' | 'unknown';
128
+ /**
129
+ * 몇 번째 LLM 턴에서 불렀는가(0부터). 판단 과정을 단계로 읽을 수 있게 한다 —
130
+ * "조회 → 거절 → 정정 → 제안" 이 한 턴 안의 일인지 여러 턴에 걸친 일인지가 진단의 절반이다.
131
+ */
132
+ iter?: number;
133
+ /**
134
+ * 이 호출이 **어떻게 끝났는가** — 화면이 배지로 보여줄 판정.
135
+ * ok 조회·실행이 정상 완료
136
+ * rejected 도구가 거절(인자 누락·대상 없음 등). 무엇이 빠졌는지는 result 에 있다.
137
+ * queued 보드 편집처럼 클라이언트에서 적용될 예정
138
+ * proposed 조치 제안이 만들어졌다(실행 아님 — 사용자가 버튼을 누른다)
139
+ * folded 같은 효과의 제안이 이미 있어 접혔다. **조용히 버리지 않는다** — 접힌 사실이 보여야
140
+ * "왜 카드가 하나뿐인가" 를 추적할 수 있다.
141
+ * error 알 수 없는 도구·검증 실패
142
+ */
143
+ outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error';
128
144
  }
129
145
  export interface ChatResponse {
130
146
  /** 사용자에게 보여줄 텍스트 응답 */
@@ -140,6 +156,13 @@ export interface ChatResponse {
140
156
  followUp?: string;
141
157
  /** AI 가 응답 만드는 과정에서 호출한 도구들 (시간순). UI fold-able 박스용. */
142
158
  toolUsages?: ToolUsage[];
159
+ /**
160
+ * **조치 제안** — 도구가 실행하지 않고 제안만 한 것들. 실행은 사용자가 버튼으로 한다.
161
+ *
162
+ * 규약: 도구 결과에 `proposed: true` 가 있으면 제안이다(도메인 무관 — board-ai 는 내용을 해석하지
163
+ * 않고 그대로 전달한다). 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다.
164
+ */
165
+ proposals?: any[];
143
166
  /**
144
167
  * 접지 경고 — 답이 언급했으나 모델이 받은 근거(프롬프트·보드 문맥·이력·도구 결과)에 없는 식별자.
145
168
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../server/service/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Board AI — 자연어 채팅 인터페이스 타입 정의.\n *\n * 핵심 개념:\n * - 단일 진입점 `chat()`. 의도 분류(생성/편집/스타일/질문)는 AI 의 일.\n * - 출력은 항상 `ChatResponse`. 보드 변경이 있으면 `patch` 동봉.\n * - patch 의 op 는 직교 4 종 (add / remove / modify / replace).\n * style/move 등은 modify.patch 안에 흡수.\n */\nimport type { BoardComponent, BoardModel } from '@things-factory/board-import'\n\nexport interface LLMMessage {\n role: 'user' | 'assistant'\n content: string\n}\n\nexport interface ChatOptions {\n /**\n * 이 대화에 노출할 **외부 도구 카테고리** 화이트리스트(registerToolCategory 이름).\n * 미지정 = 등록된 전부(현행 동작). 성격이 다른 대화면(보드 저작 도크 · 운영 도크)이\n * 서로의 능력을 갖지 않게 하는 경계 — 정의뿐 아니라 실행 판정에도 적용된다.\n */\n toolCategories?: string[]\n /**\n * 코어 보드 편집 도구(addComponent · 스타일링 · 씬 조작) 노출 여부. 기본 true.\n * 운영 대화처럼 보드를 고칠 이유가 없는 면에서는 false 로 꺼서, 되돌릴 수 없는 행동과\n * 되돌릴 수 있는 편집이 한 대화에 섞이지 않게 한다.\n */\n boardTools?: boolean\n /**\n * **첫 턴에 도구 호출을 강제**한다. 라이브 상태를 다루는 대화면(운영 협의)에서 켠다.\n *\n * 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 나온 답이다). 그건 도구를 아예 부르지 않고 답한\n * 경우여서 사후 검사로 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다. 켜고 끄는 판단은 대화면이 한다 — 서버가 질문 내용을 눈치로 분류하지 않는다.\n */\n requireGroundingTools?: boolean\n /**\n * 호스트(대화면)가 실어 보내는 **문맥** — 도구가 ctx.state.host 로 읽는다.\n *\n * 왜 필요한가: 대화면마다 \"지금 무엇을 보고 있는지\" 가 다르다(어느 공간·어느 인스턴스·어느 보드).\n * 이것 없이는 도구가 대상을 알 수 없어 모델이 사용자에게 식별자를 되묻는다 — 제품으로 성립하지 않는다.\n * 보드 대화는 보드 모델에 각인해 우회할 수 있었지만 보드가 없는 대화면(공간 협의)은 통로가 없었다.\n *\n * 신뢰 경계: 이 값은 **클라이언트가 보낸 것**이다. 도메인·사용자 같은 권한 판정에 쓰지 말 것\n * (그건 서버가 세션에서 얻는 ctx.state.domain/user 가 정본). 여기 값은 \"무엇을 보고 있는지\" 범위\n * 힌트이며, 도구는 그 범위가 호출자 테넌트 소속인지 반드시 검증한다.\n */\n hostContext?: any\n /** board-import 의 registry scope — 등록된 ImportRule 에서 type/category 후보 추출 */\n scopes?: string[]\n /** 사용 가능한 도메인 type (override). 미지정 시 scopes 에서 자동 추출 */\n knownTypes?: string[]\n /** 사용 가능한 카테고리 (override). 미지정 시 scopes 에서 자동 추출 */\n categories?: string[]\n /**\n * 컴포넌트 type 별 유효 속성 스킴.\n * LLM 이 정확한 컴포넌트 생성을 하도록 type 의 description, group, default model 키 전달.\n *\n * 예:\n * [{ type: 'rect', description: 'Rectangle', group: 'shape',\n * properties: { fillStyle: '#fff', strokeStyle: '#000' } }, ...]\n */\n componentSchemas?: ComponentSchema[]\n /**\n * 사용자가 모델러에서 현재 선택한 컴포넌트의 `refid` 목록.\n *\n * refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.\n * id (데이터 바인딩 이름, unique 아님) 와는 다른 개념 — 선택은 항상 refid 로 표현.\n */\n selectedRefids?: number[]\n /**\n * popup 에서 사용자가 명시 선택한 #mention 매핑 — {token, refid}.\n * server 가 mention 해석 시 이 매핑을 우선 사용. 본문엔 #token 만 남고\n * refid 는 사용자에게 노출되지 않음 (LLM mention map 에만 첨부).\n */\n mentionPicks?: Array<{ token: string; refid: number }>\n /** LLM 모델 override */\n model?: string\n /** LLM 옵션 */\n temperature?: number\n maxTokens?: number\n /**\n * Tool 카테고리 (read/external) builder 가 사용할 컨텍스트.\n *\n * board-ai 자체는 ResolverContext / typeorm 을 직접 알지 않지만, 외부 도메인 패키지가\n * 등록한 tool (예: board-import 의 importBoardAsync) 은 server-side 의존성 (도메인,\n * 사용자, 트랜잭션) 이 필요할 수 있다. board-ai-resolver 가 ResolverContext.state 등을\n * 이 필드로 전달하면, dispatch 시 closure 로 builder 의 ctx.state 에 주입.\n *\n * 미지정이면 외부 builder 는 ctx.state === undefined — 빈 응답이나 에러를 반환할 책임.\n */\n toolCallContext?: any\n}\n\nexport interface ComponentSchema {\n type: string\n description?: string\n group?: string\n /**\n * 좌표/크기/경로 관련 키들 — type 마다 다름.\n * 예) ['left', 'top', 'width', 'height'] vs ['cx', 'cy', 'radius'] vs ['points']\n * LLM 이 type 별 정확한 좌표 키를 사용하도록 명시.\n */\n geometryKeys?: string[]\n /**\n * 좌표 외 type 특화 속성 (style, value, min/max 등).\n * default 값 또는 keys 만 — LLM 의 token 절감을 위해 호출자가 적절히 압축.\n */\n properties?: Record<string, any>\n}\n\n/**\n * AI 가 응답을 만드는 동안 호출한 도구의 시간순 trace.\n *\n * UX 목적 — 사용자가 \"왜 이렇게 답했지\" 의문 가질 때 fold-able 박스에서 확인.\n * 디버그용 + 신뢰도 향상.\n */\nexport interface ToolUsage {\n /** 도구 이름 (예: 'getSelection', 'modifyBoard', 'addComponent') */\n name: string\n /** 호출 인자 JSON */\n arguments: any\n /** 도구 결과 — read tool 은 실제 값 (truncated 가능), write tool 은 queued 마커 */\n result: any\n /** read tool / write tool 분류 */\n kind: 'read' | 'write' | 'unknown'\n}\n\nexport interface ChatResponse {\n /** 사용자에게 보여줄 텍스트 응답 */\n reply: string\n /** 보드 변경이 있으면 patch */\n patch?: BoardEditPatch\n /**\n * Scene 조작 action (ephemeral — 모델 변경 없음 / undo 영향 없음).\n * 시간순 시퀀스로 호스트가 things-scene API 직접 실행.\n */\n actions?: BoardActionOp[]\n /** 모호한 입력 시 명확화 질문 (patch 없음) */\n followUp?: string\n /** AI 가 응답 만드는 과정에서 호출한 도구들 (시간순). UI fold-able 박스용. */\n toolUsages?: ToolUsage[]\n /**\n * 접지 경고 — 답이 언급했으나 모델이 받은 근거(프롬프트·보드 문맥·이력·도구 결과)에 없는 식별자.\n *\n * 있으면 그 대상은 **만들어 낸 것일 수 있다**. 답을 막거나 고치지 않고 사용자에게 표시한다 —\n * 판정 규칙·사정거리는 ./grounding 참조. 비면(undefined) 접지 정상.\n */\n groundingWarnings?: string[]\n}\n\nexport interface BoardEditPatch {\n ops: BoardEditOp[]\n /** 사용자 검수용 1-2 문장 요약 */\n summary: string\n /** 0..1 신뢰도 */\n confidence: number\n}\n\n/**\n * 보드 변경 op.\n *\n * 식별자 정책 — 기존 컴포넌트 타깃팅은 `refid` (number) 만 사용.\n * things-scene 의 모든 컴포넌트는 `refid` 를 자동 발급받는다 (universal).\n * `model.id` 는 optional metadata 일 뿐 — 항상 존재하지 않으므로 targeting 에는\n * 부적합. 별개 개념이므로 BoardEditOp / tool 인자에서도 별개 식별자로 분리하지\n * 않고 refid 단일 채널로 일원화.\n *\n * 계층 — 보드는 **최상위 부모** (things-scene 의 model-layer) 이고 그 자체로 자기\n * 속성을 갖는다 (fillStyle, width, height, fitMode, translate, scale, sky, skyColor,\n * exposure, hemi/dirLight 계열, camera 계열). 자식 컴포넌트와 별개. 보드 속성 변경은\n * `modifyBoard`, 자식 변경은 `modify` (refid 기반).\n *\n * 주의 — `name` 은 보드 *엔티티* (DB row) 의 컬럼이지 scene MODEL 의 필드가 아니다.\n * things-scene 의 model-layer 가 인식 안 함. modifyBoard 로 name 을 보내면 model\n * JSON 에 죽은 필드로 박힐 뿐 렌더링/동작에 영향 없음. 보드 라벨 변경은 GraphQL\n * boardPatch (BoardPatch input 의 name 필드) 영역.\n *\n * style 변경 / 이동 / 크기 변경 등 자식 컴포넌트의 변경은 모두 `modify.patch` 안에\n * 흡수. 보드 root 속성 변경은 `modifyBoard.patch` 로.\n */\n/**\n * Phase 2 — Scene 조작 op 들. 모델 차원 (좌표 / 부모-자식 관계 / z-order) 변경이지만\n * things-scene 의 자체 API (align/distribute/group/ungroup/zorder) 가 일관된 결과를\n * 보장하므로 호스트가 직접 호출. 모델 차원 시뮬레이션 (apply-patch) 은 단순화 —\n * scene 호출 결과가 정본.\n */\nexport type AlignDirection =\n | 'left'\n | 'right'\n | 'center'\n | 'top'\n | 'middle'\n | 'bottom'\n\nexport type DistributeAxis = 'horizontal' | 'vertical'\n\nexport type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'\n\n/**\n * Sugar layout — `arrange` op 의 layout 종류.\n *\n * 의도: align/distribute 위에 얹는 high-level 의도 표현. AI 가 \"3x2 그리드로\",\n * \"한 줄로\", \"세로로 일렬\" 같은 자연어를 픽셀 노가다 없이 단일 op 로 표현.\n *\n * left/top 만 변경 — width/height 는 유지. AI 가 사이즈도 바꾸려면 별도 modify.\n *\n * 위치 계산은 호스트 (things-scene 측) 가 담당 — 각 컴포넌트의 현재 width/height 를\n * 정확히 알아야 하므로 model 차원 시뮬레이션은 SCENE_ONLY (apply-patch noop).\n */\nexport type ArrangeLayout =\n | { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }\n | {\n type: 'row'\n gap?: number\n anchor?: { left: number; top: number }\n align?: 'start' | 'center' | 'end'\n }\n | {\n type: 'column'\n gap?: number\n anchor?: { left: number; top: number }\n align?: 'start' | 'center' | 'end'\n }\n\n/**\n * C-1 — Scene 조작 action 들 (ephemeral). 모델 변경 X, 따라서 BoardEditOp 와 별개\n * 채널 (board-action-execute 이벤트). undo 히스토리 미영향, dirty flag 미영향.\n *\n * 종류:\n * - selectComponents: scene.selected 직접 set (사용자 선택 변경)\n * - centerToComponent: 특정 컴포넌트로 view 이동\n * - fitToView: 보드 전체가 보이도록 fit\n * - setSceneMode: edit / view 모드 전환\n */\nexport type BoardActionOp =\n | { action: 'selectComponents'; refids: number[] }\n | { action: 'centerToComponent'; refid: number; animated?: boolean }\n | { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }\n | { action: 'setSceneMode'; mode: 'edit' | 'view' }\n /** 다중 컴포넌트 outline highlight — search/finder UX 의 \"이게 모두 매칭이다\" 시각화.\n * things-scene 의 highlightSearchResults API 위임 (2D/3D 모두 지원). */\n | { action: 'highlightComponents'; refids: number[] }\n\nexport type BoardEditOp =\n | { op: 'add'; component: BoardComponent }\n | { op: 'remove'; refid: number }\n | { op: 'modify'; refid: number; patch: Partial<BoardComponent> }\n | { op: 'modifyBoard'; patch: Partial<BoardModel> }\n | { op: 'replace'; board: BoardModel }\n | { op: 'align'; refids: number[]; direction: AlignDirection }\n | { op: 'distribute'; refids: number[]; axis: DistributeAxis }\n | { op: 'group'; refids: number[] }\n | { op: 'ungroup'; refid: number }\n | { op: 'zorder'; refid: number; direction: ZorderDirection }\n | { op: 'arrange'; refids: number[]; layout: ArrangeLayout }\n\n/**\n * BoardAIAssistant — 자연어 채팅으로 보드를 다루는 단일 인터페이스.\n *\n * 사용:\n * const ai = new DefaultBoardAIAssistant(baseClient, { scopes: ['fmsim'] })\n * const r = await ai.chat([{ role: 'user', content: 'AGV 3대 추가' }], currentBoard)\n * if (r.patch) currentBoard = applyBoardEditPatch(currentBoard, r.patch)\n */\nexport interface BoardAIAssistant {\n readonly id: string\n chat(\n messages: LLMMessage[],\n currentBoard: BoardModel | undefined,\n options?: ChatOptions\n ): Promise<ChatResponse>\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../server/service/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Board AI — 자연어 채팅 인터페이스 타입 정의.\n *\n * 핵심 개념:\n * - 단일 진입점 `chat()`. 의도 분류(생성/편집/스타일/질문)는 AI 의 일.\n * - 출력은 항상 `ChatResponse`. 보드 변경이 있으면 `patch` 동봉.\n * - patch 의 op 는 직교 4 종 (add / remove / modify / replace).\n * style/move 등은 modify.patch 안에 흡수.\n */\nimport type { BoardComponent, BoardModel } from '@things-factory/board-import'\n\nexport interface LLMMessage {\n role: 'user' | 'assistant'\n content: string\n}\n\nexport interface ChatOptions {\n /**\n * 이 대화에 노출할 **외부 도구 카테고리** 화이트리스트(registerToolCategory 이름).\n * 미지정 = 등록된 전부(현행 동작). 성격이 다른 대화면(보드 저작 도크 · 운영 도크)이\n * 서로의 능력을 갖지 않게 하는 경계 — 정의뿐 아니라 실행 판정에도 적용된다.\n */\n toolCategories?: string[]\n /**\n * 코어 보드 편집 도구(addComponent · 스타일링 · 씬 조작) 노출 여부. 기본 true.\n * 운영 대화처럼 보드를 고칠 이유가 없는 면에서는 false 로 꺼서, 되돌릴 수 없는 행동과\n * 되돌릴 수 있는 편집이 한 대화에 섞이지 않게 한다.\n */\n boardTools?: boolean\n /**\n * **첫 턴에 도구 호출을 강제**한다. 라이브 상태를 다루는 대화면(운영 협의)에서 켠다.\n *\n * 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 나온 답이다). 그건 도구를 아예 부르지 않고 답한\n * 경우여서 사후 검사로 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다. 켜고 끄는 판단은 대화면이 한다 — 서버가 질문 내용을 눈치로 분류하지 않는다.\n */\n requireGroundingTools?: boolean\n /**\n * 호스트(대화면)가 실어 보내는 **문맥** — 도구가 ctx.state.host 로 읽는다.\n *\n * 왜 필요한가: 대화면마다 \"지금 무엇을 보고 있는지\" 가 다르다(어느 공간·어느 인스턴스·어느 보드).\n * 이것 없이는 도구가 대상을 알 수 없어 모델이 사용자에게 식별자를 되묻는다 — 제품으로 성립하지 않는다.\n * 보드 대화는 보드 모델에 각인해 우회할 수 있었지만 보드가 없는 대화면(공간 협의)은 통로가 없었다.\n *\n * 신뢰 경계: 이 값은 **클라이언트가 보낸 것**이다. 도메인·사용자 같은 권한 판정에 쓰지 말 것\n * (그건 서버가 세션에서 얻는 ctx.state.domain/user 가 정본). 여기 값은 \"무엇을 보고 있는지\" 범위\n * 힌트이며, 도구는 그 범위가 호출자 테넌트 소속인지 반드시 검증한다.\n */\n hostContext?: any\n /** board-import 의 registry scope — 등록된 ImportRule 에서 type/category 후보 추출 */\n scopes?: string[]\n /** 사용 가능한 도메인 type (override). 미지정 시 scopes 에서 자동 추출 */\n knownTypes?: string[]\n /** 사용 가능한 카테고리 (override). 미지정 시 scopes 에서 자동 추출 */\n categories?: string[]\n /**\n * 컴포넌트 type 별 유효 속성 스킴.\n * LLM 이 정확한 컴포넌트 생성을 하도록 type 의 description, group, default model 키 전달.\n *\n * 예:\n * [{ type: 'rect', description: 'Rectangle', group: 'shape',\n * properties: { fillStyle: '#fff', strokeStyle: '#000' } }, ...]\n */\n componentSchemas?: ComponentSchema[]\n /**\n * 사용자가 모델러에서 현재 선택한 컴포넌트의 `refid` 목록.\n *\n * refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.\n * id (데이터 바인딩 이름, unique 아님) 와는 다른 개념 — 선택은 항상 refid 로 표현.\n */\n selectedRefids?: number[]\n /**\n * popup 에서 사용자가 명시 선택한 #mention 매핑 — {token, refid}.\n * server 가 mention 해석 시 이 매핑을 우선 사용. 본문엔 #token 만 남고\n * refid 는 사용자에게 노출되지 않음 (LLM mention map 에만 첨부).\n */\n mentionPicks?: Array<{ token: string; refid: number }>\n /** LLM 모델 override */\n model?: string\n /** LLM 옵션 */\n temperature?: number\n maxTokens?: number\n /**\n * Tool 카테고리 (read/external) builder 가 사용할 컨텍스트.\n *\n * board-ai 자체는 ResolverContext / typeorm 을 직접 알지 않지만, 외부 도메인 패키지가\n * 등록한 tool (예: board-import 의 importBoardAsync) 은 server-side 의존성 (도메인,\n * 사용자, 트랜잭션) 이 필요할 수 있다. board-ai-resolver 가 ResolverContext.state 등을\n * 이 필드로 전달하면, dispatch 시 closure 로 builder 의 ctx.state 에 주입.\n *\n * 미지정이면 외부 builder 는 ctx.state === undefined — 빈 응답이나 에러를 반환할 책임.\n */\n toolCallContext?: any\n}\n\nexport interface ComponentSchema {\n type: string\n description?: string\n group?: string\n /**\n * 좌표/크기/경로 관련 키들 — type 마다 다름.\n * 예) ['left', 'top', 'width', 'height'] vs ['cx', 'cy', 'radius'] vs ['points']\n * LLM 이 type 별 정확한 좌표 키를 사용하도록 명시.\n */\n geometryKeys?: string[]\n /**\n * 좌표 외 type 특화 속성 (style, value, min/max 등).\n * default 값 또는 keys 만 — LLM 의 token 절감을 위해 호출자가 적절히 압축.\n */\n properties?: Record<string, any>\n}\n\n/**\n * AI 가 응답을 만드는 동안 호출한 도구의 시간순 trace.\n *\n * UX 목적 — 사용자가 \"왜 이렇게 답했지\" 의문 가질 때 fold-able 박스에서 확인.\n * 디버그용 + 신뢰도 향상.\n */\nexport interface ToolUsage {\n /** 도구 이름 (예: 'getSelection', 'modifyBoard', 'addComponent') */\n name: string\n /** 호출 인자 JSON */\n arguments: any\n /** 도구 결과 — read tool 은 실제 값 (truncated 가능), write tool 은 queued 마커 */\n result: any\n /** read tool / write tool 분류 */\n kind: 'read' | 'write' | 'unknown'\n /**\n * 몇 번째 LLM 턴에서 불렀는가(0부터). 판단 과정을 단계로 읽을 수 있게 한다 —\n * \"조회 → 거절 → 정정 → 제안\" 이 한 턴 안의 일인지 여러 턴에 걸친 일인지가 진단의 절반이다.\n */\n iter?: number\n /**\n * 이 호출이 **어떻게 끝났는가** — 화면이 배지로 보여줄 판정.\n * ok 조회·실행이 정상 완료\n * rejected 도구가 거절(인자 누락·대상 없음 등). 무엇이 빠졌는지는 result 에 있다.\n * queued 보드 편집처럼 클라이언트에서 적용될 예정\n * proposed 조치 제안이 만들어졌다(실행 아님 — 사용자가 버튼을 누른다)\n * folded 같은 효과의 제안이 이미 있어 접혔다. **조용히 버리지 않는다** — 접힌 사실이 보여야\n * \"왜 카드가 하나뿐인가\" 를 추적할 수 있다.\n * error 알 수 없는 도구·검증 실패\n */\n outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error'\n}\n\nexport interface ChatResponse {\n /** 사용자에게 보여줄 텍스트 응답 */\n reply: string\n /** 보드 변경이 있으면 patch */\n patch?: BoardEditPatch\n /**\n * Scene 조작 action (ephemeral — 모델 변경 없음 / undo 영향 없음).\n * 시간순 시퀀스로 호스트가 things-scene API 직접 실행.\n */\n actions?: BoardActionOp[]\n /** 모호한 입력 시 명확화 질문 (patch 없음) */\n followUp?: string\n /** AI 가 응답 만드는 과정에서 호출한 도구들 (시간순). UI fold-able 박스용. */\n toolUsages?: ToolUsage[]\n /**\n * **조치 제안** — 도구가 실행하지 않고 제안만 한 것들. 실행은 사용자가 버튼으로 한다.\n *\n * 규약: 도구 결과에 `proposed: true` 가 있으면 제안이다(도메인 무관 — board-ai 는 내용을 해석하지\n * 않고 그대로 전달한다). 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다.\n */\n proposals?: any[]\n /**\n * 접지 경고 — 답이 언급했으나 모델이 받은 근거(프롬프트·보드 문맥·이력·도구 결과)에 없는 식별자.\n *\n * 있으면 그 대상은 **만들어 낸 것일 수 있다**. 답을 막거나 고치지 않고 사용자에게 표시한다 —\n * 판정 규칙·사정거리는 ./grounding 참조. 비면(undefined) 접지 정상.\n */\n groundingWarnings?: string[]\n}\n\nexport interface BoardEditPatch {\n ops: BoardEditOp[]\n /** 사용자 검수용 1-2 문장 요약 */\n summary: string\n /** 0..1 신뢰도 */\n confidence: number\n}\n\n/**\n * 보드 변경 op.\n *\n * 식별자 정책 — 기존 컴포넌트 타깃팅은 `refid` (number) 만 사용.\n * things-scene 의 모든 컴포넌트는 `refid` 를 자동 발급받는다 (universal).\n * `model.id` 는 optional metadata 일 뿐 — 항상 존재하지 않으므로 targeting 에는\n * 부적합. 별개 개념이므로 BoardEditOp / tool 인자에서도 별개 식별자로 분리하지\n * 않고 refid 단일 채널로 일원화.\n *\n * 계층 — 보드는 **최상위 부모** (things-scene 의 model-layer) 이고 그 자체로 자기\n * 속성을 갖는다 (fillStyle, width, height, fitMode, translate, scale, sky, skyColor,\n * exposure, hemi/dirLight 계열, camera 계열). 자식 컴포넌트와 별개. 보드 속성 변경은\n * `modifyBoard`, 자식 변경은 `modify` (refid 기반).\n *\n * 주의 — `name` 은 보드 *엔티티* (DB row) 의 컬럼이지 scene MODEL 의 필드가 아니다.\n * things-scene 의 model-layer 가 인식 안 함. modifyBoard 로 name 을 보내면 model\n * JSON 에 죽은 필드로 박힐 뿐 렌더링/동작에 영향 없음. 보드 라벨 변경은 GraphQL\n * boardPatch (BoardPatch input 의 name 필드) 영역.\n *\n * style 변경 / 이동 / 크기 변경 등 자식 컴포넌트의 변경은 모두 `modify.patch` 안에\n * 흡수. 보드 root 속성 변경은 `modifyBoard.patch` 로.\n */\n/**\n * Phase 2 — Scene 조작 op 들. 모델 차원 (좌표 / 부모-자식 관계 / z-order) 변경이지만\n * things-scene 의 자체 API (align/distribute/group/ungroup/zorder) 가 일관된 결과를\n * 보장하므로 호스트가 직접 호출. 모델 차원 시뮬레이션 (apply-patch) 은 단순화 —\n * scene 호출 결과가 정본.\n */\nexport type AlignDirection =\n | 'left'\n | 'right'\n | 'center'\n | 'top'\n | 'middle'\n | 'bottom'\n\nexport type DistributeAxis = 'horizontal' | 'vertical'\n\nexport type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'\n\n/**\n * Sugar layout — `arrange` op 의 layout 종류.\n *\n * 의도: align/distribute 위에 얹는 high-level 의도 표현. AI 가 \"3x2 그리드로\",\n * \"한 줄로\", \"세로로 일렬\" 같은 자연어를 픽셀 노가다 없이 단일 op 로 표현.\n *\n * left/top 만 변경 — width/height 는 유지. AI 가 사이즈도 바꾸려면 별도 modify.\n *\n * 위치 계산은 호스트 (things-scene 측) 가 담당 — 각 컴포넌트의 현재 width/height 를\n * 정확히 알아야 하므로 model 차원 시뮬레이션은 SCENE_ONLY (apply-patch noop).\n */\nexport type ArrangeLayout =\n | { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }\n | {\n type: 'row'\n gap?: number\n anchor?: { left: number; top: number }\n align?: 'start' | 'center' | 'end'\n }\n | {\n type: 'column'\n gap?: number\n anchor?: { left: number; top: number }\n align?: 'start' | 'center' | 'end'\n }\n\n/**\n * C-1 — Scene 조작 action 들 (ephemeral). 모델 변경 X, 따라서 BoardEditOp 와 별개\n * 채널 (board-action-execute 이벤트). undo 히스토리 미영향, dirty flag 미영향.\n *\n * 종류:\n * - selectComponents: scene.selected 직접 set (사용자 선택 변경)\n * - centerToComponent: 특정 컴포넌트로 view 이동\n * - fitToView: 보드 전체가 보이도록 fit\n * - setSceneMode: edit / view 모드 전환\n */\nexport type BoardActionOp =\n | { action: 'selectComponents'; refids: number[] }\n | { action: 'centerToComponent'; refid: number; animated?: boolean }\n | { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }\n | { action: 'setSceneMode'; mode: 'edit' | 'view' }\n /** 다중 컴포넌트 outline highlight — search/finder UX 의 \"이게 모두 매칭이다\" 시각화.\n * things-scene 의 highlightSearchResults API 위임 (2D/3D 모두 지원). */\n | { action: 'highlightComponents'; refids: number[] }\n\nexport type BoardEditOp =\n | { op: 'add'; component: BoardComponent }\n | { op: 'remove'; refid: number }\n | { op: 'modify'; refid: number; patch: Partial<BoardComponent> }\n | { op: 'modifyBoard'; patch: Partial<BoardModel> }\n | { op: 'replace'; board: BoardModel }\n | { op: 'align'; refids: number[]; direction: AlignDirection }\n | { op: 'distribute'; refids: number[]; axis: DistributeAxis }\n | { op: 'group'; refids: number[] }\n | { op: 'ungroup'; refid: number }\n | { op: 'zorder'; refid: number; direction: ZorderDirection }\n | { op: 'arrange'; refids: number[]; layout: ArrangeLayout }\n\n/**\n * BoardAIAssistant — 자연어 채팅으로 보드를 다루는 단일 인터페이스.\n *\n * 사용:\n * const ai = new DefaultBoardAIAssistant(baseClient, { scopes: ['fmsim'] })\n * const r = await ai.chat([{ role: 'user', content: 'AGV 3대 추가' }], currentBoard)\n * if (r.patch) currentBoard = applyBoardEditPatch(currentBoard, r.patch)\n */\nexport interface BoardAIAssistant {\n readonly id: string\n chat(\n messages: LLMMessage[],\n currentBoard: BoardModel | undefined,\n options?: ChatOptions\n ): Promise<ChatResponse>\n}\n"]}