Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion App/frontend/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.10.6"
"happy-dom": "^20.10.6",
"@types/hast": "^3.0.4"
}
}
11 changes: 8 additions & 3 deletions App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,10 @@ function TraceMemoryDetail(props: {
: detail.steps.flatMap((step) => step.toolCalls);
const turnEvents = buildTraceTurnEvents(detail, toolCalls);
const hasTurnSteps = turnEvents.length > 0;
const summaryText = displayMemorySummaryText(detail.summary || props.item.summary, t, props.item.processing);
const rawBody = !hasTurnSteps && props.item.body?.trim() ? props.item.body : undefined;
// The read model owns summary validity and legacy field precedence. Raw trace
// metadata may still contain a placeholder or an older copy of the summary.
const summaryText = displayMemorySummaryText(props.item.summary, t, props.item.processing);

return (
<>
Expand Down Expand Up @@ -998,11 +1001,13 @@ function TraceMemoryDetail(props: {
</section>

<section className="memory-detail-card">
<h5 className="memory-detail-card__label">{t("memory.memories.turnSteps")}</h5>
<h5 className="memory-detail-card__label">{t(rawBody ? "memory.memories.body" : "memory.memories.turnSteps")}</h5>
{hasTurnSteps ? (
<div className="memory-turn">
{turnEvents.map((event) => <TraceTurnEventBlock key={event.key} event={event} />)}
</div>
) : rawBody ? (
<div className="memory-detail-text memory-detail-raw-body">{rawBody}</div>
) : (
<MemoryStateBox message={t("memory.memories.noTurnSteps")} />
)}
Expand Down Expand Up @@ -1033,7 +1038,7 @@ function TraceTurnEventBlock(props: { event: TraceTurnEvent }) {

return (
<MemoryTurnBlock label={label} tone={event.kind}>
<MemoryMarkdown text={event.text} />
<MemoryMarkdown text={event.text} literalHtml />
</MemoryTurnBlock>
);
}
Expand Down
13 changes: 7 additions & 6 deletions App/frontend/desktop/src/pages/memory/memory-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PLACEHOLDER_TITLES = new Set([
"system",
"tool",
"developer",
"\u6458\u8981\u603B\u7ED3\u4E2D",
"\u6458\u8981\u6392\u961F\u4E2D",
"\u6458\u8981\u6574\u7406\u4E2D",
"\u5EFA\u7ACB\u7D22\u5F15\u4E2D",
Expand Down Expand Up @@ -63,8 +64,9 @@ export function displayMemoryTitle(item: MemoryDisplayItem, ...candidates: Array
if (generatedTitle) return generatedTitle;

const summary = readySummary(item.summary);
if (item.kind === "trace" && summary) {
return summary;
if (item.kind === "trace") {
// All processing states use the read model preview, never raw source text.
return summary ?? waitingPrimaryText(item);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

旧记录没有摘要时,原文标签仍会盖过已清洗的用户请求

问题
打开一条尚无有效摘要、也没有生成标题的旧版 L1 记录,如果它以未分段的原文保存,例如 浏览器上下文\n请修复扫描标题,本次读模型已经能提取出 sourceText = 请修复扫描标题,但当记录处于 ready 或 failed 时,列表标题仍显示浏览器标签和上下文。没有有效用户请求的同类记录,也不能可靠回退到记录 ID。

原因
数据库没有摘要,并不意味着前端收到的 item.summary 为空。列表读取先经过 Repositories.toListItem();其中 listSummaryForMemory() 在没有存储摘要时,会把 memoryValue 的第一条可读行填入 summary。详情的 detailSummaryForMemory() 也有正文回退。与此同时,本次 displayFieldsForMemory() 把来源封装过滤掉,另行给出正确的 sourceText。
前端 readySummary() 无法区分真正存储的摘要和这条从正文回填的内容,会接受浏览器标签。这里的 summary ?? waitingPrimaryText(item) 因而优先返回原文,使已经清洗的 sourceText 没有机会使用。新增前端测试直接把 summary 设置为 "" 或占位文案,绕过了服务端实际回填过程,所以未覆盖这条链路。

建议做法
对原始对话记忆统一列表和详情的摘要读取约定:没有有效的存储摘要时,服务返回空的 summary,不要拿原文首行填充。sourceText 是适合标题预览的用户请求文本,这个字段原本就存在,本 PR 改进了它的来源封装过滤规则。没有生成标题或有效摘要时,让前端已有逻辑回退到 sourceText;连有效请求也没有时再显示记录 ID。无需把 sourceText 再伪装成摘要,也不要在前端另加一套标签过滤。
调整列表 listSummaryForMemory()、详情 detailSummaryForMemory() 与前端 displayMemoryTitle() 的衔接,并核对读取详情摘要的 panel/task 调用方。改动限定在此次原始对话摘要回退问题,保留聚合记忆、工作记忆等其它类型的原有字段含义。

}

for (const value of [summary, ...candidates, firstUserQueryLine(item.body), firstReadableBodyLine(item.body), item.title]) {
Expand Down Expand Up @@ -99,10 +101,9 @@ export function experienceDisplayTitle(item: MemoryDisplayItem): string | undefi
export function waitingPrimaryText(item: MemoryDisplayItem): string {
const source = cleanMemoryText(item.sourceText);
if (usableWaitingText(source)) return source;
if (item.kind === "policy") return displayMemoryId(item.id);
return firstUserQueryLine(item.body)
?? firstReadableBodyLine(item.body)
?? displayMemoryId(item.id);
// The read model owns preview extraction. Re-reading raw body here would
// resurrect injected context that it deliberately excluded from sourceText.
return displayMemoryId(item.id);
}

export function independentGeneratedTitle(item: MemoryDisplayItem): string | undefined {
Expand Down
26 changes: 25 additions & 1 deletion App/frontend/desktop/src/pages/memory/memory-markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { ComponentPropsWithoutRef } from "react";
import type { Root, RootContent } from "hast";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";

export interface MemoryMarkdownProps {
text: string;
literalHtml?: boolean;
}

const components: Components = {
Expand Down Expand Up @@ -36,7 +38,7 @@ const components: Components = {
export function MemoryMarkdown(props: MemoryMarkdownProps) {
return (
<div className="memory-markdown">
<ReactMarkdown remarkPlugins={[[remarkGfm, { singleTilde: false }], remarkBreaks]} components={components} skipHtml>
<ReactMarkdown remarkPlugins={[[remarkGfm, { singleTilde: false }], remarkBreaks]} rehypePlugins={props.literalHtml ? [rehypeLiteralHtml] : []} skipHtml={!props.literalHtml} components={components}>
{props.text}
</ReactMarkdown>
</div>
Expand All @@ -46,3 +48,25 @@ export function MemoryMarkdown(props: MemoryMarkdownProps) {
function MemoryMarkdownHeading(props: ComponentPropsWithoutRef<"h4">) {
return <h4 className="memory-markdown__heading">{props.children}</h4>;
}

/** Preserve source HTML as inert text, including line breaks within raw blocks. */
function rehypeLiteralHtml() {
return (tree: Root) => {
function preserve(nodes: RootContent[]): void {
for (const [index, node] of nodes.entries()) {
if (node.type === "raw") {
// Never parse source markup into elements or copy its attributes.
nodes[index] = {
type: "element",
tagName: "span",
properties: { className: ["memory-markdown__literal-html"] },
children: [{ type: "text", value: node.value }]
};
} else if ("children" in node) {
preserve(node.children);
}
}
}
preserve(tree.children);
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const CACHE_PREFIX = "memmy.memory-panel.";
const CACHE_VERSION = 1;
// Invalidate source previews and summaries manufactured from legacy bodies.
const CACHE_VERSION = 3;
const CACHE_TTL_MS = 30_000;

interface CacheEnvelope<T> {
Expand Down
2 changes: 1 addition & 1 deletion App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ function TaskChatBubble(props: { message: TaskChatMessage }) {
{message.role === "tool" && message.tool ? (
<TaskToolBubble tool={message.tool} />
) : (
<MemoryMarkdown text={message.text ?? ""} />
<MemoryMarkdown text={message.text ?? ""} literalHtml />
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ import {
} from "../memory-agent-filter.js";

describe("MemoriesSubPage", () => {
it("uses the canonical detail summary when legacy trace metadata still has a placeholder", () => {
const detail = { ...memoryDetailFixture, item: { ...memoryDetailFixture.item,
summary: "有效存储摘要", processing: undefined,
metadata: { ...memoryDetailFixture.item.metadata, traceDetail: {
...(memoryDetailFixture.item.metadata.traceDetail as Record<string, unknown>), summary: "摘要总结中"
} }
} };
const html = renderMemories({ status: "ready", data: panelItemsOutput([]), detail: { status: "ready", data: detail } });
expect(html).toContain("有效存储摘要");
expect(html).not.toContain("摘要总结中");
detail.item.summary = "";
const empty = renderMemories({ status: "ready", data: panelItemsOutput([]), detail: { status: "ready", data: detail } });
expect(empty).not.toContain("摘要总结中");
});
it("在所有记忆列表明确标注没有提炼为记忆的观察和原因", () => {
const html = renderMemories({ status: "ready", data: panelItemsOutput([{
...memoryListItemFixture,
Expand All @@ -38,6 +52,20 @@ describe("MemoriesSubPage", () => {
expect(html).toContain("重复的窗口切换没有提供新上下文。");
expect(html).not.toContain("memory-card__score");
});

it("shows source envelopes and adjacent requests in the user detail block", () => {
const detail = { ...memoryDetailFixture, item: { ...memoryDetailFixture.item,
metadata: { ...memoryDetailFixture.item.metadata, traceDetail: {
...(memoryDetailFixture.item.metadata.traceDetail as Record<string, unknown>),
userQuery: '<in-app-browser-context>\n上下文\n</in-app-browser-context>\n请保留记忆请求。'
} }
} };
const html = renderMemories({ status: "ready", data: panelItemsOutput([memoryListItemFixture]), detail: { status: "ready", data: detail } });
expect(html).toContain("请保留记忆请求。");
expect(html).toContain("&lt;in-app-browser-context&gt;");
expect(html).not.toContain("<in-app-browser-context>");
});

it("把搜索转换为只读取原始记忆的 PanelItemsInput", () => {
expect(buildPanelItemsInput({ query: "偏好" })).toEqual({
q: "偏好",
Expand Down Expand Up @@ -330,7 +358,7 @@ describe("MemoriesSubPage", () => {
expect(html).not.toContain('class="memory-card__title">trace_457c41a4ec1e730ecd39');
});

it("waiting 列表的 RawTurn 标题不盖过正文里的用户原句", () => {
it("waiting 列表缺少 sourceText 时使用 id,不重新提取原始正文", () => {
const html = renderMemories({
status: "ready",
data: panelItemsOutput([{
Expand All @@ -345,11 +373,65 @@ describe("MemoriesSubPage", () => {
}]),
detail: null
});
expect(html).toContain('class="memory-card__title">请修复自动扫描卡顿');
expect(html).toContain('class="memory-card__title">trace_waiting_raw');
expect(html).not.toContain('class="memory-card__title">请修复自动扫描卡顿');
expect(html).toContain("摘要总结中");
expect(html).not.toContain("RawTurn: raw_pending");
});

it.each(["summary_pending", "summarizing"] as const)("%s 不从正文恢复已过滤的上下文或占位状态", (state) => {
for (const sourceText of [undefined, "摘要总结中"]) {
const html = renderMemories({
status: "ready",
data: panelItemsOutput([{
...memoryListItemFixture,
id: "trace_context_only", title: "<timestamp>today</timestamp>",
generatedTitle: undefined, sourceText, summary: "",
body: 'User:\n<in-app-browser-context source="ambient-ui-state">\n页面正文\n</in-app-browser-context>\nAssistant:\nDone',
processing: processingRecord("trace_context_only", state, "summary")
}]), detail: null
});
expect(html).toContain('class="memory-card__title">trace_context_only');
expect(html).toContain("摘要总结中");
expect(html).not.toContain("in-app-browser-context");
expect(html).not.toContain("页面正文");
expect(html).not.toContain("timestamp");
}
});

it.each(["failed", "ready"] as const)("%s 的 trace 使用语义预览或 ID,不能恢复历史标签", (state) => {
for (const summary of ["", "摘要总结中"]) {
for (const sourceText of ["请修复标题", undefined, "摘要总结中"]) {
const html = renderMemories({
status: "ready",
data: panelItemsOutput([{
...memoryListItemFixture, kind: "trace", id: "trace_context_fallback",
title: "<timestamp>today</timestamp>", generatedTitle: undefined, summary, sourceText,
body: 'User:\n<in-app-browser-context source="ambient-ui-state">\n页面正文\n</in-app-browser-context>\nAssistant:\nDone',
processing: { ...processingRecord("trace_context_fallback", "ready", "summary"), state }
}]), detail: null
});
expect(html).toContain(`class="memory-card__title">${sourceText === "请修复标题" ? sourceText : "trace_context_fallback"}`);
expect(html).not.toContain("in-app-browser-context");
expect(html).not.toContain("timestamp");
expect(html).not.toContain("页面正文");
}
}
});

it("摘要完成进入 embedding 阶段后使用生成标题,不继续显示预览", () => {
const html = renderMemories({
status: "ready",
data: panelItemsOutput([{
...memoryListItemFixture,
generatedTitle: "扫描标题修复", sourceText: "请修复扫描后的记忆标题",
processing: processingRecord(memoryListItemFixture.id, "embedding_pending", "embedding")
}]), detail: null
});
expect(html).toContain('class="memory-card__title">扫描标题修复');
expect(html).not.toContain('class="memory-card__title">请修复扫描后的记忆标题');
});

it("waiting 的记忆详情不显示标题,并把记忆摘要固定成摘要总结中", () => {
const waitingDetail = {
...memoryDetailFixture,
Expand Down Expand Up @@ -1037,7 +1119,7 @@ function renderMemories(

function processingRecord(
memoryId: string,
state: "summary_pending" | "embedding_pending" | "ready",
state: "summary_pending" | "summarizing" | "embedding_pending" | "ready",
stage: "summary" | "embedding" | null
) {
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { MemoryMarkdown } from "../memory-markdown.js";

const render = (text: string) => renderToStaticMarkup(<MemoryMarkdown text={text} literalHtml />);
const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");

describe("MemoryMarkdown literal HTML", () => {
it("keeps HTML hidden unless literal rendering is explicitly enabled", () => {
const text = '<div>旧页面隐藏内容</div>\n\n**正常正文**';
const html = renderToStaticMarkup(<MemoryMarkdown text={text} />);
expect(html).not.toContain("旧页面隐藏内容");
expect(html).not.toContain("literal-html");
expect(html).toContain("<strong>正常正文</strong>");
for (const file of ["world-model-sub-page.tsx", "computer-history-sub-page.tsx"]) {
const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
expect(source).not.toMatch(/<MemoryMarkdown[^>]*literalHtml/);
}
});

it.each([
'<in-app-browser-context source="ambient-ui-state">\n示例项目\n</in-app-browser-context>\n给搜索框增加清空按钮。',
'<in-app-browser-context>\n示例项目\n</in-app-browser-context>\n\n给搜索框增加清空按钮。',
'<in-app-browser-context>\n未闭合的上下文\n给搜索框增加清空按钮。',
'<timestamp>Tuesday, Jun 16, 2026</timestamp>',
'<!-- 原始注释 -->\n给搜索框增加清空按钮。',
'<div>\n多行原文\n</div>\n给搜索框增加清空按钮。'
])("preserves envelope and request text: %s", (text) => {
const html = render(text);
for (const line of text.split("\n").filter(Boolean)) expect(html.replace(/<[^>]*>/g, "")).toContain(escape(line));
expect(html).toContain('class="memory-markdown__literal-html"');
});

it("preserves raw block line breaks with a scoped style", () => {
const html = render('<context>\n第一行\n第二行\n</context>');
expect(html).toContain('&lt;context&gt;\n第一行\n第二行\n&lt;/context&gt;');
const css = readFileSync(new URL("../../../styles.css", import.meta.url), "utf8");
expect(css).toMatch(/\.memory-markdown__literal-html\s*\{[^}]*white-space:\s*pre-wrap/s);
});

it.each([
'<script>alert("test")</script>',
'<iframe src="https://example.invalid"></iframe>',
'<img src="https://example.invalid/a" onerror="alert(1)">',
'<div onclick="alert(1)">原文</div>',
'<a href="javascript:alert(1)">原文</a>',
'<style>body{display:none}</style>'
])("renders active HTML only as text: %s", (text) => {
const html = render(text);
expect(html.replace(/<[^>]*>/g, "")).toContain(escape(text));
expect(html).not.toMatch(/<(?:script|iframe|img|div\s+onclick|a\s+href|style)\b/i);
});

it("keeps inline tags literal and normal Markdown formatted", () => {
const html = render('正文 <b>示例</b>,**加粗**。\n\n- 项目\n\n| 列 |\n| --- |\n| 值 |\n\n`<code>`\n\n```html\n<div>代码</div>\n```');
expect(html).toContain('&lt;b&gt;');
expect(html).toContain('&lt;/b&gt;');
expect(html).toContain('<strong>加粗</strong>');
expect(html).toContain('<li>项目</li>');
expect(html).toContain('<table');
expect(html).toContain('<pre');
expect(html).toContain('&lt;div&gt;代码&lt;/div&gt;');
expect(html).not.toContain('<b>');
});

it("keeps existing inert links and images and accepts empty input", () => {
const html = render('[链接](https://example.invalid) ![图片说明](https://example.invalid/img)');
expect(html).toContain('链接');
expect(html).toContain('图片说明');
expect(html).not.toMatch(/<(?:a|img)\b/);
expect(render('')).toBe('<div class="memory-markdown"></div>');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ describe("memory panel cache", () => {
vi.unstubAllGlobals();
});

it.each([1, 2])("discards fresh previews from cache version %s", (version) => {
const storage = createMemoryStorage();
vi.stubGlobal("window", { sessionStorage: storage });
const key = memoryPanelCacheKey("memories", "latest");
storage.setItem(`memmy.memory-panel.${key}`, JSON.stringify({
version, savedAt: new Date().toISOString(),
data: { sourceText: '<in-app-browser-context source="ambient-ui-state">' }
}));
expect(readMemoryPanelCache(key)).toBeNull();
expect(storage.getItem(`memmy.memory-panel.${key}`)).toBeNull();
writeMemoryPanelCache(key, { sourceText: "请修复标题" });
expect(readMemoryPanelCache(key)).toEqual({ sourceText: "请修复标题" });
});

it("clears only memory panel cache entries", () => {
const storage = createMemoryStorage();
vi.stubGlobal("window", { sessionStorage: storage });
Expand Down
Loading
Loading