@things-factory/ai-assistant 10.1.125 → 10.1.129
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 +122 -68
- package/client/components/chat-echo-dedup.ts +2 -1
- package/client/components/reply-message.test.ts +15 -0
- package/client/components/reply-message.ts +11 -0
- package/client/components/roster-identity.test.ts +18 -0
- package/client/components/roster-identity.ts +10 -0
- package/dist-client/components/assistant-chat.d.ts +7 -0
- package/dist-client/components/assistant-chat.js +127 -68
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +1 -0
- package/dist-client/components/chat-echo-dedup.js +1 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/reply-message.d.ts +5 -0
- package/dist-client/components/reply-message.js +14 -0
- package/dist-client/components/reply-message.js.map +1 -0
- package/dist-client/components/reply-message.test.d.ts +1 -0
- package/dist-client/components/reply-message.test.js +16 -0
- package/dist-client/components/reply-message.test.js.map +1 -0
- package/dist-client/components/roster-identity.d.ts +11 -0
- package/dist-client/components/roster-identity.js +11 -0
- package/dist-client/components/roster-identity.js.map +1 -0
- package/dist-client/components/roster-identity.test.d.ts +1 -0
- package/dist-client/components/roster-identity.test.js +20 -0
- package/dist-client/components/roster-identity.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/chat-session-participant/read-roster.d.ts +9 -0
- package/dist-server/service/chat-session-participant/read-roster.js +15 -0
- package/dist-server/service/chat-session-participant/read-roster.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +1 -1
- package/dist-server/service/chat-session-resolver.js +15 -11
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/chat-session-participant/read-roster.test.ts +18 -0
- package/server/service/chat-session-participant/read-roster.ts +14 -0
- package/server/service/chat-session-resolver.ts +11 -8
- package/translations/en.json +10 -2
- package/translations/ja.json +1 -1
- package/translations/ko.json +10 -2
- package/translations/ms.json +1 -1
- package/translations/zh.json +1 -1
|
@@ -22,6 +22,7 @@ export function remoteMessageToLine(m) {
|
|
|
22
22
|
attachments: Array.isArray(m.attachmentsJson)
|
|
23
23
|
? m.attachmentsJson.map((a) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))
|
|
24
24
|
: undefined,
|
|
25
|
+
senderId: m.creator?.id ?? undefined,
|
|
25
26
|
senderEmail: m.creator?.email ?? undefined,
|
|
26
27
|
senderName: m.creator?.name ?? undefined,
|
|
27
28
|
/*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** The answer's proposals, stored with it - the cards come back after a refresh (ADR-0097 Q-D4 ④). */\n proposalsJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n /** 이 사람이 이 답의 문을 못 지나 본문이 한 줄로 바뀌어 왔나(ADR-0073 판정 1). */\n redacted?: boolean\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n proposals: Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n /* Files sent with the turn (shared dock §9) - opened through the conversation's own route when drawn. */\n attachments: Array.isArray((m as any).attachmentsJson)\n ? (m as any).attachmentsJson.map((a: any) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))\n : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined,\n /*\n * 서버가 이 사람에게 값을 안 내준 줄 — ADR-0073 판정 1. `content` 에 온 것은 어시스턴트가 쓴\n * 문장이 아니라 「권한이 필요합니다」 한 줄이다. 그대로 말풍선에 넣으면 어시스턴트가 그렇게 말한\n * 것처럼 읽힌다.\n */\n redacted: m.redacted === true\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n proposals: nextLines[idx].proposals ?? (Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined),\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n /* 같은 서버 id 를 든 **다른** 줄 — 에코가 흡수되지 못하고 새 줄로 붙은 경우다. */\n const echoed = serverId ? lines.findIndex((l, i) => i !== idx && l.id === serverId) : -1\n\n /*\n * 대기 줄이 없어졌다 — 보내는 동안 대화가 지워졌거나 이력으로 갈렸다. 그래도 **응답이 어느 메시지의\n * 것인지는 안다**(`assistantMessageId`). 그 줄에 합친다.\n *\n * 여기서 그냥 돌아가면, 저장되는 값(본문 · 도구 사용)은 에코가 들고 있으니 화면은 멀쩡해 보이고\n * **저장되지 않는 것만 사라진다** — 제안 카드다. 모델은 「카드를 눌러 적용하세요」라고 말하고 화면에는\n * 누를 것이 없다(2026-09-18 트윈에서 실제로 그랬다).\n */\n if (idx < 0) {\n if (echoed < 0) return { lines, seen }\n const merged = [...lines]\n merged[echoed] = { ...merged[echoed], ...patch, id: serverId ?? merged[echoed].id }\n return { lines: merged, seen: serverId ? new Set(seen).add(serverId) : seen }\n }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (echoed >= 0) {\n /* 남는 것은 에코 줄이다. 응답에만 있는 것(제안)을 그 줄에 옮기고 대기 줄을 지운다. */\n nextLines[echoed] = { ...nextLines[echoed], ...patch, id: serverId ?? nextLines[echoed].id }\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** The answer's proposals, stored with it - the cards come back after a refresh (ADR-0097 Q-D4 ④). */\n proposalsJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { id?: string; name?: string; email?: string } | null\n /** 이 사람이 이 답의 문을 못 지나 본문이 한 줄로 바뀌어 왔나(ADR-0073 판정 1). */\n redacted?: boolean\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n proposals: Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n /* Files sent with the turn (shared dock §9) - opened through the conversation's own route when drawn. */\n attachments: Array.isArray((m as any).attachmentsJson)\n ? (m as any).attachmentsJson.map((a: any) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))\n : undefined,\n senderId: m.creator?.id ?? undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined,\n /*\n * 서버가 이 사람에게 값을 안 내준 줄 — ADR-0073 판정 1. `content` 에 온 것은 어시스턴트가 쓴\n * 문장이 아니라 「권한이 필요합니다」 한 줄이다. 그대로 말풍선에 넣으면 어시스턴트가 그렇게 말한\n * 것처럼 읽힌다.\n */\n redacted: m.redacted === true\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n proposals: nextLines[idx].proposals ?? (Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined),\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n /* 같은 서버 id 를 든 **다른** 줄 — 에코가 흡수되지 못하고 새 줄로 붙은 경우다. */\n const echoed = serverId ? lines.findIndex((l, i) => i !== idx && l.id === serverId) : -1\n\n /*\n * 대기 줄이 없어졌다 — 보내는 동안 대화가 지워졌거나 이력으로 갈렸다. 그래도 **응답이 어느 메시지의\n * 것인지는 안다**(`assistantMessageId`). 그 줄에 합친다.\n *\n * 여기서 그냥 돌아가면, 저장되는 값(본문 · 도구 사용)은 에코가 들고 있으니 화면은 멀쩡해 보이고\n * **저장되지 않는 것만 사라진다** — 제안 카드다. 모델은 「카드를 눌러 적용하세요」라고 말하고 화면에는\n * 누를 것이 없다(2026-09-18 트윈에서 실제로 그랬다).\n */\n if (idx < 0) {\n if (echoed < 0) return { lines, seen }\n const merged = [...lines]\n merged[echoed] = { ...merged[echoed], ...patch, id: serverId ?? merged[echoed].id }\n return { lines: merged, seen: serverId ? new Set(seen).add(serverId) : seen }\n }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (echoed >= 0) {\n /* 남는 것은 에코 줄이다. 응답에만 있는 것(제안)을 그 줄에 옮기고 대기 줄을 지운다. */\n nextLines[echoed] = { ...nextLines[echoed], ...patch, id: serverId ?? nextLines[echoed].id }\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function replyDestination(role, hasUserMention) {
|
|
2
|
+
if (role === 'assistant')
|
|
3
|
+
return 'assistant';
|
|
4
|
+
if (role === 'user')
|
|
5
|
+
return 'room';
|
|
6
|
+
return hasUserMention ? 'room' : 'assistant';
|
|
7
|
+
}
|
|
8
|
+
export function quotedReply(body, reply) {
|
|
9
|
+
if (!reply)
|
|
10
|
+
return body;
|
|
11
|
+
const escape = (text) => text.replace(/[\\`*_{}\[\]<>#]/g, char => `\\${char}`);
|
|
12
|
+
return `> ${escape(reply.name)}: ${escape(reply.excerpt)}\n\n${body}`;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=reply-message.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reply-message.js","sourceRoot":"","sources":["../../client/components/reply-message.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,gBAAgB,CAAC,IAAwB,EAAE,cAAuB;IAChF,IAAI,IAAI,KAAK,WAAW;QAAE,OAAO,WAAW,CAAA;IAC5C,IAAI,IAAI,KAAK,MAAM;QAAE,OAAO,MAAM,CAAA;IAClC,OAAO,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAA;AAC9C,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,KAAyC;IACjF,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,MAAM,MAAM,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,mBAAmB,EAAE,IAAI,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAA;IACvF,OAAO,KAAK,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAA;AACvE,CAAC","sourcesContent":["export function replyDestination(role: string | undefined, hasUserMention: boolean): 'room' | 'assistant' {\n if (role === 'assistant') return 'assistant'\n if (role === 'user') return 'room'\n return hasUserMention ? 'room' : 'assistant'\n}\n\nexport function quotedReply(body: string, reply?: { name: string; excerpt: string }): string {\n if (!reply) return body\n const escape = (text: string) => text.replace(/[\\\\`*_{}\\[\\]<>#]/g, char => `\\\\${char}`)\n return `> ${escape(reply.name)}: ${escape(reply.excerpt)}\\n\\n${body}`\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { replyDestination, quotedReply } from './reply-message';
|
|
2
|
+
describe('message replies', () => {
|
|
3
|
+
it('routes a human reply to the room and an AI reply to AI even with an earlier mention', () => {
|
|
4
|
+
expect(replyDestination('user', false)).toBe('room');
|
|
5
|
+
expect(replyDestination('assistant', true)).toBe('assistant');
|
|
6
|
+
expect(replyDestination(undefined, true)).toBe('room');
|
|
7
|
+
expect(replyDestination(undefined, false)).toBe('assistant');
|
|
8
|
+
});
|
|
9
|
+
it('keeps a short quoted original above the answer and escapes markup', () => {
|
|
10
|
+
expect(quotedReply('answer', { name: 'Kim', excerpt: 'original' })).toBe('> Kim: original\n\nanswer');
|
|
11
|
+
expect(quotedReply('answer')).toBe('answer');
|
|
12
|
+
expect(quotedReply('answer', { name: '<Kim>', excerpt: '**text**' })).toContain('\\<Kim\\>');
|
|
13
|
+
expect(quotedReply('answer', { name: 'Kim', excerpt: '**text**' })).toContain('\\*\\*text\\*\\*');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
//# sourceMappingURL=reply-message.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reply-message.test.js","sourceRoot":"","sources":["../../client/components/reply-message.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC/D,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE;IAC/B,EAAE,CAAC,qFAAqF,EAAE,GAAG,EAAE;QAC7F,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACpD,MAAM,CAAC,gBAAgB,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC7D,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACtD,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IACF,EAAE,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC3E,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAC,CAAC,CAAC,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAA;QACnG,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC5C,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAC,CAAC,CAAC,CAAC,SAAS,CAAC,WAAW,CAAC,CAAA;QAC1F,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAC,CAAC,CAAC,CAAC,SAAS,CAAC,kBAAkB,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { replyDestination, quotedReply } from './reply-message'\ndescribe('message replies', () => {\n it('routes a human reply to the room and an AI reply to AI even with an earlier mention', () => {\n expect(replyDestination('user', false)).toBe('room')\n expect(replyDestination('assistant', true)).toBe('assistant')\n expect(replyDestination(undefined, true)).toBe('room')\n expect(replyDestination(undefined, false)).toBe('assistant')\n })\n it('keeps a short quoted original above the answer and escapes markup', () => {\n expect(quotedReply('answer', {name: 'Kim', excerpt: 'original'})).toBe('> Kim: original\\n\\nanswer')\n expect(quotedReply('answer')).toBe('answer')\n expect(quotedReply('answer', {name: '<Kim>', excerpt: '**text**'})).toContain('\\\\<Kim\\\\>')\n expect(quotedReply('answer', {name: 'Kim', excerpt: '**text**'})).toContain('\\\\*\\\\*text\\\\*\\\\*')\n })\n})\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RosterParticipant {
|
|
2
|
+
role: string;
|
|
3
|
+
user?: {
|
|
4
|
+
id: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Missing identity/loading is not evidence that a person lacks membership. Server gates remain authoritative. */
|
|
10
|
+
export declare function rosterInviteState(rows: readonly RosterParticipant[], userId: string | undefined, loaded: boolean): 'checking' | 'allowed' | 'denied';
|
|
11
|
+
export declare function rosterName(person: RosterParticipant, fallback: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Missing identity/loading is not evidence that a person lacks membership. Server gates remain authoritative. */
|
|
2
|
+
export function rosterInviteState(rows, userId, loaded) {
|
|
3
|
+
if (!loaded || !userId || rows.some(row => !row.user?.id))
|
|
4
|
+
return 'checking';
|
|
5
|
+
const role = rows.find(row => row.user?.id === userId)?.role;
|
|
6
|
+
return role === 'owner' || role === 'member' ? 'allowed' : 'denied';
|
|
7
|
+
}
|
|
8
|
+
export function rosterName(person, fallback) {
|
|
9
|
+
return person.user?.name?.trim() || person.user?.username?.trim() || fallback;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=roster-identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roster-identity.js","sourceRoot":"","sources":["../../client/components/roster-identity.ts"],"names":[],"mappings":"AACA,kHAAkH;AAClH,MAAM,UAAU,iBAAiB,CAAC,IAAkC,EAAE,MAA0B,EAAE,MAAe;IAC/G,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;QAAE,OAAO,UAAU,CAAA;IAC5E,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,KAAK,MAAM,CAAC,EAAE,IAAI,CAAA;IAC5D,OAAO,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAA;AACrE,CAAC;AACD,MAAM,UAAU,UAAU,CAAC,MAAyB,EAAE,QAAgB;IACpE,OAAO,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,QAAQ,CAAA;AAC/E,CAAC","sourcesContent":["export interface RosterParticipant { role: string; user?: { id: string; name?: string; username?: string } }\n/** Missing identity/loading is not evidence that a person lacks membership. Server gates remain authoritative. */\nexport function rosterInviteState(rows: readonly RosterParticipant[], userId: string | undefined, loaded: boolean): 'checking' | 'allowed' | 'denied' {\n if (!loaded || !userId || rows.some(row => !row.user?.id)) return 'checking'\n const role = rows.find(row => row.user?.id === userId)?.role\n return role === 'owner' || role === 'member' ? 'allowed' : 'denied'\n}\nexport function rosterName(person: RosterParticipant, fallback: string): string {\n return person.user?.name?.trim() || person.user?.username?.trim() || fallback\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { rosterInviteState, rosterName } from './roster-identity';
|
|
2
|
+
describe('roster identity and invite notice', () => {
|
|
3
|
+
it('does not claim denial when participant identities are missing or still loading', () => {
|
|
4
|
+
expect(rosterInviteState([{ role: 'owner' }], 'me', true)).toBe('checking');
|
|
5
|
+
expect(rosterInviteState([], 'me', false)).toBe('checking');
|
|
6
|
+
expect(rosterInviteState([], undefined, true)).toBe('checking');
|
|
7
|
+
});
|
|
8
|
+
it('uses the current person role rather than another member or a stale loading state', () => {
|
|
9
|
+
for (const role of ['owner', 'member'])
|
|
10
|
+
expect(rosterInviteState([{ role, user: { id: 'me' } }], 'me', true)).toBe('allowed');
|
|
11
|
+
expect(rosterInviteState([{ role: 'viewer', user: { id: 'me' } }, { role: 'owner', user: { id: 'other' } }], 'me', true)).toBe('denied');
|
|
12
|
+
expect(rosterInviteState([{ role: 'member', user: { id: 'other' } }], 'me', true)).toBe('denied');
|
|
13
|
+
});
|
|
14
|
+
it('shows a recognizable full name, then username, then an explicit unavailable label', () => {
|
|
15
|
+
expect(rosterName({ role: 'member', user: { id: 'u', name: ' 정연희 ' } }, 'Unavailable')).toBe('정연희');
|
|
16
|
+
expect(rosterName({ role: 'member', user: { id: 'u', username: 'yeonhee' } }, 'Unavailable')).toBe('yeonhee');
|
|
17
|
+
expect(rosterName({ role: 'member' }, 'Unavailable')).toBe('Unavailable');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
//# sourceMappingURL=roster-identity.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roster-identity.test.js","sourceRoot":"","sources":["../../client/components/roster-identity.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACjE,QAAQ,CAAC,mCAAmC,EAAC,GAAE,EAAE;IAChD,EAAE,CAAC,gFAAgF,EAAC,GAAE,EAAE;QACvF,MAAM,CAAC,iBAAiB,CAAC,CAAC,EAAC,IAAI,EAAC,OAAO,EAAC,CAAC,EAAC,IAAI,EAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;QACtE,MAAM,CAAC,iBAAiB,CAAC,EAAE,EAAE,IAAI,EAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;QAC1D,MAAM,CAAC,iBAAiB,CAAC,EAAE,EAAE,SAAS,EAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IAC/D,CAAC,CAAC,CAAA;IACF,EAAE,CAAC,kFAAkF,EAAC,GAAE,EAAE;QACzF,KAAI,MAAM,IAAI,IAAI,CAAC,OAAO,EAAC,QAAQ,CAAC;YAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,EAAC,IAAI,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,IAAI,EAAC,EAAC,CAAC,EAAC,IAAI,EAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QACjH,MAAM,CAAC,iBAAiB,CAAC,CAAC,EAAC,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,IAAI,EAAC,EAAC,EAAC,EAAC,IAAI,EAAC,OAAO,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,OAAO,EAAC,EAAC,CAAC,EAAC,IAAI,EAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACrH,MAAM,CAAC,iBAAiB,CAAC,CAAC,EAAC,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,OAAO,EAAC,EAAC,CAAC,EAAC,IAAI,EAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACxF,CAAC,CAAC,CAAA;IACF,EAAE,CAAC,mFAAmF,EAAC,GAAE,EAAE;QAC1F,MAAM,CAAC,UAAU,CAAC,EAAC,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,GAAG,EAAC,IAAI,EAAC,OAAO,EAAC,EAAC,EAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACxF,MAAM,CAAC,UAAU,CAAC,EAAC,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,EAAC,EAAE,EAAC,GAAG,EAAC,QAAQ,EAAC,SAAS,EAAC,EAAC,EAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QAClG,MAAM,CAAC,UAAU,CAAC,EAAC,IAAI,EAAC,QAAQ,EAAC,EAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;IACtE,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA","sourcesContent":["import { rosterInviteState, rosterName } from './roster-identity'\ndescribe('roster identity and invite notice',()=>{\n it('does not claim denial when participant identities are missing or still loading',()=>{\n expect(rosterInviteState([{role:'owner'}],'me',true)).toBe('checking')\n expect(rosterInviteState([], 'me',false)).toBe('checking')\n expect(rosterInviteState([], undefined,true)).toBe('checking')\n })\n it('uses the current person role rather than another member or a stale loading state',()=>{\n for(const role of ['owner','member'])expect(rosterInviteState([{role,user:{id:'me'}}],'me',true)).toBe('allowed')\n expect(rosterInviteState([{role:'viewer',user:{id:'me'}},{role:'owner',user:{id:'other'}}],'me',true)).toBe('denied')\n expect(rosterInviteState([{role:'member',user:{id:'other'}}],'me',true)).toBe('denied')\n })\n it('shows a recognizable full name, then username, then an explicit unavailable label',()=>{\n expect(rosterName({role:'member',user:{id:'u',name:' 정연희 '}},'Unavailable')).toBe('정연희')\n expect(rosterName({role:'member',user:{id:'u',username:'yeonhee'}},'Unavailable')).toBe('yeonhee')\n expect(rosterName({role:'member'},'Unavailable')).toBe('Unavailable')\n })\n})\n"]}
|