From 878c507c1c92248d863c83917187cdbb1e2c8127 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 17 Oct 2026 10:38:10 +0800 Subject: [PATCH] emrg: the GUI rows carry the moment the daemon recorded, on live and replay alike --- Agent.md | 4 +- emrg/gui/daemon_client.js | 8 +++ emrg/gui/renderer/css/components.css | 12 ++++ emrg/gui/renderer/src/components/Composer.tsx | 9 ++- .../src/components/TranscriptView.test.tsx | 45 +++++++++++++ .../src/components/TranscriptView.tsx | 34 +++++++++- .../gui/renderer/src/lib/daemonBridge.test.ts | 65 +++++++++++++++++++ emrg/gui/renderer/src/lib/daemonBridge.ts | 17 +++++ .../renderer/src/lib/historyReplay.test.ts | 49 +++++++++++++- emrg/gui/renderer/src/lib/historyReplay.ts | 32 +++++++-- emrg/gui/renderer/src/lib/transcript.test.ts | 51 +++++++++++++++ emrg/gui/renderer/src/lib/transcript.ts | 58 ++++++++++++++++- emrg/gui/renderer/src/lib/utils.test.ts | 29 ++++++++- emrg/gui/renderer/src/lib/utils.ts | 37 +++++++++++ emrg/gui/test/daemon_client.test.js | 4 +- 15 files changed, 437 insertions(+), 17 deletions(-) diff --git a/Agent.md b/Agent.md index 8137dce78..91fb5d874 100644 --- a/Agent.md +++ b/Agent.md @@ -10,7 +10,7 @@ EMRG is a self-evolving AI agent architecture experiment: a **micro-kernel** — **The server is the living core; a client is only the interface.** Task understanding, tool scheduling, capability growth and self-evolution live in the server; one server serves many clients and outlives any of them. -- `emrg/` — core: `__main__.py` (CLI), `protocol.py`, `config.py` (`~/.emrg/config.toml`), `connect.py` (WebSocket over loopback, token auth) +- `emrg/` — core: `__main__.py` (CLI), `protocol.py`, `config.py`, `connect.py` (WebSocket over loopback, token auth) - `emrg/server/` — the daemon: `daemon.py` (messages, the background thread running evolution, the tool loop), `llm.py`, `tool_types.py`, task templates - `emrg/tools/` — bash, read, write, edit, glob, grep, base + registry - `emrg/skills/` — skills from `.emrg/skills/` (progressive disclosure) + the installable-skill catalog @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (156: 95 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (666: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 24 transcript + 23 TranscriptView + 22 history + 18 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 45 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (684: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 20 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index ae84fd263..ba5567183 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -1103,6 +1103,14 @@ class DaemonClient { this._emit(frame.type, frame); return; } + if (frame.type === "user_message") { + // 新消息落盘的回执(rant 2026-10-17T10:03:08):daemon 在写用户记录的同一处 + // `_broadcast` 它,带 `request_id` 与 `timestamp` —— 那一刻只有 daemon 知道, + // 渲染层据此给乐观画出来的那一行补上时间。此前本层没有分支,它落进下面的兜底 + // 只记一行日志,于是 GUI 的用户消息永远没有时刻(TUI 一直在用这个帧)。 + this._emit(frame.type, frame); + return; + } if (frame.type === "task_queued" || frame.type === "steer_committed") { // The same seam as the turn frames above, for two more types (#2189). The // daemon `_broadcast`s both to every connection subscribed to the session diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 5de30eb49..4e7c9813e 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -88,6 +88,18 @@ color: var(--accent); animation: cursor-pulse 1s ease-in-out infinite; } +/* 消息行上的时钟(rant 2026-10-17T10:03:08):dim、等宽数字,不抢正文。 + 元素只在 daemon 真的给了时刻时才存在(`formatMessageTime` 反问可读性), + 所以这里没有「空值形态」需要兜。 */ +.msg-time { + display: block; + margin-top: 2px; + font-size: var(--fs-aux); + color: var(--text-3); + font-variant-numeric: tabular-nums; + user-select: none; +} + .msg.system { text-align: center; font-size: var(--fs-secondary); diff --git a/emrg/gui/renderer/src/components/Composer.tsx b/emrg/gui/renderer/src/components/Composer.tsx index eb1392905..edf36d798 100644 --- a/emrg/gui/renderer/src/components/Composer.tsx +++ b/emrg/gui/renderer/src/components/Composer.tsx @@ -610,13 +610,16 @@ export function Composer({ // 回合结束未注入则 queued_requeue 以原 requestId 重发。busy 时记录待重发条目。 const wasBusy = busy; setInternalBusy(true); - store.addUserMessage(value, sid); - editor.commands.clearContent(); - // B3:消息已发送 → 清除草稿(tiptap 内容即草稿,发送即清) // G143:send 前预生成 requestId——消除 IPC 往返竞态窗口。(G143 原本还用它给消息打 // 「自有流」标;那是客户端自持的“谁的消息”判定,已按 rant 2026-09-29T15:52:49 要求 3 // 移除:daemon 是唯一状态源,客户端只是渲染器。) + // 铸在这里、并在插入消息行时就交给它:这一行要靠这个 id 认领 daemon 随后送来的 + // `user_message` / `steer_committed` 帧里的时刻(rant 2026-10-17T10:03:08)—— + // 客户端自己不知道这条消息是什么时候被记下的,也不许编一个。 const requestId = genRequestId(); + store.addUserMessage(value, sid, { requestId }); + editor.commands.clearContent(); + // B3:消息已发送 → 清除草稿(tiptap 内容即草稿,发送即清) if (wasBusy) { queueSend(queuedRef.current, sid, { requestId, text: value, sandbox: tier, images }); } diff --git a/emrg/gui/renderer/src/components/TranscriptView.test.tsx b/emrg/gui/renderer/src/components/TranscriptView.test.tsx index 07a267b2a..8ad0b3bf5 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.test.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.test.tsx @@ -541,3 +541,48 @@ describe("TranscriptView", () => { expect(viewport.scrollTop).toBe(0); }); }); + +/** + * 消息行上的时刻(rant 2026-10-17T10:03:08 → issue #2340)。断言读的是**渲染产物**: + * 类型上有没有字段、store 里有没有值,都不算 —— 宿主看的是屏幕上那一行。 + */ +describe("消息时刻", () => { + it("用户行与助手行各显示 daemon 给的那个 HH:MM", async () => { + const store = createTranscriptStore({ t: (k) => k }); + store.addUserMessage("hi", "s1", { + requestId: "req-1", + timestamp: "2026-10-17T10:27:55.123456+08:00", + }); + store.handleDelta([{ request_id: "req-1", content: "hello" }], "s1"); + store.handleDone({ request_id: "req-1", timestamp: "2026-10-17T10:28:09+08:00" }, "s1"); + const { container } = setup(store, "s1"); + + const times = Array.from(container.querySelectorAll(".msg-time")).map((el) => el.textContent); + expect(times).toEqual(["10:27", "10:28"]); + // 完整日期挂在悬停上,行上只留时钟 + expect(container.querySelector(".msg.user .msg-time")!.getAttribute("title")).toBe( + "2026-10-17 10:27", + ); + // 助手正文照旧渲染(时间元素没把消息顶掉) + await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument()); + }); + + it("没有时刻的行不渲染任何时间元素:不是空括号,不是 Invalid Date,不是 NaN", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.addUserMessage("no clock", "s1"); // 帧还没来(或本就没有时刻的老记录) + store.handleDelta([{ request_id: "r1", content: "reply" }], "s1"); + store.handleDone({ request_id: "r1", timeout: true }, "s1"); // 取消:没有回复落盘 + const { container } = setup(store, "s1"); + + expect(container.querySelector(".msg-time")).toBeNull(); + const text = container.textContent ?? ""; + expect(text).not.toMatch(/Invalid|NaN|\(\)|()|\[\]/); + }); + + it("坏时刻同样什么都不画(记录可读性由格式器裁决,视图不做第二次猜测)", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.addUserMessage("bad clock", "s1", { requestId: "req-1", timestamp: "not a date" }); + const { container } = setup(store, "s1"); + expect(container.querySelector(".msg-time")).toBeNull(); + }); +}); diff --git a/emrg/gui/renderer/src/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx index 58e80a50b..d8937eedd 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -11,6 +11,7 @@ import { } from "../lib/transcript"; import { createMarkdownRenderer, type MarkdownRenderer, type StreamState } from "../lib/markdown"; import { canLoadOlder, scrollCompensation, shouldLoadOlder } from "../lib/history"; +import { formatMessageTime, messageTimeTitle } from "../lib/utils"; import { toolPhrases } from "../lib/copywriting"; import { useI18n, type TranslateFn } from "../lib/i18n"; import { MarkdownText } from "./MarkdownText"; @@ -228,6 +229,7 @@ function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNo return (
+
); case "system": @@ -241,12 +243,38 @@ function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNo } } +/** + * 消息行上的时刻(rant 2026-10-17T10:03:08):`HH:MM`,值只来自 daemon。 + * + * 缺时刻/坏时刻渲染成**没有这个元素**,而不是空括号、`Invalid Date` 或 `NaN` —— + * 老记录本就没有时间,而“缺陷只在渲染里看得见”正是空串要避免的。完整日期在 `title` 上, + * 行上只留时钟(与 TUI 同口径)。 + */ +function MessageTime({ stamp }: { stamp?: string }) { + const clock = formatMessageTime(stamp); + if (!clock) return null; + return ( + + {clock} + + ); +} + /** 助手消息:每文本段一个 .msg.assistant 节点(chat.js createAssistantNode 语义) */ function AssistantView({ entry, t, md }: { entry: AssistantEntry; t: TranslateFn; md: MarkdownRenderer }) { + const last = entry.segments.length - 1; return ( <> {entry.segments.map((seg, i) => ( - + ))} ); @@ -266,11 +294,14 @@ function AssistantSegmentView({ segment, t, md, + stamp, }: { entry: AssistantEntry; segment: AssistantSegment; t: TranslateFn; md: MarkdownRenderer; + /** daemon 写下这条回复的时刻(只在最后一段传入);缺席则这一行不显示时间 */ + stamp?: string; }) { // 块投影 host:引擎把「✦ + .md-stream」写入此节点(streamProject 返回 true 后显示; // React 不管理其 children,避免与引擎增量 DOM 冲突) @@ -335,6 +366,7 @@ function AssistantSegmentView({ )} + ); } diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts index c2124e04e..c6dd2c2fb 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.test.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -622,3 +622,68 @@ describe("createDaemonBridge", () => { }); }); + +/** + * 时刻从帧走到行上(rant 2026-10-17T10:03:08 → issue #2340)。daemon 早就把时刻发出来了; + * 这里守住中间这层:`done` / `user_message` / `steer_committed` 三个帧各自把 daemon 写的 + * 那个字符串放到对的那一行上,且只在帧真的带了它的时候。 + */ +describe("消息时刻的三个来源", () => { + const stampOf = (store: ReturnType, sid: string, kind: "user" | "assistant") => { + const e = store.getEntries(sid).find((x) => x.kind === kind) as { timestamp?: string } | undefined; + return e?.timestamp; + }; + + it("done 帧的 timestamp 落到助手行上", () => { + const { bridge, transcript } = setup(); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "hi" }] }, sid: "s1" }); + bridge.handleFrame({ type: "done", data: { request_id: "r1", timestamp: "2026-10-17T10:28:09+08:00" }, sid: "s1" }); + expect(stampOf(transcript, "s1", "assistant")).toBe("2026-10-17T10:28:09+08:00"); + }); + + it("user_message 帧按 request_id 给那一行补时刻,不碰别的行", () => { + const { bridge, transcript } = setup(); + transcript.addUserMessage("mine", "s1", { requestId: "req-1" }); + transcript.addUserMessage("other", "s1", { requestId: "req-2" }); + bridge.handleFrame({ + type: "user_message", + data: { request_id: "req-1", timestamp: "2026-10-17T10:27:55+08:00" }, + sid: "s1", + }); + const rows = transcript.getEntries("s1") as Array<{ text: string; timestamp?: string }>; + expect(rows[0].timestamp).toBe("2026-10-17T10:27:55+08:00"); + expect(rows[1].timestamp).toBeUndefined(); + }); + + it("user_message 帧认不到那一行时什么也不做(另一个客户端发的消息)", () => { + const { bridge, transcript } = setup(); + transcript.addUserMessage("mine", "s1", { requestId: "req-1" }); + bridge.handleFrame({ + type: "user_message", + data: { request_id: "someone-else", timestamp: "2026-10-17T10:27:55+08:00" }, + sid: "s1", + }); + expect(stampOf(transcript, "s1", "user")).toBeUndefined(); + }); + + it("steer_committed 帧给排队的用户行补时刻(那一行从发送起就在屏幕上)", () => { + const { bridge, transcript } = setup(); + transcript.addUserMessage("queued", "s1", { requestId: "req-9" }); + bridge.handleFrame({ + type: "steer_committed", + data: { request_id: "req-9", timestamp: "2026-10-17T10:31:02+08:00" }, + sid: "s1", + }); + expect(stampOf(transcript, "s1", "user")).toBe("2026-10-17T10:31:02+08:00"); + }); + + it("帧里没有 timestamp 时不写任何值(缺席就是缺席,不拿空串冒充)", () => { + const { bridge, transcript } = setup(); + transcript.addUserMessage("mine", "s1", { requestId: "req-1" }); + bridge.handleFrame({ type: "user_message", data: { request_id: "req-1" }, sid: "s1" }); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "hi" }] }, sid: "s1" }); + bridge.handleFrame({ type: "done", data: { request_id: "r1" }, sid: "s1" }); + expect(stampOf(transcript, "s1", "user")).toBeUndefined(); + expect(stampOf(transcript, "s1", "assistant")).toBeUndefined(); + }); +}); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts index de1596477..86ab3b691 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -58,6 +58,11 @@ export interface QueuedData { position?: number; request_id?: string; request_ids?: string[]; + /** + * daemon 写下这条消息时那一刻。`steer_committed`(排队消息被注入当前回合)与 + * `user_message`(新消息落盘)都带它 —— 用户行的时刻只从这里来(rant 2026-10-17T10:03:08)。 + */ + timestamp?: string; } export interface ErrorData { message?: string; @@ -653,6 +658,15 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { })); break; } + case "user_message": { + // 新消息落盘的回执:这一行是客户端乐观画出来的,时刻只有 daemon 知道 + // (`daemon.py` 的 `user_message` 帧带 `request_id` + `timestamp`)。 + // 中间层此前把它当无人认领的帧丢掉,于是用户行的时钟永远空着 —— + // 现在按帧点名的那一行补上,别的客户端的行认不到就什么也不做。 + const rid = (data as QueuedData).request_id; + if (rid) transcript.stampUserMessage(rid, (data as QueuedData).timestamp, sid); + break; + } case "message_delta": transcript.handleDelta(data.chunks || [data], sid); break; @@ -691,6 +705,9 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { if (idx >= 0) q.splice(idx, 1); if (q.length === 0) queuedSends.delete(k); } + // 排队的消息被注入时,daemon 才把它写进会话并记下那一刻 —— 这一行从发送起就在 + // 屏幕上(乐观绘制),时间到这里才补得上(rant 2026-10-17T10:03:08)。 + if (rid) transcript.stampUserMessage(rid, (data as QueuedData).timestamp, sid); break; } case "queued_requeue": { diff --git a/emrg/gui/renderer/src/lib/historyReplay.test.ts b/emrg/gui/renderer/src/lib/historyReplay.test.ts index 09500409c..2545d3b8b 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.test.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { createTranscriptStore, formatToolArguments, type TranscriptEntry, type TranscriptStore } from "./transcript"; +import { createTranscriptStore, formatToolArguments, type AssistantEntry, type TranscriptEntry, type TranscriptStore } from "./transcript"; import { replayHistoryRecords, replayHistoryRecordsPrepend, toolCallIntent, type HistoryRecord } from "./historyReplay"; /** @@ -308,7 +308,9 @@ describe("the record shape the daemon actually sends", () => { SID, sink, ); - expect(sink.addUserMessage).toHaveBeenCalledWith("q", SID); + // 第三个参数是「这一行是谁、什么时候」:id 由 record_index 合成(本页没有 record_index, + // 于是落在 `hist-u`),记录没带时刻就不写这个键(rant 2026-10-17T10:03:08)。 + expect(sink.addUserMessage).toHaveBeenCalledWith("q", SID, { requestId: "hist-u" }); expect(sink.handleDelta).toHaveBeenCalledWith([{ request_id: "hist-u", content: "a" }], SID); expect(sink.handleDone).toHaveBeenCalledWith({ request_id: "hist-u" }, SID); }); @@ -396,3 +398,46 @@ describe("工具入参的回放一致性(#1787)", () => { expect(formatToolArguments(rowOf(live).arguments)).toContain('"command": "ls -la"'); }); }); + +/** + * 时刻在回放这一路的去向(rant 2026-10-17T10:03:08 → issue #2340):`timestamp` 曾经 + * 「类型上有、映射处被丢掉」,于是重开一个会话永远看不到时间。这里断言两件事: + * 记录里的时刻确实落到了条目上,且与实时那一轮落下的是**同一个值**。 + */ +describe("时刻:回放与实时同值", () => { + const USER_STAMP = "2026-10-17T10:27:55.123456+08:00"; + const REPLY_STAMP = "2026-10-17T10:28:09.987654+08:00"; + const records: HistoryRecord[] = [ + { kind: "message", record_index: 0, role: "user", content: "hi", timestamp: USER_STAMP }, + { kind: "message", record_index: 1, role: "assistant", content: "hello", timestamp: REPLY_STAMP }, + ]; + + it("记录里的时刻落到用户行与助手行上", () => { + const replayed = createTranscriptStore(); + replayHistoryRecords(records, SID, replayed); + const entries = replayed.getEntries(SID); + const user = entries.find((e) => e.kind === "user") as { timestamp?: string }; + const assistant = entries.find((e) => e.kind === "assistant") as AssistantEntry; + expect(user.timestamp).toBe(USER_STAMP); + expect(assistant.timestamp).toBe(REPLY_STAMP); + }); + + it("同一条消息:实时那一轮与重开会话后读到的是同一个时刻", () => { + // 实时:用户行先由客户端插入(只带 request id),时刻由 user_message 帧补;回复的时刻 + // 由收尾帧带着。两处给的都是 daemon 写在记录里的那个字符串。 + const live = createTranscriptStore(); + live.addUserMessage("hi", SID, { requestId: "req-1" }); + live.stampUserMessage("req-1", USER_STAMP, SID); + live.handleDelta([{ request_id: "req-1", content: "hello" }], SID); + live.handleDone({ request_id: "req-1", timestamp: REPLY_STAMP }, SID); + + const replayed = createTranscriptStore(); + replayHistoryRecords(records, SID, replayed); + + const stamps = (s: TranscriptStore) => + s.getEntries(SID).map((e) => + e.kind === "user" || e.kind === "assistant" ? e.timestamp : undefined, + ); + expect(stamps(replayed)).toEqual(stamps(live)); + }); +}); diff --git a/emrg/gui/renderer/src/lib/historyReplay.ts b/emrg/gui/renderer/src/lib/historyReplay.ts index a410b2a25..a7aa8d6a5 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.ts @@ -48,9 +48,17 @@ export type HistoryRecord = HistoryMessageRecord | HistoryToolResultRecord; /** 回放写入端:实时那一组 handler(TranscriptStore 直接满足)。 */ export interface ReplaySink { - addUserMessage(text: string, sid?: string | null): void; + /** + * `meta.timestamp` 是**记录里** daemon 写下的时刻(rant 2026-10-17T10:03:08)。回放把它 + * 原样交给 store,于是重开一个会话看到的时间与一直开着时同一条消息看到的一样 —— + * 客户端的时钟从头到尾没有参与。 + */ + addUserMessage(text: string, sid?: string | null, meta?: { requestId?: string; timestamp?: string }): void; handleDelta(chunks: Array<{ request_id?: string; content?: string }>, sid?: string | null): void; - handleDone(data: { request_id?: string; content?: string }, sid?: string | null): void; + handleDone( + data: { request_id?: string; content?: string; timestamp?: string }, + sid?: string | null, + ): void; handleToolStart( data: { request_id?: string; tool_call_id: string; tool_name: string; intent?: string; arguments?: unknown }, sid?: string | null, @@ -114,9 +122,17 @@ export function replayHistoryRecords( let rid: string | null = null; let turnOpen = false; + let assistantStamp: string | undefined; + const closeTurn = (): void => { - if (rid && turnOpen) sink.handleDone({ request_id: rid }, sid); + if (rid && turnOpen) { + sink.handleDone( + assistantStamp ? { request_id: rid, timestamp: assistantStamp } : { request_id: rid }, + sid, + ); + } turnOpen = false; + assistantStamp = undefined; }; for (const r of records) { @@ -124,7 +140,12 @@ export function replayHistoryRecords( // 实时顺序:上一轮的 done 先到,用户才接着发下一条。 closeTurn(); rid = `hist-${r.record_index ?? "u"}`; - sink.addUserMessage(r.content ?? "", sid); + // 用户行的时刻来自记录本身(此前 `timestamp` 类型上有、映射处被丢掉)。 + // meta 与实时那一路同形状:id 一定有,时刻有才写 —— 不塞 `timestamp: undefined`, + // 否则同一个「还没有时刻」在两路上是两个不同的对象。 + const meta: { requestId?: string; timestamp?: string } = { requestId: rid }; + if (r.timestamp) meta.timestamp = r.timestamp; + sink.addUserMessage(r.content ?? "", sid, meta); continue; } @@ -137,6 +158,9 @@ export function replayHistoryRecords( sink.handleDelta([{ request_id: rid, content }], sid); turnOpen = true; } + // 这一轮回复的时刻:收尾的 done 带上它,行上才显示得出来。空正文的助手记录 + // (只带 tool_calls 的那种)没有可见的行,记下来也无处显示,跳过。 + if (content && r.timestamp) assistantStamp = r.timestamp; for (const tc of r.tool_calls ?? []) { const callId = tc?.id ?? ""; const name = tc?.function?.name ?? ""; diff --git a/emrg/gui/renderer/src/lib/transcript.test.ts b/emrg/gui/renderer/src/lib/transcript.test.ts index 23870a284..d1c42feb3 100644 --- a/emrg/gui/renderer/src/lib/transcript.test.ts +++ b/emrg/gui/renderer/src/lib/transcript.test.ts @@ -362,3 +362,54 @@ describe("工具入参:卡片持有 + 可读呈现(#1787)", () => { expect(formatToolArguments(" ")).toBe(""); }); }); + +/** + * 消息的时刻(rant 2026-10-17T10:03:08 → issue #2340)。事实只有一条来源:daemon 写在帧里/ + * 记录里的那个字符串。客户端不盖章、不换算,缺席就留空。 + */ +describe("消息时刻", () => { + it("done 带来的时刻落在该 rid 的最后一段助手上(一轮被工具切成多段也只算一条消息)", () => { + const st = store(); + st.handleDelta([{ request_id: "r1", content: "first" }], "s1"); + st.handleToolStart({ request_id: "r1", tool_call_id: "c1", tool_name: "bash" }, "s1"); + st.handleToolEnd({ tool_call_id: "c1", tool_name: "bash", content: "ok" }, "s1"); + st.handleDelta([{ request_id: "r1", content: "second" }], "s1"); + st.handleDone({ request_id: "r1", timestamp: "2026-10-17T10:27:55+08:00" }, "s1"); + + const assistants = st.getEntries("s1").filter((e) => e.kind === "assistant") as AssistantEntry[]; + expect(assistants).toHaveLength(2); + // 时刻属于「这条回复」,不属于每一段:只有最后一段带它 + expect(assistants[0].timestamp).toBeUndefined(); + expect(assistants[1].timestamp).toBe("2026-10-17T10:27:55+08:00"); + }); + + it("收尾帧不带时刻 → 行上没有时间(取消 / 超轮次 / 出错本就没有回复落盘)", () => { + const st = store(); + st.handleDelta([{ request_id: "r1", content: "partial" }], "s1"); + st.handleDone({ request_id: "r1", timeout: true }, "s1"); + expect(assistantOf(st.getEntries("s1"), "r1")!.timestamp).toBeUndefined(); + // 连空串也不写:渲染层据此不画元素(空串与缺席在数据里可辨,在屏幕上都是「没有」) + expect(assistantOf(st.getEntries("s1"), "r1")!.timestamp ?? "").toBe(""); + }); + + it("用户行记下 request id,stampUserMessage 按 id 补 daemon 的时刻", () => { + const st = store(); + st.addUserMessage("mine", "s1", { requestId: "req-1" }); + st.addUserMessage("also mine", "s1", { requestId: "req-2" }); + st.stampUserMessage("req-2", "2026-10-17T10:28:01+08:00", "s1"); + const rows = st.getEntries("s1") as Array<{ kind: string; text: string; timestamp?: string }>; + expect(rows[0].timestamp).toBeUndefined(); + expect(rows[1].timestamp).toBe("2026-10-17T10:28:01+08:00"); + }); + + it("stampUserMessage 认不到那一行就什么都不做(别的客户端发的消息不该被盖成我的时刻)", () => { + const st = store(); + st.addUserMessage("mine", "s1", { requestId: "req-1" }); + const before = st.getVersion(); + st.stampUserMessage("req-unknown", "2026-10-17T10:28:01+08:00", "s1"); + const rows = st.getEntries("s1") as Array<{ timestamp?: string }>; + expect(rows[0].timestamp).toBeUndefined(); + // 没有盖章就不该通知订阅者:一次没发生的变更不该触发一次渲染 + expect(st.getVersion()).toBe(before); + }); +}); diff --git a/emrg/gui/renderer/src/lib/transcript.ts b/emrg/gui/renderer/src/lib/transcript.ts index b552593bf..0fa84fbe2 100644 --- a/emrg/gui/renderer/src/lib/transcript.ts +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -68,11 +68,26 @@ export interface AssistantEntry { kind: "assistant"; rid: string; segments: AssistantSegment[]; + /** + * 这条回复的时刻 —— **daemon 记下的那一个**,取自收尾帧的 `timestamp` + * (rant 2026-10-17T10:03:08)。只有该 rid 的**最后**一个助手段带它:一轮回复被工具 + * 切成多段时,时刻属于这条消息,不属于每一段。收尾帧不带时刻(取消/超轮次/出错, + * 那些结局本就不落盘回复)时留空,行上就不显示任何时间。 + */ + timestamp?: string; } export interface UserEntry { kind: "user"; text: string; + /** + * 这一行的时刻 —— daemon 写记录的那一刻(`user_message` / `steer_committed` 帧带来)。 + * 客户端自己点击发送时并不知道它,所以先留空、由帧补上;补不上的行(老记录、别的 + * 客户端发的)宁可没有时间,也不能由本地时钟编一个。 + */ + timestamp?: string; + /** 发起这一行的 request id —— 帧按它认领自己那一行(见 `stampUserMessage`)。 */ + rid?: string; } export interface SystemEntry { kind: "system"; @@ -129,6 +144,11 @@ export interface DoneData { request_id?: string; timeout?: boolean; content?: string; + /** + * 这条回复的时刻,与落盘记录里的是**同一个字符串**(`daemon.py` 收尾帧)。 + * 缺席 = 这一轮没有回复落盘(取消 / 超轮次 / 出错),行上就该没有时间。 + */ + timestamp?: string; } export interface ToolStartData { request_id?: string; @@ -172,7 +192,17 @@ export interface TranscriptStore { handleToolEnd(data: ToolEndData, sid?: string | null): void; clearTyping(sid?: string | null): void; clear(sid?: string | null): void; - addUserMessage(text: string, sid?: string | null): void; + /** + * 追加一条用户消息。`meta.requestId` 让后来到达的 `user_message` / `steer_committed` + * 帧认领这一行(`stampUserMessage`);`meta.timestamp` 只有回放那一路用得上 —— 那是 + * 记录里 daemon 写下的时刻,不是客户端时钟(rant 2026-10-17T10:03:08)。 + */ + addUserMessage(text: string, sid?: string | null, meta?: { requestId?: string; timestamp?: string }): void; + /** + * 给 `rid` 那一行用户消息补上 daemon 的时刻。找不到该行(例如别的客户端发的消息) + * 就什么也不做 —— 绝不退化成「给最后一行盖章」。 + */ + stampUserMessage(rid: string, timestamp?: string | null, sid?: string | null): void; addSystemMessage(text: string, sid?: string | null): void; /** * 把一段回放出来的条目整块插到最前(rant 2026-09-20T18:58:44:更早一页由实时 handler @@ -385,6 +415,10 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip const entry = s.entries[entryIndex]; if (entry && entry.kind === "assistant") { for (const seg of entry.segments) seg.typing = false; + // 收尾帧带来的时刻写到**这一段回复的行**上 —— groupIndex[rid] 指的正是该 rid + // 最后一个助手条目,工具把一轮回复切成多段时,时刻属于消息而非每段。 + // 帧不带时刻(取消/超轮次/出错,本就没有回复落盘)→ 行上留空。 + if (data.timestamp) entry.timestamp = data.timestamp; } s.groupIndex.delete(rid); // 渲染完成 → 移除映射(chat.js groupNodes.delete) } @@ -531,11 +565,29 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip handleToolEnd, clearTyping, clear, - addUserMessage: (text, sid) => { + addUserMessage: (text, sid, meta) => { mutate(() => { - st(sid).entries.push({ kind: "user", text }); + const entry: UserEntry = { kind: "user", text }; + if (meta?.requestId) entry.rid = meta.requestId; + if (meta?.timestamp) entry.timestamp = meta.timestamp; + st(sid).entries.push(entry); }); }, + stampUserMessage: (rid, timestamp, sid) => { + if (!rid || !timestamp) return; + const s = st(sid); + // 从后往前找:同一个 rid 只可能有一行,但重发过的行可能重复,取最近的那一行。 + for (let i = s.entries.length - 1; i >= 0; i--) { + const e = s.entries[i]; + if (e.kind === "user" && e.rid === rid) { + mutate(() => { + e.timestamp = timestamp; + }); + return; + } + } + // 找不到就不动:`st(sid)` 已经在上面建过桶,不该为了补一个时刻而触发一次渲染。 + }, addSystemMessage: (text, sid) => { mutate(() => { st(sid).entries.push({ kind: "system", text }); diff --git a/emrg/gui/renderer/src/lib/utils.test.ts b/emrg/gui/renderer/src/lib/utils.test.ts index 32f2885e4..6ec5ff943 100644 --- a/emrg/gui/renderer/src/lib/utils.test.ts +++ b/emrg/gui/renderer/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { escapeHtml, genRequestId, relTime, type TranslateFn } from "./utils"; +import { escapeHtml, formatMessageTime, genRequestId, messageTimeTitle, relTime, type TranslateFn } from "./utils"; /** 与 vanilla i18n 相同的 relTime 词典(zh)——断言与旧行为一致 */ const zhT: TranslateFn = (key, params) => { @@ -58,3 +58,30 @@ describe("relTime", () => { expect(relTime(iso, zhT)).toBe("3 天前"); }); }); + +describe("formatMessageTime", () => { + it("读出记录自己写下的时钟(分秒小数与时区后缀都不影响)", () => { + expect(formatMessageTime("2026-10-17T10:27:55.123456+08:00")).toBe("10:27"); + expect(formatMessageTime("2026-10-17T10:27:55")).toBe("10:27"); + expect(formatMessageTime("2026-10-17 10:27:55")).toBe("10:27"); + }); + + it("不做时区换算:同一个字符串在两台不同时区的客户端上给出同一个值(与 TUI 同口径)", () => { + // 若这里改成 `new Date(...)` 再取本地时分,同一个记录在不同时区的机器上就会显示不同的钟 + // ——那正是「时刻只来自 daemon」要杜绝的。 + expect(formatMessageTime("2026-10-17T10:27:55+00:00")).toBe("10:27"); + expect(formatMessageTime("2026-10-17T23:59:59Z")).toBe("23:59"); + }); + + it("缺席或不可读 → 空串:不出现空括号、Invalid Date、NaN", () => { + for (const bad of [undefined, null, "", " ", "not a date", "2026-13-45T99:99", "10:27"]) { + expect(formatMessageTime(bad as string | null | undefined)).toBe(""); + } + }); + + it("完整日期给悬停用;不可读同样是什么都没有", () => { + expect(messageTimeTitle("2026-10-17T10:27:55.123456+08:00")).toBe("2026-10-17 10:27"); + expect(messageTimeTitle("")).toBe(""); + expect(messageTimeTitle(undefined)).toBe(""); + }); +}); diff --git a/emrg/gui/renderer/src/lib/utils.ts b/emrg/gui/renderer/src/lib/utils.ts index faa851056..ad07c669d 100644 --- a/emrg/gui/renderer/src/lib/utils.ts +++ b/emrg/gui/renderer/src/lib/utils.ts @@ -31,6 +31,43 @@ export function genRequestId(): string { export type TranslateFn = (key: string, params?: Record) => string; +/** `YYYY-MM-DD HH:MM` 的原始片段 —— 只认字符串自己的时钟,不换算时区。 */ +const _MOMENT = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/; + +/** 把 daemon 的 ISO 时刻拆成它自己写下的年月日时分;不可读 → null。 */ +function _momentParts(iso: string | null | undefined): { date: string; time: string } | null { + if (!iso) return null; + const m = _MOMENT.exec(String(iso).trim()); + if (!m) return null; + const [, y, mo, d, h, mi] = m; + // 正则只保证是两位数字:`2026-13-45T99:99` 形状对、时刻不对,按不可读处理 + if (+mo < 1 || +mo > 12 || +d < 1 || +d > 31 || +h > 23 || +mi > 59) return null; + return { date: `${y}-${mo}-${d}`, time: `${h}:${mi}` }; +} + +/** + * 消息行上显示的 `HH:MM`,值取自 **daemon 写下那一刻**,或不可读时 `""`。 + * + * 与 TUI 的 `format_message_time`(`emrg/client/python_tui/widgets/chat_row.py`)同口径: + * 一个格式器给所有行用,用户消息与助手回复的格式不会分家;缺值/坏值渲染成**什么都没有**, + * 而不是空括号、`Invalid Date` 或 `NaN` —— 老记录没有时刻,而只在渲染里才看得见的缺陷 + * 正是返回 `""` 要避免的。 + * + * **不做时区换算**:读的是字符串自己写下的时钟。`fromisoformat` + `strftime` 也是这个 + * 语义(带偏移也不换到本地),两端因此对同一条记录给出同一个值;换算会让客户端的时区 + * 成为显示的输入,正中「时刻只来自 daemon」的反面。 + */ +export function formatMessageTime(iso: string | null | undefined): string { + const parts = _momentParts(iso); + return parts ? parts.time : ""; +} + +/** 完整日期 `YYYY-MM-DD HH:MM`(行上悬停可见);不可读 → `""`。 */ +export function messageTimeTitle(iso?: string | null): string { + const parts = _momentParts(iso); + return parts ? `${parts.date} ${parts.time}` : ""; +} + /** * 相对时间(与 vanilla utils.js relTime 一致):ISO 时间串 → 刚刚 / {n} 分钟前 / * {n} 小时前 / {n} 天前。走注入的翻译函数(组件侧从 i18n context 取);缺失/非法 diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index e25652386..b28afc24c 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -937,7 +937,9 @@ test("守卫:daemon `_broadcast` 的每一种帧类型,中间层要么转发 // (TUI 独有)、或本就是别的会话命令的回执。写进这里=承认它会被兜底记一行。 const NOT_FORWARDED = { compact_result: "别的会话 /compact 的回执,渲染层无 case(daemon 广播给全部订阅者)", - user_message: "TUI 独有的通知(app.py:1505),渲染层 daemonBridge 无 case", + // `user_message` 曾在此处(「TUI 独有」)——rant 2026-10-17T10:03:08 之后它是渲染层 + // 给用户行补时刻的唯一来源(`daemonBridge.ts` 的 `case "user_message"`),本层必须 + // 转发;豁免留着就是名单说谎,④ 会当场变红。 }; const warned = [];