From cf4e1d7706844a55fe671da4dd7399b290e1f34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Fri, 14 Aug 2026 16:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Markdown=20=E4=B8=8E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 4 +- .../conversations/conversation-pane.ts | 13 +- .../features/messages/markdown-content.css | 90 +++++++++ .../src/features/messages/markdown-content.ts | 174 ++++++++++++++++++ .../features/messages/smart-scroll.test.ts | 9 + .../web/src/features/messages/smart-scroll.ts | 37 ++++ apps/web/src/styles/tokens.css | 1 + docs/FEATURES.md | 9 +- 8 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/features/messages/markdown-content.css create mode 100644 apps/web/src/features/messages/markdown-content.ts create mode 100644 apps/web/src/features/messages/smart-scroll.test.ts create mode 100644 apps/web/src/features/messages/smart-scroll.ts diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index 6fca727..830fb61 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -50,7 +50,7 @@ 状态: "已完成" - 编号: "F-009" 名称: "Claude Desktop 视觉与交互收口" - 状态: "进行中" + 状态: "已完成" - 编号: "F-010" 名称: "恢复、边界与发布前加固" - 状态: "待开始" + 状态: "进行中" diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index d467668..a230c9d 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -7,6 +7,8 @@ import { RunStatus } from "../runs/run-status"; import { FilePicker } from "../files/file-picker"; import { ConversationManagementPanel } from "./conversation-management-panel"; import { SettingsPanel } from "../settings/settings-panel"; +import { MarkdownContent } from "../messages/markdown-content"; +import { installSmartScroll } from "../messages/smart-scroll"; const { article, button, div, h1, header, main, p, span, textarea } = van.tags; @@ -27,7 +29,7 @@ export function ConversationPane(controller: AppController): HTMLElement { } } - return main( + const root = main( { class: "main-pane" }, header({ class: "mobile-header" }, "Great Agent 2"), div({ class: "pane-content" }, () => @@ -56,7 +58,9 @@ export function ConversationPane(controller: AppController): HTMLElement { { class: `message-role ${message.role}-role` }, message.role === "user" ? "你" : "G", ), - message.content ? p(message.content) : div(), + message.content + ? MarkdownContent(message.content) + : div(), message.attachments.length ? div( { class: "message-attachments" }, @@ -94,7 +98,7 @@ export function ConversationPane(controller: AppController): HTMLElement { ? article( { class: "message assistant streaming" }, span({ class: "message-role assistant-role" }, "G"), - p(controller.assistantDraft), + MarkdownContent(controller.assistantDraft.val), ) : null, () => @@ -147,6 +151,9 @@ export function ConversationPane(controller: AppController): HTMLElement { : div(), ), ); + const scrollContainer = root.querySelector(".pane-content"); + if (scrollContainer) installSmartScroll(scrollContainer); + return root; } function Composer( diff --git a/apps/web/src/features/messages/markdown-content.css b/apps/web/src/features/messages/markdown-content.css new file mode 100644 index 0000000..f7ed215 --- /dev/null +++ b/apps/web/src/features/messages/markdown-content.css @@ -0,0 +1,90 @@ +.markdown-content { + min-width: 0; + color: #e1ded8; + font-size: 14px; + line-height: 1.62; +} +.markdown-content p { + margin: 2px 0 0; + white-space: pre-wrap; +} +.markdown-content ul, +.markdown-content ol { + margin: 4px 0; + padding-left: 22px; +} +.markdown-content li { + margin: 2px 0; +} +.markdown-content blockquote { + margin: 7px 0; + padding: 3px 0 3px 12px; + border-left: 3px solid #6c6760; + color: #aaa59d; +} +.markdown-content a { + color: #d18c78; + text-decoration: underline; + text-underline-offset: 2px; +} +.markdown-content code { + padding: 1px 4px; + border-radius: 4px; + background: #34322f; + font: 0.9em var(--font-mono); +} +.markdown-content .strong { + font-weight: 700; +} +.markdown-content table { + display: block; + max-width: 100%; + margin: 9px 0; + overflow-x: auto; + border-collapse: collapse; +} +.markdown-content th, +.markdown-content td { + padding: 6px 9px; + border: 1px solid #4a4742; + text-align: left; +} +.markdown-content th { + background: #302f2c; + font-weight: 600; +} +.code-block { + margin: 9px 0; + overflow: hidden; + border: 1px solid #44413d; + border-radius: 9px; + background: #1e1e1c; +} +.code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 9px; + color: #918c84; + background: #2c2a27; + font-size: 9px; +} +.code-header button { + padding: 3px 7px; + border-radius: 4px; + color: #aaa59d; + background: #393733; + font-size: 9px; +} +.code-block pre { + margin: 0; + overflow-x: auto; + padding: 12px; +} +.code-block pre code { + padding: 0; + color: #dcd8d1; + background: transparent; + font: 11px / 1.55 var(--font-mono); + white-space: pre; +} diff --git a/apps/web/src/features/messages/markdown-content.ts b/apps/web/src/features/messages/markdown-content.ts new file mode 100644 index 0000000..5248a1c --- /dev/null +++ b/apps/web/src/features/messages/markdown-content.ts @@ -0,0 +1,174 @@ +import van from "vanjs-core"; +import "./markdown-content.css"; + +const { + a, + blockquote, + button, + code, + div, + li, + ol, + p, + pre, + span, + table, + tbody, + td, + th, + thead, + tr, + ul, +} = van.tags; + +export function MarkdownContent(content: string): HTMLElement { + return div({ class: "markdown-content" }, ...parseBlocks(content)); +} + +function parseBlocks(content: string): HTMLElement[] { + const lines = content.replaceAll("\r\n", "\n").split("\n"); + const blocks: HTMLElement[] = []; + for (let index = 0; index < lines.length; ) { + const line = lines[index] ?? ""; + if (!line.trim()) { + index++; + continue; + } + if (line.startsWith("```")) { + const language = line.slice(3).trim(); + const codeLines: string[] = []; + index++; + while (index < lines.length && !lines[index]?.startsWith("```")) + codeLines.push(lines[index++] ?? ""); + if (index < lines.length) index++; + blocks.push(CodeBlock(codeLines.join("\n"), language)); + continue; + } + if (isTable(lines, index)) { + const headers = cells(line); + index += 2; + const rows: string[][] = []; + while (index < lines.length && (lines[index] ?? "").includes("|")) + rows.push(cells(lines[index++] ?? "")); + blocks.push( + table( + thead(tr(...headers.map((cell) => th(...inline(cell))))), + tbody( + ...rows.map((row) => tr(...row.map((cell) => td(...inline(cell))))), + ), + ), + ); + continue; + } + if (/^\s*[-*] /u.test(line) || /^\s*\d+\. /u.test(line)) { + const ordered = /^\s*\d+\. /u.test(line); + const items: HTMLElement[] = []; + const matcher = ordered ? /^\s*\d+\.\s+/u : /^\s*[-*]\s+/u; + while (index < lines.length && matcher.test(lines[index] ?? "")) + items.push(li(...inline((lines[index++] ?? "").replace(matcher, "")))); + blocks.push(ordered ? ol(...items) : ul(...items)); + continue; + } + if (line.startsWith(">")) { + const quoted: string[] = []; + while (index < lines.length && lines[index]?.startsWith(">")) + quoted.push((lines[index++] ?? "").replace(/^>\s?/u, "")); + blocks.push(blockquote(...inline(quoted.join("\n")))); + continue; + } + const paragraph: string[] = [line]; + index++; + while ( + index < lines.length && + lines[index]?.trim() && + !startsBlock(lines, index) + ) + paragraph.push(lines[index++] ?? ""); + blocks.push(p(...inline(paragraph.join("\n")))); + } + return blocks; +} + +function CodeBlock(content: string, language: string): HTMLElement { + return div( + { class: "code-block" }, + div( + { class: "code-header" }, + span(language || "文本"), + button( + { + type: "button", + onclick: async (event: Event) => { + const target = event.currentTarget as HTMLButtonElement; + try { + await navigator.clipboard.writeText(content); + target.textContent = "已复制"; + } catch { + target.textContent = "复制失败"; + } + setTimeout(() => { + target.textContent = "复制"; + }, 1_200); + }, + }, + "复制", + ), + ), + pre(code({ class: language ? `language-${language}` : "" }, content)), + ); +} + +function inline(value: string): (string | HTMLElement)[] { + const result: (string | HTMLElement)[] = []; + const pattern = /(`[^`]+`|\[[^\]]+\]\([^\s)]+\)|\*\*[^*]+\*\*)/gu; + let offset = 0; + for (const match of value.matchAll(pattern)) { + const start = match.index ?? 0; + if (start > offset) result.push(value.slice(offset, start)); + const token = match[0]; + if (token.startsWith("`")) result.push(code(token.slice(1, -1))); + else if (token.startsWith("**")) + result.push(span({ class: "strong" }, token.slice(2, -2))); + else { + const parts = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(token); + const href = parts?.[2] ?? ""; + result.push( + isSafeLink(href) + ? a({ href, target: "_blank", rel: "noreferrer" }, parts?.[1] ?? href) + : token, + ); + } + offset = start + token.length; + } + if (offset < value.length) result.push(value.slice(offset)); + return result; +} + +function startsBlock(lines: string[], index: number): boolean { + const line = lines[index] ?? ""; + return ( + line.startsWith("```") || + line.startsWith(">") || + /^\s*([-*]|\d+\.) /u.test(line) || + isTable(lines, index) + ); +} + +function isTable(lines: string[], index: number): boolean { + return ( + (lines[index] ?? "").includes("|") && + /^\s*\|?\s*:?-{3,}/u.test(lines[index + 1] ?? "") + ); +} + +function cells(line: string): string[] { + return line + .trim() + .replace(/^\||\|$/gu, "") + .split("|") + .map((cell) => cell.trim()); +} + +function isSafeLink(value: string): boolean { + return /^(https?:\/\/|\/)/u.test(value); +} diff --git a/apps/web/src/features/messages/smart-scroll.test.ts b/apps/web/src/features/messages/smart-scroll.test.ts new file mode 100644 index 0000000..b5908b9 --- /dev/null +++ b/apps/web/src/features/messages/smart-scroll.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import { isNearBottom } from "./smart-scroll"; + +describe("消息智能滚动", () => { + test("接近底部时跟随,主动上滚后停止跟随", () => { + expect(isNearBottom(1_000, 430, 500)).toBe(true); + expect(isNearBottom(1_000, 100, 500)).toBe(false); + }); +}); diff --git a/apps/web/src/features/messages/smart-scroll.ts b/apps/web/src/features/messages/smart-scroll.ts new file mode 100644 index 0000000..134a07a --- /dev/null +++ b/apps/web/src/features/messages/smart-scroll.ts @@ -0,0 +1,37 @@ +export function installSmartScroll(container: HTMLElement): () => void { + let followsLatest = true; + const update = () => { + followsLatest = isNearBottom( + container.scrollHeight, + container.scrollTop, + container.clientHeight, + ); + }; + container.addEventListener("scroll", update, { passive: true }); + const observer = new MutationObserver(() => { + if (!followsLatest) return; + requestAnimationFrame(() => + container.scrollTo({ top: container.scrollHeight }), + ); + }); + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + requestAnimationFrame(() => + container.scrollTo({ top: container.scrollHeight }), + ); + return () => { + observer.disconnect(); + container.removeEventListener("scroll", update); + }; +} + +export function isNearBottom( + scrollHeight: number, + scrollTop: number, + clientHeight: number, +): boolean { + return scrollHeight - scrollTop - clientHeight < 80; +} diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 26aadf2..907745a 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -16,4 +16,5 @@ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-serif: Georgia, serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bea669e..b9233f0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -109,13 +109,18 @@ ### F-009——Claude Desktop 视觉与交互收口 -- 状态:进行中 +- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核) - 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。 - 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。 +- 消息排版:新增不依赖大型 UI 库的安全 Markdown 组件,支持段落、无序/有序列表、引用、链接、行内代码、粗体、表格和带语言标识的代码块;代码块提供复制按钮和复制结果反馈。解析过程直接创建 DOM 节点,不把消息内容作为 HTML 注入。 +- 滚动行为:打开长会话和用户停留在底部时自动滚动到最新内容;用户主动向上滚动超过 80 px 后,新消息和状态更新不再抢回底部。页面根节点继续锁定视口高度,侧栏和消息内容区分别独立滚动,顶部区域、侧栏底部和输入区不跟随消息滚动。 +- 视觉收口:沿用已确认的 Claude Desktop 深色三栏结构,补齐 Markdown 表格、引用、代码块、会话操作、设置页与文件选择器样式;没有增加未实现的 Claude 小组件或无效入口。新增组件继续使用功能共置样式,TypeScript 文件均不超过 400 行。 +- 自动化验证结果:2026-08-14 通过全仓类型检查、36 项测试和 123 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;智能滚动阈值有独立测试。 +- 人工操作结果:2026-08-14 使用隔离数据目录和本机应用内浏览器验证列表、引用、表格、TypeScript 代码块及复制反馈;长消息新增后滚动位置到达底部(`1812/1812`),主动上滚到 `212` 后再新增消息仍保持 `212`;页面根节点和 `body` 均无滚动,侧栏与内容区为独立 `overflow-y: auto`,隔离页面控制台无错误。应用内浏览器固定为 1280×720,1440×900 的结构约束由同一桌面断点和既有参考截图覆盖。 ### F-010——恢复、边界与发布前加固 -- 状态:待开始 +- 状态:进行中 - 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。 - 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。