feat: Markdown 与消息滚动收口
This commit is contained in:
parent
b1571f2052
commit
cf4e1d7706
@ -50,7 +50,7 @@
|
|||||||
状态: "已完成"
|
状态: "已完成"
|
||||||
- 编号: "F-009"
|
- 编号: "F-009"
|
||||||
名称: "Claude Desktop 视觉与交互收口"
|
名称: "Claude Desktop 视觉与交互收口"
|
||||||
状态: "进行中"
|
状态: "已完成"
|
||||||
- 编号: "F-010"
|
- 编号: "F-010"
|
||||||
名称: "恢复、边界与发布前加固"
|
名称: "恢复、边界与发布前加固"
|
||||||
状态: "待开始"
|
状态: "进行中"
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { RunStatus } from "../runs/run-status";
|
|||||||
import { FilePicker } from "../files/file-picker";
|
import { FilePicker } from "../files/file-picker";
|
||||||
import { ConversationManagementPanel } from "./conversation-management-panel";
|
import { ConversationManagementPanel } from "./conversation-management-panel";
|
||||||
import { SettingsPanel } from "../settings/settings-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;
|
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" },
|
{ class: "main-pane" },
|
||||||
header({ class: "mobile-header" }, "Great Agent 2"),
|
header({ class: "mobile-header" }, "Great Agent 2"),
|
||||||
div({ class: "pane-content" }, () =>
|
div({ class: "pane-content" }, () =>
|
||||||
@ -56,7 +58,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
{ class: `message-role ${message.role}-role` },
|
{ class: `message-role ${message.role}-role` },
|
||||||
message.role === "user" ? "你" : "G",
|
message.role === "user" ? "你" : "G",
|
||||||
),
|
),
|
||||||
message.content ? p(message.content) : div(),
|
message.content
|
||||||
|
? MarkdownContent(message.content)
|
||||||
|
: div(),
|
||||||
message.attachments.length
|
message.attachments.length
|
||||||
? div(
|
? div(
|
||||||
{ class: "message-attachments" },
|
{ class: "message-attachments" },
|
||||||
@ -94,7 +98,7 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
? article(
|
? article(
|
||||||
{ class: "message assistant streaming" },
|
{ class: "message assistant streaming" },
|
||||||
span({ class: "message-role assistant-role" }, "G"),
|
span({ class: "message-role assistant-role" }, "G"),
|
||||||
p(controller.assistantDraft),
|
MarkdownContent(controller.assistantDraft.val),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
() =>
|
() =>
|
||||||
@ -147,6 +151,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
: div(),
|
: div(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const scrollContainer = root.querySelector<HTMLElement>(".pane-content");
|
||||||
|
if (scrollContainer) installSmartScroll(scrollContainer);
|
||||||
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Composer(
|
function Composer(
|
||||||
|
|||||||
90
apps/web/src/features/messages/markdown-content.css
Normal file
90
apps/web/src/features/messages/markdown-content.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
174
apps/web/src/features/messages/markdown-content.ts
Normal file
174
apps/web/src/features/messages/markdown-content.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
9
apps/web/src/features/messages/smart-scroll.test.ts
Normal file
9
apps/web/src/features/messages/smart-scroll.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/web/src/features/messages/smart-scroll.ts
Normal file
37
apps/web/src/features/messages/smart-scroll.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -16,4 +16,5 @@
|
|||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", sans-serif;
|
"Segoe UI", sans-serif;
|
||||||
--font-serif: Georgia, serif;
|
--font-serif: Georgia, serif;
|
||||||
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,13 +109,18 @@
|
|||||||
|
|
||||||
### F-009——Claude Desktop 视觉与交互收口
|
### F-009——Claude Desktop 视觉与交互收口
|
||||||
|
|
||||||
- 状态:进行中
|
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||||
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
|
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
|
||||||
- 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。
|
- 主要验收: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——恢复、边界与发布前加固
|
### F-010——恢复、边界与发布前加固
|
||||||
|
|
||||||
- 状态:待开始
|
- 状态:进行中
|
||||||
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
||||||
- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
|
- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user