@things-factory/ai-assistant 10.1.58 → 10.1.81
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.
- package/client/components/assistant-chat.ts +102 -13
- package/client/components/assistant-dock.ts +160 -18
- package/client/components/assistant-mode.test.ts +16 -2
- package/client/components/assistant-session-manager.ts +52 -25
- package/client/components/assistant-session-toolbar.ts +4 -2
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/proposal-outcome-line.ts +36 -0
- package/client/components/proposal-request.test.ts +52 -0
- package/client/utils/assistant-navigation.test.ts +38 -0
- package/client/utils/assistant-navigation.ts +6 -0
- package/client/utils/assistant-surface.ts +21 -0
- package/dist-client/components/assistant-chat.d.ts +15 -0
- package/dist-client/components/assistant-chat.js +106 -13
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +13 -0
- package/dist-client/components/assistant-dock.js +175 -20
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +14 -2
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +4 -0
- package/dist-client/components/assistant-session-manager.js +68 -26
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
- package/dist-client/components/assistant-session-toolbar.js +8 -2
- package/dist-client/components/assistant-session-toolbar.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +2 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/proposal-outcome-line.d.ts +29 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -1
- package/dist-client/components/proposal-request.test.d.ts +1 -0
- package/dist-client/components/proposal-request.test.js +45 -0
- package/dist-client/components/proposal-request.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-navigation.d.ts +6 -0
- package/dist-client/utils/assistant-navigation.js +9 -0
- package/dist-client/utils/assistant-navigation.js.map +1 -0
- package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
- package/dist-client/utils/assistant-navigation.test.js +37 -0
- package/dist-client/utils/assistant-navigation.test.js.map +1 -0
- package/dist-client/utils/assistant-surface.d.ts +19 -0
- package/dist-client/utils/assistant-surface.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +10 -0
- package/dist-server/service/chat-message/chat-message.js +29 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
- package/dist-server/service/chat-message/proposal-outcome.js +35 -1
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
- package/dist-server/service/chat-session/session-inbox.js +7 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +63 -9
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/assistant-chat-resolver.ts +1 -0
- package/server/service/chat-message/chat-message.ts +32 -0
- package/server/service/chat-message/proposal-outcome.test.ts +53 -2
- package/server/service/chat-message/proposal-outcome.ts +33 -0
- package/server/service/chat-session/session-inbox.ts +7 -0
- package/server/service/chat-session-resolver.ts +71 -12
- package/test/assistant-dock.test.ts +8 -3
- package/test/assistant-session-toolbar.test.ts +7 -1
- package/test/open-doors.test.ts +2 -1
- package/test/session-discovery.test.ts +42 -0
- package/translations/en.json +26 -1
- package/translations/ja.json +26 -1
- package/translations/ko.json +26 -1
- package/translations/ms.json +26 -1
- package/translations/zh.json +26 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proposal-outcome.js","sourceRoot":"","sources":["../../../server/service/chat-message/proposal-outcome.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAuBH,8DAOC;
|
|
1
|
+
{"version":3,"file":"proposal-outcome.js","sourceRoot":"","sources":["../../../server/service/chat-message/proposal-outcome.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAuBH,8DAOC;AAeD,kEAEC;AAMD,oDAUC;AAjDY,QAAA,uBAAuB,GAAG,WAAW,CAAA;AAErC,QAAA,iBAAiB,GAAsB,CAAC,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,CAAC,CAAA;AAEtG;;;;GAIG;AACH,SAAgB,yBAAyB,CAAC,GAAW,EAAE,OAAwB;IAC7E,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;IACjE,MAAM,OAAO,GACX,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ,IAAI,OAAO,OAAO,CAAC,MAAM,KAAK,QAAQ;QACvE,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,EAAE;QAC9E,CAAC,CAAC,EAAE,CAAA;IACR,OAAO,GAAG,+BAAuB,GAAG,GAAG,KAAK,OAAO,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,EAAE,CAAA;AAClF,CAAC;AAED;;;;;;;;;GASG;AACU,QAAA,kBAAkB,GAAG,WAAW,CAAA;AAE7C,kCAAkC;AAClC,SAAgB,2BAA2B,CAAC,GAAW;IACrD,OAAO,GAAG,+BAAuB,GAAG,GAAG,KAAK,0BAAkB,EAAE,CAAA;AAClE,CAAC;AAED;;;GAGG;AACH,SAAgB,oBAAoB,CAAC,GAAW,EAAE,WAAmD;IACnG,MAAM,SAAS,GAAG,2BAA2B,CAAC,GAAG,CAAC,CAAA;IAClD,MAAM,QAAQ,GAAG,GAAG,+BAAuB,GAAG,GAAG,IAAI,CAAA;IACrD,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;QAC7B,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QAChC,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,IAAI,CAAA;QACnC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;QACxC,OAAO,yBAAiB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,KAAK,OAAO,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,OAAO,GAAG,CAAC,CAAC,CAAA;IAC9F,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["/*\n * What the host did with a proposal, written into the conversation (ADR-0070 A12-a).\n *\n * The card used to change to \"executed\" the moment it was pressed, which is the one thing it knew\n * and the one thing that is not the answer. The host's answer comes back through a server door and\n * is stored as a `system` row, so three readers get it from the same place: the card that is open,\n * the card after a refresh, and the model on its next turn — a model that is not told its proposal\n * was refused goes on as though it had been applied.\n *\n * The line is the row's content. It is parsed back by the panel, so the shape is fixed here and the\n * panel's parser is tested against this formatter.\n */\n\n/** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */\nexport type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'\n\nexport interface ProposalOutcome {\n verdict: ProposalVerdict\n /** The host's own reason code, carried through untouched — no mapping table (ADR-0042). */\n errorCode?: string\n /** For a proposal made of several edits: how many went in, and how many did not. */\n applied?: number\n missed?: number\n}\n\nexport const PROPOSAL_OUTCOME_PREFIX = 'proposal '\n\nexport const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']\n\n/**\n * `proposal <key>: <verdict> (<errorCode>) 2/3`\n *\n * English, like the other lines the model reads. The panel turns it into the reader's language.\n */\nexport function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string {\n const reason = outcome.errorCode ? ` (${outcome.errorCode})` : ''\n const counted =\n typeof outcome.applied === 'number' || typeof outcome.missed === 'number'\n ? ` ${outcome.applied ?? 0}/${(outcome.applied ?? 0) + (outcome.missed ?? 0)}`\n : ''\n return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`\n}\n\n/*\n * \"Requested\" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).\n *\n * The card used to remember that it was pressed in the page's memory only. After a refresh a\n * proposal whose outcome never came back had its button again, and pressing it asked the host to\n * apply it a second time. The request is now a row, like the outcome, so every reader sees it: the\n * card after a refresh, another window, and the model (\"requested, no answer yet\").\n *\n * It is not a verdict — nothing happened yet. It only says the host was asked.\n */\nexport const PROPOSAL_REQUESTED = 'requested'\n\n/** `proposal <key>: requested` */\nexport function formatProposalRequestedLine(key: string): string {\n return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${PROPOSAL_REQUESTED}`\n}\n\n/**\n * Whether this proposal was already taken in this conversation — requested, or answered.\n * The caller passes the conversation's system lines; the answer is the rule, kept testable.\n */\nexport function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean {\n const requested = formatProposalRequestedLine(key)\n const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `\n return systemLines.some(line => {\n const text = (line ?? '').trim()\n if (text === requested) return true\n if (!text.startsWith(answered)) return false\n const rest = text.slice(answered.length)\n return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))\n })\n}\n"]}
|
|
@@ -89,6 +89,13 @@ export declare function filterInbox<T extends InboxEntry & {
|
|
|
89
89
|
mine?: boolean;
|
|
90
90
|
}>(entries: T[], filter?: InboxFilter): T[];
|
|
91
91
|
export declare function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[];
|
|
92
|
+
/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
|
|
93
|
+
export declare function searchReadableMessages(rows: readonly {
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
content?: string;
|
|
96
|
+
role?: string;
|
|
97
|
+
redacted?: boolean;
|
|
98
|
+
}[], search: string): Set<string>;
|
|
92
99
|
/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
|
|
93
100
|
export declare const INBOX_SCAN_CAP = 500;
|
|
94
101
|
/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
|
|
@@ -21,6 +21,7 @@ exports.buildInbox = buildInbox;
|
|
|
21
21
|
exports.applyHidden = applyHidden;
|
|
22
22
|
exports.filterInbox = filterInbox;
|
|
23
23
|
exports.searchInbox = searchInbox;
|
|
24
|
+
exports.searchReadableMessages = searchReadableMessages;
|
|
24
25
|
exports.countUnreadSessions = countUnreadSessions;
|
|
25
26
|
exports.activityPatch = activityPatch;
|
|
26
27
|
exports.autoSessionName = autoSessionName;
|
|
@@ -122,6 +123,12 @@ function searchInbox(entries, search) {
|
|
|
122
123
|
return entries;
|
|
123
124
|
return entries.filter(e => `${e.name ?? ''}\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q));
|
|
124
125
|
}
|
|
126
|
+
/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
|
|
127
|
+
function searchReadableMessages(rows, search) {
|
|
128
|
+
const q = search.trim().toLowerCase();
|
|
129
|
+
return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))
|
|
130
|
+
.map(row => row.sessionId).filter((id) => !!id));
|
|
131
|
+
}
|
|
125
132
|
/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
|
|
126
133
|
exports.INBOX_SCAN_CAP = 500;
|
|
127
134
|
/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
|
|
@@ -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\nimport { mayReadSession } from './session-visibility.js'\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\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 /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: 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 const owns = 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 if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .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 visibility: session.visibility ?? 'open',\n owner: owns.has(id),\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 */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\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(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\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\nimport { mayReadSession } from './session-visibility.js'\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\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 /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: 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 const owns = 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 if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .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 visibility: session.visibility ?? 'open',\n owner: owns.has(id),\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 */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\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/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */\nexport function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {\n const q = search.trim().toLowerCase()\n return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))\n .map(row => row.sessionId).filter((id): id is string => !!id))\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(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\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"]}
|
|
@@ -109,6 +109,7 @@ export declare class ChatSessionResolver {
|
|
|
109
109
|
createAISession(anchorType: string, anchorId: string, name: string | undefined, context: ResolverContext): Promise<ChatSession>;
|
|
110
110
|
renameChatSession(sessionId: string, name: string, context: ResolverContext): Promise<boolean>;
|
|
111
111
|
reportProposalOutcome(sessionId: string, proposalKey: string, outcome: ProposalOutcome, context: ResolverContext): Promise<boolean>;
|
|
112
|
+
requestProposal(sessionId: string, proposalKey: string, context: ResolverContext): Promise<boolean>;
|
|
112
113
|
/**
|
|
113
114
|
* Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
|
|
114
115
|
* Passes the write gate - a private conversation takes files from its people only - and the mode's
|
|
@@ -217,7 +217,22 @@ let ChatSessionResolver = class ChatSessionResolver {
|
|
|
217
217
|
const all = await this._inboxEntries(anchorType, anchorId, context);
|
|
218
218
|
let entries = opts.showHidden ? all : all.filter(e => !e.hidden);
|
|
219
219
|
entries = (0, session_inbox_js_1.filterInbox)(entries, opts);
|
|
220
|
-
|
|
220
|
+
if (!opts.search?.trim() || !entries.length)
|
|
221
|
+
return entries;
|
|
222
|
+
// Search only readable rooms, then pass their messages through the SAME reader gate.
|
|
223
|
+
// A match in a redacted answer must not reveal even that a conversation contains it.
|
|
224
|
+
const messages = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).find({
|
|
225
|
+
where: { session: { id: (0, typeorm_1.In)(entries.map(entry => entry.id)) } },
|
|
226
|
+
order: { createdAt: 'DESC', id: 'DESC' },
|
|
227
|
+
take: 2001
|
|
228
|
+
});
|
|
229
|
+
if (messages.length > 2000) {
|
|
230
|
+
throw new Error(context.t('ai-assistant.session.search-too-broad', { defaultValue: 'Too many messages to search here. Narrow the conversation filters and try again.' }));
|
|
231
|
+
}
|
|
232
|
+
const readable = await (0, reader_gate_js_1.readerGate)(context)(messages);
|
|
233
|
+
const matched = (0, session_inbox_js_1.searchReadableMessages)(readable, opts.search);
|
|
234
|
+
const names = new Set((0, session_inbox_js_1.searchInbox)(entries, opts.search).map(entry => entry.id));
|
|
235
|
+
return entries.filter(entry => names.has(entry.id) || matched.has(entry.id));
|
|
221
236
|
}
|
|
222
237
|
/**
|
|
223
238
|
* 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
|
|
@@ -515,13 +530,8 @@ let ChatSessionResolver = class ChatSessionResolver {
|
|
|
515
530
|
async createAISession(anchorType, anchorId, name, context) {
|
|
516
531
|
const { domain, user, tx } = context.state;
|
|
517
532
|
const repo = (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx);
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
if (!finalName) {
|
|
521
|
-
const count = await repo.count({ where: (0, session_anchor_js_1.anchorWhere)(domain.id, anchorType, anchorId) });
|
|
522
|
-
/* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
|
|
523
|
-
finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' });
|
|
524
|
-
}
|
|
533
|
+
// Do not prefill a numbered name: that prevents the first question from naming it.
|
|
534
|
+
const finalName = name?.trim() || null;
|
|
525
535
|
const session = await repo.save({
|
|
526
536
|
domain,
|
|
527
537
|
...(0, session_anchor_js_1.anchorColumns)(anchorType, anchorId),
|
|
@@ -585,6 +595,37 @@ let ChatSessionResolver = class ChatSessionResolver {
|
|
|
585
595
|
(0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message: sysMsg });
|
|
586
596
|
return true;
|
|
587
597
|
}
|
|
598
|
+
async requestProposal(sessionId, proposalKey, context) {
|
|
599
|
+
const { domain, user, tx } = context.state;
|
|
600
|
+
const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } });
|
|
601
|
+
if (!session)
|
|
602
|
+
throw new Error(`ChatSession ${sessionId} not found`);
|
|
603
|
+
await writeGate(session, context, tx);
|
|
604
|
+
const key = (proposalKey ?? '').trim();
|
|
605
|
+
if (!key)
|
|
606
|
+
return false;
|
|
607
|
+
/* Only this conversation's proposal lines - a conversation is bounded, and the rule reads them all. */
|
|
608
|
+
const lines = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).find({
|
|
609
|
+
select: ['content'],
|
|
610
|
+
where: { session: { id: session.id }, role: 'system', content: (0, typeorm_1.Like)(`${proposal_outcome_js_1.PROPOSAL_OUTCOME_PREFIX}%`) }
|
|
611
|
+
});
|
|
612
|
+
if ((0, proposal_outcome_js_1.proposalAlreadyTaken)(key, lines.map(line => line.content)))
|
|
613
|
+
return false;
|
|
614
|
+
const previousLast = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).findOne({
|
|
615
|
+
where: { session: { id: session.id } },
|
|
616
|
+
order: { createdAt: 'DESC' }
|
|
617
|
+
});
|
|
618
|
+
const row = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).save({
|
|
619
|
+
session: { id: session.id },
|
|
620
|
+
role: 'system',
|
|
621
|
+
content: (0, proposal_outcome_js_1.formatProposalRequestedLine)(key),
|
|
622
|
+
creator: user,
|
|
623
|
+
updater: user,
|
|
624
|
+
parentMessage: previousLast ? { id: previousLast.id } : undefined
|
|
625
|
+
});
|
|
626
|
+
(0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message: row });
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
588
629
|
/**
|
|
589
630
|
* Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
|
|
590
631
|
* Passes the write gate - a private conversation takes files from its people only - and the mode's
|
|
@@ -989,7 +1030,7 @@ tslib_1.__decorate([
|
|
|
989
1030
|
(0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
|
|
990
1031
|
tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
|
|
991
1032
|
tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
|
|
992
|
-
tslib_1.__param(2, (0, type_graphql_1.Arg)('name', { nullable: true, description: 'Optional name.
|
|
1033
|
+
tslib_1.__param(2, (0, type_graphql_1.Arg)('name', { nullable: true, description: 'Optional name. If omitted, the first question names the conversation.' })),
|
|
993
1034
|
tslib_1.__param(3, (0, type_graphql_1.Ctx)()),
|
|
994
1035
|
tslib_1.__metadata("design:type", Function),
|
|
995
1036
|
tslib_1.__metadata("design:paramtypes", [String, String, String, Object]),
|
|
@@ -1022,6 +1063,19 @@ tslib_1.__decorate([
|
|
|
1022
1063
|
tslib_1.__metadata("design:paramtypes", [String, String, Object, Object]),
|
|
1023
1064
|
tslib_1.__metadata("design:returntype", Promise)
|
|
1024
1065
|
], ChatSessionResolver.prototype, "reportProposalOutcome", null);
|
|
1066
|
+
tslib_1.__decorate([
|
|
1067
|
+
(0, type_graphql_1.Directive)('@transaction'),
|
|
1068
|
+
(0, type_graphql_1.Mutation)(() => Boolean, {
|
|
1069
|
+
description: "Ask before running a proposal: true when this conversation has not taken it yet, and the request is recorded as a system row; false when it was already requested or answered - then the host must not be asked again. The card reads its state from these rows, so a refreshed panel or another window does not offer the same proposal twice (ADR-0097)."
|
|
1070
|
+
}),
|
|
1071
|
+
(0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
|
|
1072
|
+
tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
|
|
1073
|
+
tslib_1.__param(1, (0, type_graphql_1.Arg)('proposalKey')),
|
|
1074
|
+
tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
|
|
1075
|
+
tslib_1.__metadata("design:type", Function),
|
|
1076
|
+
tslib_1.__metadata("design:paramtypes", [String, String, Object]),
|
|
1077
|
+
tslib_1.__metadata("design:returntype", Promise)
|
|
1078
|
+
], ChatSessionResolver.prototype, "requestProposal", null);
|
|
1025
1079
|
tslib_1.__decorate([
|
|
1026
1080
|
(0, type_graphql_1.Directive)('@transaction'),
|
|
1027
1081
|
(0, type_graphql_1.Mutation)(() => [ChatAttachmentInfo], {
|