@things-factory/board-ai 10.0.1 → 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 (90) hide show
  1. package/client/components/board-ai-chat.ts +565 -21
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +32 -0
  4. package/client/components/chat-input-builder.ts +6 -0
  5. package/dist-client/client/components/board-ai-chat.d.ts +80 -0
  6. package/dist-client/client/components/board-ai-chat.js +540 -18
  7. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  8. package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
  9. package/dist-client/client/components/chat-echo-dedup.js +29 -0
  10. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  11. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  12. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  13. package/dist-client/client/components/chat-input-builder.d.ts +5 -0
  14. package/dist-client/client/components/chat-input-builder.js +1 -0
  15. package/dist-client/client/components/chat-input-builder.js.map +1 -1
  16. package/dist-client/server/service/agentic-loop.d.ts +33 -0
  17. package/dist-client/server/service/agentic-loop.js +80 -10
  18. package/dist-client/server/service/agentic-loop.js.map +1 -1
  19. package/dist-client/server/service/assistant.js +32 -5
  20. package/dist-client/server/service/assistant.js.map +1 -1
  21. package/dist-client/server/service/grounding.d.ts +17 -0
  22. package/dist-client/server/service/grounding.js +42 -0
  23. package/dist-client/server/service/grounding.js.map +1 -0
  24. package/dist-client/server/service/types.d.ts +39 -0
  25. package/dist-client/server/service/types.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-server/service/agentic-loop.d.ts +33 -0
  28. package/dist-server/service/agentic-loop.js +81 -10
  29. package/dist-server/service/agentic-loop.js.map +1 -1
  30. package/dist-server/service/assistant.js +31 -4
  31. package/dist-server/service/assistant.js.map +1 -1
  32. package/dist-server/service/board-ai-resolver.d.ts +15 -0
  33. package/dist-server/service/board-ai-resolver.js +121 -2
  34. package/dist-server/service/board-ai-resolver.js.map +1 -1
  35. package/dist-server/service/chat-message/chat-message.d.ts +12 -0
  36. package/dist-server/service/chat-message/chat-message.js +23 -0
  37. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  38. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  39. package/dist-server/service/chat-message/fold-history.js +29 -0
  40. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  41. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  42. package/dist-server/service/chat-message/history-summary.js +77 -0
  43. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  44. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  45. package/dist-server/service/chat-message/llm-history.js +31 -1
  46. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  47. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  48. package/dist-server/service/chat-session/chat-session.js +5 -0
  49. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  50. package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
  51. package/dist-server/service/chat-session/session-inbox.js +41 -0
  52. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  53. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
  54. package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
  55. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
  56. package/dist-server/service/chat-session-resolver.d.ts +44 -1
  57. package/dist-server/service/chat-session-resolver.js +306 -6
  58. package/dist-server/service/chat-session-resolver.js.map +1 -1
  59. package/dist-server/service/grounding.d.ts +17 -0
  60. package/dist-server/service/grounding.js +46 -0
  61. package/dist-server/service/grounding.js.map +1 -0
  62. package/dist-server/service/types.d.ts +39 -0
  63. package/dist-server/service/types.js.map +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +6 -6
  66. package/server/service/agentic-loop.test.ts +154 -0
  67. package/server/service/agentic-loop.ts +108 -10
  68. package/server/service/assistant.ts +36 -5
  69. package/server/service/board-ai-resolver.ts +131 -2
  70. package/server/service/chat-message/chat-message.ts +26 -0
  71. package/server/service/chat-message/fold-history.test.ts +98 -0
  72. package/server/service/chat-message/fold-history.ts +60 -0
  73. package/server/service/chat-message/history-summary.test.ts +127 -0
  74. package/server/service/chat-message/history-summary.ts +100 -0
  75. package/server/service/chat-message/llm-history.test.ts +65 -0
  76. package/server/service/chat-message/llm-history.ts +48 -1
  77. package/server/service/chat-session/chat-session.ts +11 -0
  78. package/server/service/chat-session/session-inbox.test.ts +69 -1
  79. package/server/service/chat-session/session-inbox.ts +45 -0
  80. package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
  81. package/server/service/chat-session-resolver.ts +297 -5
  82. package/server/service/dock-contract.test.ts +305 -0
  83. package/server/service/grounding.test.ts +55 -0
  84. package/server/service/grounding.ts +53 -0
  85. package/server/service/types.ts +39 -0
  86. package/translations/en.json +16 -1
  87. package/translations/ja.json +16 -1
  88. package/translations/ko.json +15 -0
  89. package/translations/ms.json +16 -1
  90. package/translations/zh.json +16 -1
@@ -0,0 +1,43 @@
1
+ import type { HistoryRow } from './llm-history.js';
2
+ /** 세션에 저장된 요약 상태. */
3
+ export interface StoredSummary {
4
+ /** 요약 본문(사람이 읽어도 되는 몇 문장). 없으면 아직 요약이 없다. */
5
+ text?: string | null;
6
+ /** 이 메시지까지 요약에 반영됐다. 이후 메시지는 아직 요약 밖. */
7
+ upToMessageId?: string | null;
8
+ }
9
+ /** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */
10
+ export declare function splitByCap(rows: HistoryRow[], maxTurns: number): {
11
+ dropped: HistoryRow[];
12
+ kept: HistoryRow[];
13
+ };
14
+ /**
15
+ * 아직 요약에 반영되지 않은, 밀려난 메시지들.
16
+ *
17
+ * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에
18
+ * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시
19
+ * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).
20
+ */
21
+ export declare function pendingForSummary(dropped: HistoryRow[], stored: StoredSummary | undefined): HistoryRow[];
22
+ /**
23
+ * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.
24
+ * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)
25
+ */
26
+ export declare function shouldResummarize(pending: HistoryRow[], threshold?: number): boolean;
27
+ /** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */
28
+ export declare function summarySourceText(pending: HistoryRow[]): string;
29
+ /**
30
+ * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**
31
+ *
32
+ * 협의에서 나중까지 필요한 것은 "무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지" 다. 잡담·인사는
33
+ * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이
34
+ * 이름을 바꾸면 이후 대화의 접지가 깨진다.
35
+ */
36
+ export declare function summaryInstruction(locale?: string): string;
37
+ /** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */
38
+ export declare function summaryUserContent(previous: string | null | undefined, pending: HistoryRow[]): string;
39
+ /** 요약 저장 값 — 본문과 "어디까지 덮었는지". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */
40
+ export declare function summaryPatch(text: string, pending: HistoryRow[]): {
41
+ lastSummary: string;
42
+ summaryUpToMessageId?: string;
43
+ };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitByCap = splitByCap;
4
+ exports.pendingForSummary = pendingForSummary;
5
+ exports.shouldResummarize = shouldResummarize;
6
+ exports.summarySourceText = summarySourceText;
7
+ exports.summaryInstruction = summaryInstruction;
8
+ exports.summaryUserContent = summaryUserContent;
9
+ exports.summaryPatch = summaryPatch;
10
+ /** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */
11
+ function splitByCap(rows, maxTurns) {
12
+ if (!maxTurns || maxTurns <= 0 || rows.length <= maxTurns)
13
+ return { dropped: [], kept: rows };
14
+ return { dropped: rows.slice(0, rows.length - maxTurns), kept: rows.slice(-maxTurns) };
15
+ }
16
+ /**
17
+ * 아직 요약에 반영되지 않은, 밀려난 메시지들.
18
+ *
19
+ * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에
20
+ * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시
21
+ * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).
22
+ */
23
+ function pendingForSummary(dropped, stored) {
24
+ const upTo = stored?.upToMessageId;
25
+ if (!upTo || !stored?.text)
26
+ return dropped;
27
+ const idx = dropped.findIndex(r => r.id === upTo);
28
+ return idx < 0 ? dropped : dropped.slice(idx + 1);
29
+ }
30
+ /**
31
+ * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.
32
+ * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)
33
+ */
34
+ function shouldResummarize(pending, threshold = 10) {
35
+ return pending.length >= Math.max(1, threshold);
36
+ }
37
+ /** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */
38
+ function summarySourceText(pending) {
39
+ return pending
40
+ .map(r => {
41
+ const who = r.role === 'assistant' ? 'AI' : r.role === 'system' ? 'SYSTEM' : r.senderName || 'USER';
42
+ return `${who}: ${(r.content ?? '').trim()}`;
43
+ })
44
+ .filter(line => line.length > 6)
45
+ .join('\n');
46
+ }
47
+ /**
48
+ * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**
49
+ *
50
+ * 협의에서 나중까지 필요한 것은 "무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지" 다. 잡담·인사는
51
+ * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이
52
+ * 이름을 바꾸면 이후 대화의 접지가 깨진다.
53
+ */
54
+ function summaryInstruction(locale) {
55
+ return [
56
+ 'Summarize the earlier part of a shared operations deliberation so it can be carried into later turns.',
57
+ 'Keep ONLY what stays relevant: what was checked and found, what was decided, what is still open, and any action that was executed.',
58
+ 'Drop greetings, small talk, and repetition.',
59
+ 'Preserve identifiers (node / order / instance ids) EXACTLY as written — renaming them breaks grounding in later turns.',
60
+ 'Never add facts that are not in the text. If something is unclear, leave it out.',
61
+ 'At most 8 short bullet points. No preamble, no closing remark.',
62
+ locale ? `Write the summary in the language of locale "${locale}".` : ''
63
+ ]
64
+ .filter(Boolean)
65
+ .join(' ');
66
+ }
67
+ /** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */
68
+ function summaryUserContent(previous, pending) {
69
+ const head = previous?.trim() ? `SUMMARY SO FAR:\n${previous.trim()}\n\n` : '';
70
+ return `${head}NEW MESSAGES TO FOLD IN:\n${summarySourceText(pending)}`;
71
+ }
72
+ /** 요약 저장 값 — 본문과 "어디까지 덮었는지". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */
73
+ function summaryPatch(text, pending) {
74
+ const last = [...pending].reverse().find(r => r.id)?.id;
75
+ return { lastSummary: text.trim().slice(0, 4000), summaryUpToMessageId: last };
76
+ }
77
+ //# sourceMappingURL=history-summary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-summary.js","sourceRoot":"","sources":["../../../server/service/chat-message/history-summary.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 이력 요약 접기 — 긴 협의의 앞부분을 **요약으로 대체**한다.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 모델은 기억이 없어 매 턴 이력을 다시 받는다. 그래서 상한(maxTurns)을 두었지만, 상한만으로는\n * 넘친 앞부분이 **그냥 사라진다** — 며칠 이어지는 협의에서 앞에서 합의한 내용이 없던 일이 된다.\n * 요약을 대신 실어 보내면 토큰은 상한 안에 머물면서 맥락은 남는다.\n *\n * ── 규율 ────────────────────────────────────────────────────────────────────\n * ① **어디까지 요약했는지 기록한다**(upToMessageId). 그것이 없으면 매 턴 전체를 다시 요약하거나\n * (비용) 이미 요약한 것을 또 이어 붙인다(중복).\n * ② **누적 요약**이다: 이전 요약 + 새로 밀려난 메시지 → 새 요약. 원본 전체를 다시 읽지 않는다.\n * ③ **아껴 만든다**: 새로 밀려난 것이 임계치를 넘을 때만 다시 만든다. 상한을 넘긴 뒤 매 턴\n * 요약을 만들면 대화마다 LLM 호출이 두 번씩 붙는다.\n * ④ **요약은 요약이라고 밝힌다**: 프롬프트에 \"요약(원문 아님)\" 으로 표기해 모델이 그것을 인용\n * 가능한 원문처럼 다루지 않게 한다. 요약에 없는 사실은 사용자에게 다시 확인해야 한다.\n *\n * 이 파일은 순수 로직이다 — LLM 호출·DB 는 리졸버가 한다(테스트 가능성 유지).\n */\nimport type { HistoryRow } from './llm-history.js'\n\n/** 세션에 저장된 요약 상태. */\nexport interface StoredSummary {\n /** 요약 본문(사람이 읽어도 되는 몇 문장). 없으면 아직 요약이 없다. */\n text?: string | null\n /** 이 메시지까지 요약에 반영됐다. 이후 메시지는 아직 요약 밖. */\n upToMessageId?: string | null\n}\n\n/** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */\nexport function splitByCap(rows: HistoryRow[], maxTurns: number): { dropped: HistoryRow[]; kept: HistoryRow[] } {\n if (!maxTurns || maxTurns <= 0 || rows.length <= maxTurns) return { dropped: [], kept: rows }\n return { dropped: rows.slice(0, rows.length - maxTurns), kept: rows.slice(-maxTurns) }\n}\n\n/**\n * 아직 요약에 반영되지 않은, 밀려난 메시지들.\n *\n * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에\n * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시\n * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).\n */\nexport function pendingForSummary(dropped: HistoryRow[], stored: StoredSummary | undefined): HistoryRow[] {\n const upTo = stored?.upToMessageId\n if (!upTo || !stored?.text) return dropped\n const idx = dropped.findIndex(r => r.id === upTo)\n return idx < 0 ? dropped : dropped.slice(idx + 1)\n}\n\n/**\n * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.\n * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)\n */\nexport function shouldResummarize(pending: HistoryRow[], threshold = 10): boolean {\n return pending.length >= Math.max(1, threshold)\n}\n\n/** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */\nexport function summarySourceText(pending: HistoryRow[]): string {\n return pending\n .map(r => {\n const who = r.role === 'assistant' ? 'AI' : r.role === 'system' ? 'SYSTEM' : r.senderName || 'USER'\n return `${who}: ${(r.content ?? '').trim()}`\n })\n .filter(line => line.length > 6)\n .join('\\n')\n}\n\n/**\n * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**\n *\n * 협의에서 나중까지 필요한 것은 \"무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지\" 다. 잡담·인사는\n * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이\n * 이름을 바꾸면 이후 대화의 접지가 깨진다.\n */\nexport function summaryInstruction(locale?: string): string {\n return [\n 'Summarize the earlier part of a shared operations deliberation so it can be carried into later turns.',\n 'Keep ONLY what stays relevant: what was checked and found, what was decided, what is still open, and any action that was executed.',\n 'Drop greetings, small talk, and repetition.',\n 'Preserve identifiers (node / order / instance ids) EXACTLY as written — renaming them breaks grounding in later turns.',\n 'Never add facts that are not in the text. If something is unclear, leave it out.',\n 'At most 8 short bullet points. No preamble, no closing remark.',\n locale ? `Write the summary in the language of locale \"${locale}\".` : ''\n ]\n .filter(Boolean)\n .join(' ')\n}\n\n/** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */\nexport function summaryUserContent(previous: string | null | undefined, pending: HistoryRow[]): string {\n const head = previous?.trim() ? `SUMMARY SO FAR:\\n${previous.trim()}\\n\\n` : ''\n return `${head}NEW MESSAGES TO FOLD IN:\\n${summarySourceText(pending)}`\n}\n\n/** 요약 저장 값 — 본문과 \"어디까지 덮었는지\". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */\nexport function summaryPatch(text: string, pending: HistoryRow[]): { lastSummary: string; summaryUpToMessageId?: string } {\n const last = [...pending].reverse().find(r => r.id)?.id\n return { lastSummary: text.trim().slice(0, 4000), summaryUpToMessageId: last }\n}\n"]}
@@ -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":"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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'\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":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qCAWgB;AAChB,+CAAoD;AAEpD,iDAA8C;AAC9C,yDAAgD;AAChD,6CAA4C;AAE5C,qEAA6D;AAC7D,kEAA0D;AAE1D,MAAM,SAAS,GAAG,YAAM,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;AAC7C,MAAM,aAAa,GAAG,SAAS,CAAC,IAAI,CAAA;AAW7B,IAAM,WAAW,GAAjB,MAAM,WAAW;CAqIvB,CAAA;AArIY,kCAAW;AAGb;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;uCAClB;AAIpB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACjC,cAAM;2CAAA;AAGf;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;;6CACpC;AAYjB;IANC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC;QACL,QAAQ,EAAE,IAAI;QACd,WAAW,EACT,gMAAgM;KACnM,CAAC;;4CACc;AAehB;IANC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC;QACL,QAAQ,EAAE,IAAI;QACd,WAAW,EACT,8MAA8M;KACjN,CAAC;;+CACiB;AAKnB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gEAAgE,EAAE,CAAC;;6CACxF;AAUjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,+DAA+D,EAAE,CAAC;;yCAC3F;AAIb;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gCAAgC,EAAE,CAAC;sCAC7E,gBAAI;4CAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;8CACpC;AAIlB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qCAAqC,EAAE,CAAC;sCAClF,gBAAI;4CAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;8CACpC;AAYlB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wKAAwK,EAAE,CAAC;sCACjM,IAAI;IAEpB,8DAA8D;;kDAF1C;AAKpB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gGAAgG,EAAE,CAAC;;uDAC9G;AAe3B;IAbC,IAAA,gBAAM,EAAC;QACN,QAAQ,EAAE,IAAI;QACd,IAAI,EACF,aAAa,IAAI,OAAO,IAAI,aAAa,IAAI,SAAS;YACpD,CAAC,CAAC,UAAU;YACZ,CAAC,CAAC,aAAa,IAAI,QAAQ;gBACzB,CAAC,CAAC,MAAM;gBACR,CAAC,CAAC,aAAa,IAAI,OAAO;oBACxB,CAAC,CAAC,UAAU;oBACZ,CAAC,CAAC,MAAM;QAChB,MAAM,EAAE,aAAa,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;KACrD,CAAC;IACD,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,0DAA0D,EAAE,CAAC;;gDAC/E;AAWpB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,6HAA6H,EAAE,CAAC;;yDACzI;AAK7B;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;+CACP;AAInB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACd,IAAI;8CAAA;AAIhB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACd,IAAI;8CAAA;AAGhB;IADC,IAAA,0BAAgB,GAAE;sCACP,IAAI;IAEhB,oDAAoD;;8CAFpC;AAIhB;IADC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,6BAAW,EAAE,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;6CACnC;AAGxB;IADC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,2BAAU,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC;;4CAChC;sBApIX,WAAW;IATvB,IAAA,gBAAM,GAAE;IACT,qEAAqE;IACrE,kDAAkD;;IACjD,IAAA,eAAK,EAAC,mBAAmB,EAAE,CAAC,OAAoB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACxF,uEAAuE;;IACtE,IAAA,eAAK,EAAC,mBAAmB,EAAE,CAAC,OAAoB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5G,IAAA,yBAAU,EAAC;QACV,WAAW,EAAE,wEAAwE;KACtF,CAAC;GACW,WAAW,CAqIvB","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"]}
@@ -13,12 +13,16 @@ export interface InboxSessionRow {
13
13
  export interface MyParticipation {
14
14
  sessionId?: string;
15
15
  lastReadAt?: Date | string | null;
16
+ /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */
17
+ hiddenAt?: Date | string | null;
16
18
  }
17
19
  export interface InboxEntry extends InboxSessionRow {
18
20
  /** 안 읽은 활동이 있는가 — 목록의 점 표시. */
19
21
  unread: boolean;
20
22
  /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */
21
23
  participant: boolean;
24
+ /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */
25
+ hidden?: boolean;
22
26
  }
23
27
  /**
24
28
  * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.
@@ -32,6 +36,28 @@ export interface InboxEntry extends InboxSessionRow {
32
36
  * 잡으면 방금 읽은 화면에 배지가 남는다.
33
37
  */
34
38
  export declare function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[];
39
+ /**
40
+ * 숨김 표시·필터.
41
+ *
42
+ * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —
43
+ * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.
44
+ *
45
+ * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이
46
+ * 숨겨진 것인지 알아야 되돌릴 수 있다.
47
+ */
48
+ export declare function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden?: boolean): InboxEntry[];
49
+ /**
50
+ * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.
51
+ *
52
+ * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은
53
+ * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),
54
+ * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로
55
+ * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
56
+ * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
57
+ */
58
+ export declare function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[];
59
+ /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
60
+ export declare const INBOX_SCAN_CAP = 500;
35
61
  /** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
36
62
  export declare function countUnreadSessions(entries: InboxEntry[]): number;
37
63
  /**
@@ -16,7 +16,10 @@
16
16
  * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.INBOX_SCAN_CAP = void 0;
19
20
  exports.buildInbox = buildInbox;
21
+ exports.applyHidden = applyHidden;
22
+ exports.searchInbox = searchInbox;
20
23
  exports.countUnreadSessions = countUnreadSessions;
21
24
  exports.activityPatch = activityPatch;
22
25
  exports.autoSessionName = autoSessionName;
@@ -59,6 +62,44 @@ function buildInbox(sessions, mine) {
59
62
  });
60
63
  return entries.sort((a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt)));
61
64
  }
65
+ /**
66
+ * 숨김 표시·필터.
67
+ *
68
+ * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —
69
+ * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.
70
+ *
71
+ * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이
72
+ * 숨겨진 것인지 알아야 되돌릴 수 있다.
73
+ */
74
+ function applyHidden(entries, hiddenAt, showHidden = false) {
75
+ const out = [];
76
+ for (const entry of entries) {
77
+ const at = hiddenAt.get(entry.id ?? '');
78
+ /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */
79
+ const hidden = !!at && time(entry.lastMessageAt) <= time(at);
80
+ if (hidden && !showHidden)
81
+ continue;
82
+ out.push(hidden ? { ...entry, hidden: true } : entry);
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.
88
+ *
89
+ * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은
90
+ * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),
91
+ * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로
92
+ * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
93
+ * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
94
+ */
95
+ function searchInbox(entries, search) {
96
+ const q = (search ?? '').trim().toLowerCase();
97
+ if (!q)
98
+ return entries;
99
+ return entries.filter(e => `${e.name ?? ''}\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q));
100
+ }
101
+ /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
102
+ exports.INBOX_SCAN_CAP = 500;
62
103
  /** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
63
104
  function countUnreadSessions(entries) {
64
105
  return entries.filter(e => e.unread).length;
@@ -1 +1 @@
1
- {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n createdAt?: Date | string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n lastReadAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n }\n\n const entries = sessions.map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(content: string, at: Date = new Date()): { lastMessageAt: Date; lastMessagePreview: string } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200) }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
1
+ {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n createdAt?: Date | string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n }\n\n const entries = sessions.map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(content: string, at: Date = new Date()): { lastMessageAt: Date; lastMessagePreview: string } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200) }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
@@ -28,6 +28,17 @@ export declare class ChatSessionParticipant {
28
28
  * nullable — 한 번도 읽지 않은 참여자(초대 직후)는 세션의 모든 활동이 안 읽음이다.
29
29
  */
30
30
  lastReadAt?: Date;
31
+ /**
32
+ * 이 사용자가 **자기 목록에서 숨긴 시각**. 숨김은 **나에게만** 적용된다 — 남의 목록·대화 내용은 그대로다.
33
+ *
34
+ * 왜 삭제와 나누는가: 협의는 여럿의 기록이므로 지우는 것은 **만든 사람**만 할 수 있다(되돌릴 수 없다).
35
+ * 그 권한이 없는 사람에게도 "내 목록을 정리할" 방법은 있어야 하고, 그것이 숨김이다.
36
+ *
37
+ * 숨긴 **뒤에 새 활동이 생기면 다시 나타난다** — 아직 참여자인 대화에서 조용히 잘려 나가는 것이
38
+ * 목록이 한 줄 늘어나는 것보다 나쁘다. 완전히 빠지려면 대화에서 나가야 한다(별개 기능).
39
+ * nullable — 값이 없으면 숨기지 않은 상태.
40
+ */
41
+ hiddenAt?: Date;
31
42
  creator?: User;
32
43
  creatorId?: string;
33
44
  updater?: User;
@@ -74,8 +74,24 @@ tslib_1.__decorate([
74
74
  tslib_1.__decorate([
75
75
  (0, typeorm_1.Column)({ nullable: true }),
76
76
  (0, type_graphql_1.Field)({ nullable: true, description: 'Point up to which this participant has read the session. Distinct from lastSeenAt (presence): a window can be open without being read, and the read point survives disconnection. Null means nothing read yet.' }),
77
- tslib_1.__metadata("design:type", Date)
77
+ tslib_1.__metadata("design:type", Date
78
+ /**
79
+ * 이 사용자가 **자기 목록에서 숨긴 시각**. 숨김은 **나에게만** 적용된다 — 남의 목록·대화 내용은 그대로다.
80
+ *
81
+ * 왜 삭제와 나누는가: 협의는 여럿의 기록이므로 지우는 것은 **만든 사람**만 할 수 있다(되돌릴 수 없다).
82
+ * 그 권한이 없는 사람에게도 "내 목록을 정리할" 방법은 있어야 하고, 그것이 숨김이다.
83
+ *
84
+ * 숨긴 **뒤에 새 활동이 생기면 다시 나타난다** — 아직 참여자인 대화에서 조용히 잘려 나가는 것이
85
+ * 목록이 한 줄 늘어나는 것보다 나쁘다. 완전히 빠지려면 대화에서 나가야 한다(별개 기능).
86
+ * nullable — 값이 없으면 숨기지 않은 상태.
87
+ */
88
+ )
78
89
  ], ChatSessionParticipant.prototype, "lastReadAt", void 0);
90
+ tslib_1.__decorate([
91
+ (0, typeorm_1.Column)({ nullable: true }),
92
+ (0, type_graphql_1.Field)({ nullable: true, description: 'When this user hid the session from their own list. Per-user only; the session and other participants are unaffected. Activity newer than this timestamp makes it reappear.' }),
93
+ tslib_1.__metadata("design:type", Date)
94
+ ], ChatSessionParticipant.prototype, "hiddenAt", void 0);
79
95
  tslib_1.__decorate([
80
96
  (0, typeorm_1.ManyToOne)(type => auth_base_1.User),
81
97
  (0, type_graphql_1.Field)(type => auth_base_1.User, { nullable: true, description: 'User who created this participant record (typically session owner who invited).' }),
@@ -1 +1 @@
1
- {"version":3,"file":"chat-session-participant.js","sourceRoot":"","sources":["../../../server/service/chat-session-participant/chat-session-participant.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;GAUG;AACH,qCAUgB;AAChB,+CAAoD;AAEpD,iDAA8C;AAC9C,yDAAgD;AAEhD,qEAA6D;AActD,IAAM,sBAAsB,GAA5B,MAAM,sBAAsB;CA+ElC,CAAA;AA/EY,wDAAsB;AAGxB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,wBAAwB,EAAE,CAAC;;kDACzC;AAIpB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,2CAA2C,EAAE,CAAC;sCAC3F,cAAM;sDAAA;AAGf;IADC,IAAA,oBAAU,EAAC,CAAC,CAAyB,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;;wDACnC;AAIjB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,6BAAW,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;IACvD,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,6BAAW,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gDAAgD,EAAE,CAAC;sCACpG,6BAAW;uDAAA;AAGrB;IADC,IAAA,oBAAU,EAAC,CAAC,CAAyB,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;;yDACnC;AAIlB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wCAAwC,EAAE,CAAC;sCACxF,gBAAI;oDAAA;AAGX;IADC,IAAA,oBAAU,EAAC,CAAC,CAAyB,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;;sDACnC;AAYf;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC1D,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,oDAAoD,EAAE,CAAC;;oDAChE;AAKb;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,sDAAsD,EAAE,CAAC;sCAClF,IAAI;IAEjB;;;;;;OAMG;;0DARc;AAWjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gNAAgN,EAAE,CAAC;sCAC5O,IAAI;0DAAA;AAIjB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,iFAAiF,EAAE,CAAC;sCAC9H,gBAAI;uDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,CAAyB,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;;yDACnC;AAIlB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kEAAkE,EAAE,CAAC;sCAC/G,gBAAI;uDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,CAAyB,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;;yDACnC;AAIlB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,sBAAsB,EAAE,CAAC;sCACnD,IAAI;yDAAA;AAIhB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wDAAwD,EAAE,CAAC;sCACrF,IAAI;yDAAA;AAIhB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,yCAAyC,EAAE,CAAC;sCACtE,IAAI;yDAAA;iCA9EL,sBAAsB;IAZlC,IAAA,gBAAM,EAAC,2BAA2B,CAAC;IACnC,IAAA,eAAK,EAAC,+BAA+B,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE;QACrE,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,sBAAsB;KAC9B,CAAC;IACD,IAAA,eAAK,EAAC,+BAA+B,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE;QAC1D,KAAK,EAAE,sBAAsB;KAC9B,CAAC;IACD,IAAA,yBAAU,EAAC;QACV,WAAW,EACT,6IAA6I;KAChJ,CAAC;GACW,sBAAsB,CA+ElC","sourcesContent":["/**\n * ChatSessionParticipant — 채팅 세션의 참여 사용자.\n *\n * 한 ChatSession 에 여러 사용자가 참여할 수 있고, 각자 role (owner / member / viewer 등)\n * 을 가짐. 다자간 채팅 / 권한 분화 / \"내 세션 목록\" / 멤버 표시 등의 기반.\n *\n * 현재 visibility 정책 (도메인 내 모두 공개) 은 그대로 유지 — 본 엔티티는 영속/추적\n * 데이터 만들고, \"참여자만 접근\" 같은 enforcement 는 정책 굳을 때 별도 phase 에서 도입.\n *\n * 자동 참여 vs 명시적 초대 정책도 미정 — 일단 세션 creator 만 자동 'owner' 로 등록.\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\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'\n\nimport { ChatSession } from '../chat-session/chat-session.js'\n\n@Entity('chat_session_participants')\n@Index('ix_chat_session_participant_0', ['domain', 'session', 'user'], {\n unique: true,\n where: '\"deleted_at\" IS NULL'\n})\n@Index('ix_chat_session_participant_1', ['domain', 'user'], {\n where: '\"deleted_at\" IS NULL'\n})\n@ObjectType({\n description:\n 'Participant of a ChatSession — links a User to a session with a role. Foundation for multi-user chat / per-user filtering / member display.'\n})\nexport class ChatSessionParticipant {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique participant id.' })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Domain to which this participant belongs.' })\n domain?: Domain\n\n @RelationId((p: ChatSessionParticipant) => p.domain)\n domainId?: string\n\n @ManyToOne(type => ChatSession, { onDelete: 'CASCADE' })\n @Field(type => ChatSession, { nullable: true, description: 'The ChatSession this participation belongs to.' })\n session?: ChatSession\n\n @RelationId((p: ChatSessionParticipant) => p.session)\n sessionId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'The user participating in the session.' })\n user?: User\n\n @RelationId((p: ChatSessionParticipant) => p.user)\n userId?: string\n\n /**\n * 참여자의 역할.\n * - 'owner': 세션 만든 사람. rename / delete 권한.\n * - 'member': 일반 참여자. 메시지 송수신.\n * - 'viewer': 읽기 전용 (미래 권한 정책 활성화 시).\n *\n * 시작은 'owner' / 'member' 둘로 충분. 더 세분화 필요해지면 enum 확장.\n */\n @Column({ type: 'varchar', length: 16, default: 'member' })\n @Field({ description: 'Role of this participant: owner | member | viewer.' })\n role!: string\n\n /** 마지막 활동 시각 — presence indicator. nullable. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last seen / activity timestamp (presence indicator).' })\n lastSeenAt?: Date\n\n /**\n * 이 참여자가 **읽은 지점**. 세션의 마지막 활동 시각과 비교해 안 읽음을 판정한다.\n *\n * `lastSeenAt`(접속 표시)과 다른 개념이다 — 창을 열어 두고 화면을 안 볼 수 있고, 접속이 끊긴 뒤에도\n * 읽은 지점은 남는다. 두 값을 섞어 쓰면 \"봤는데 안 읽음\" 또는 그 반대가 된다.\n * nullable — 한 번도 읽지 않은 참여자(초대 직후)는 세션의 모든 활동이 안 읽음이다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Point up to which this participant has read the session. Distinct from lastSeenAt (presence): a window can be open without being read, and the read point survives disconnection. Null means nothing read yet.' })\n lastReadAt?: Date\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this participant record (typically session owner who invited).' })\n creator?: User\n\n @RelationId((p: ChatSessionParticipant) => p.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'Last user to update this participant record (e.g., role change).' })\n updater?: User\n\n @RelationId((p: ChatSessionParticipant) => p.updater)\n updaterId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Joined-at timestamp.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Last update timestamp (role change / lastSeenAt bump).' })\n updatedAt?: Date\n\n @DeleteDateColumn()\n @Field({ nullable: true, description: 'Soft delete (= left session) timestamp.' })\n deletedAt?: Date\n}\n"]}
1
+ {"version":3,"file":"chat-session-participant.js","sourceRoot":"","sources":["../../../server/service/chat-session-participant/chat-session-participant.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSessionParticipant — 채팅 세션의 참여 사용자.\n *\n * 한 ChatSession 에 여러 사용자가 참여할 수 있고, 각자 role (owner / member / viewer 등)\n * 을 가짐. 다자간 채팅 / 권한 분화 / \"내 세션 목록\" / 멤버 표시 등의 기반.\n *\n * 현재 visibility 정책 (도메인 내 모두 공개) 은 그대로 유지 — 본 엔티티는 영속/추적\n * 데이터 만들고, \"참여자만 접근\" 같은 enforcement 는 정책 굳을 때 별도 phase 에서 도입.\n *\n * 자동 참여 vs 명시적 초대 정책도 미정 — 일단 세션 creator 만 자동 'owner' 로 등록.\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\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'\n\nimport { ChatSession } from '../chat-session/chat-session.js'\n\n@Entity('chat_session_participants')\n@Index('ix_chat_session_participant_0', ['domain', 'session', 'user'], {\n unique: true,\n where: '\"deleted_at\" IS NULL'\n})\n@Index('ix_chat_session_participant_1', ['domain', 'user'], {\n where: '\"deleted_at\" IS NULL'\n})\n@ObjectType({\n description:\n 'Participant of a ChatSession — links a User to a session with a role. Foundation for multi-user chat / per-user filtering / member display.'\n})\nexport class ChatSessionParticipant {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique participant id.' })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Domain to which this participant belongs.' })\n domain?: Domain\n\n @RelationId((p: ChatSessionParticipant) => p.domain)\n domainId?: string\n\n @ManyToOne(type => ChatSession, { onDelete: 'CASCADE' })\n @Field(type => ChatSession, { nullable: true, description: 'The ChatSession this participation belongs to.' })\n session?: ChatSession\n\n @RelationId((p: ChatSessionParticipant) => p.session)\n sessionId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'The user participating in the session.' })\n user?: User\n\n @RelationId((p: ChatSessionParticipant) => p.user)\n userId?: string\n\n /**\n * 참여자의 역할.\n * - 'owner': 세션 만든 사람. rename / delete 권한.\n * - 'member': 일반 참여자. 메시지 송수신.\n * - 'viewer': 읽기 전용 (미래 권한 정책 활성화 시).\n *\n * 시작은 'owner' / 'member' 둘로 충분. 더 세분화 필요해지면 enum 확장.\n */\n @Column({ type: 'varchar', length: 16, default: 'member' })\n @Field({ description: 'Role of this participant: owner | member | viewer.' })\n role!: string\n\n /** 마지막 활동 시각 — presence indicator. nullable. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last seen / activity timestamp (presence indicator).' })\n lastSeenAt?: Date\n\n /**\n * 이 참여자가 **읽은 지점**. 세션의 마지막 활동 시각과 비교해 안 읽음을 판정한다.\n *\n * `lastSeenAt`(접속 표시)과 다른 개념이다 — 창을 열어 두고 화면을 안 볼 수 있고, 접속이 끊긴 뒤에도\n * 읽은 지점은 남는다. 두 값을 섞어 쓰면 \"봤는데 안 읽음\" 또는 그 반대가 된다.\n * nullable — 한 번도 읽지 않은 참여자(초대 직후)는 세션의 모든 활동이 안 읽음이다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Point up to which this participant has read the session. Distinct from lastSeenAt (presence): a window can be open without being read, and the read point survives disconnection. Null means nothing read yet.' })\n lastReadAt?: Date\n\n /**\n * 이 사용자가 **자기 목록에서 숨긴 시각**. 숨김은 **나에게만** 적용된다 — 남의 목록·대화 내용은 그대로다.\n *\n * 왜 삭제와 나누는가: 협의는 여럿의 기록이므로 지우는 것은 **만든 사람**만 할 수 있다(되돌릴 수 없다).\n * 그 권한이 없는 사람에게도 \"내 목록을 정리할\" 방법은 있어야 하고, 그것이 숨김이다.\n *\n * 숨긴 **뒤에 새 활동이 생기면 다시 나타난다** — 아직 참여자인 대화에서 조용히 잘려 나가는 것이\n * 목록이 한 줄 늘어나는 것보다 나쁘다. 완전히 빠지려면 대화에서 나가야 한다(별개 기능).\n * nullable — 값이 없으면 숨기지 않은 상태.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When this user hid the session from their own list. Per-user only; the session and other participants are unaffected. Activity newer than this timestamp makes it reappear.' })\n hiddenAt?: Date\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this participant record (typically session owner who invited).' })\n creator?: User\n\n @RelationId((p: ChatSessionParticipant) => p.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'Last user to update this participant record (e.g., role change).' })\n updater?: User\n\n @RelationId((p: ChatSessionParticipant) => p.updater)\n updaterId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Joined-at timestamp.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Last update timestamp (role change / lastSeenAt bump).' })\n updatedAt?: Date\n\n @DeleteDateColumn()\n @Field({ nullable: true, description: 'Soft delete (= left session) timestamp.' })\n deletedAt?: Date\n}\n"]}