@zmzai/theme 0.2.13 → 0.3.0
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/package.json +5 -2
- package/src/components/diff-view/DiffView.tsx +69 -0
- package/src/components/diff-view/diff-view.css +22 -0
- package/src/components/diff-view/index.ts +2 -0
- package/src/components/diff-view/parse-unified-diff.ts +94 -0
- package/src/components/index.ts +7 -0
- package/src/components/markdown/Markdown.tsx +127 -0
- package/src/components/markdown/index.ts +1 -0
- package/src/components/markdown/markdown.css +37 -0
- package/src/components/permission-card/PermissionCard.tsx +63 -0
- package/src/components/permission-card/index.ts +1 -0
- package/src/components/permission-card/permission-card.css +7 -0
- package/src/components/todo-checklist/TodoChecklist.tsx +88 -0
- package/src/components/todo-checklist/index.ts +1 -0
- package/src/components/todo-checklist/todo-checklist.css +35 -0
- package/src/components/tool-card/ToolCard.tsx +95 -0
- package/src/components/tool-card/index.ts +2 -0
- package/src/components/tool-card/tool-card.css +30 -0
- package/src/components/tool-card/types.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zmzai/theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
|
|
@@ -94,7 +94,10 @@
|
|
|
94
94
|
"@radix-ui/react-select": "^2.1.0",
|
|
95
95
|
"@radix-ui/react-tooltip": "^1.1.0",
|
|
96
96
|
"class-variance-authority": "^0.7.0",
|
|
97
|
-
"framer-motion": "^11.0.0"
|
|
97
|
+
"framer-motion": "^11.0.0",
|
|
98
|
+
"react-markdown": "^10.1.0",
|
|
99
|
+
"remark-gfm": "^4.0.1",
|
|
100
|
+
"highlight.js": "^11.11.1"
|
|
98
101
|
},
|
|
99
102
|
"packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e"
|
|
100
103
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
import { parseUnifiedDiff, type DiffFile, type DiffLine } from "./parse-unified-diff";
|
|
6
|
+
import "./diff-view.css";
|
|
7
|
+
|
|
8
|
+
function DiffLineRow({ line, index }: { line: DiffLine; index: number }) {
|
|
9
|
+
if (line.type === "hunk") {
|
|
10
|
+
return <div className="diff-line hunk" key={index}><span className="diff-gutter">@@</span><span className="diff-text">{line.text.replace(/^@@ /, "").replace(/ @@$/, "")}</span></div>;
|
|
11
|
+
}
|
|
12
|
+
if (line.type === "meta") {
|
|
13
|
+
return <div className="diff-line meta" key={index}><span className="diff-gutter" /><span className="diff-text">{line.text}</span></div>;
|
|
14
|
+
}
|
|
15
|
+
const gutter = line.type === "add" ? "+" : line.type === "remove" ? "−" : " ";
|
|
16
|
+
return (
|
|
17
|
+
<div className={`diff-line ${line.type}`} key={index}>
|
|
18
|
+
<span className="diff-gutter">{gutter}</span>
|
|
19
|
+
<span className="diff-text">{line.text || " "}</span>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function DiffFileBlock({ file }: { file: DiffFile }) {
|
|
25
|
+
return (
|
|
26
|
+
<section className="diff-file">
|
|
27
|
+
<header className="diff-file-head">
|
|
28
|
+
<span className="diff-file-path">{file.newPath ?? file.oldPath ?? "(未知文件)"}</span>
|
|
29
|
+
<span className="diff-file-stats">
|
|
30
|
+
{file.additions > 0 && <em className="diff-add-count">+{file.additions}</em>}
|
|
31
|
+
{file.deletions > 0 && <em className="diff-del-count">−{file.deletions}</em>}
|
|
32
|
+
</span>
|
|
33
|
+
</header>
|
|
34
|
+
{file.hunks.map((hunk, hunkIndex) => (
|
|
35
|
+
<div className="diff-hunk" key={hunkIndex}>
|
|
36
|
+
{hunk.lines.map((line, lineIndex) => <DiffLineRow line={line} index={lineIndex} key={lineIndex} />)}
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</section>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* DiffView — Codex-style colored unified diff renderer.
|
|
45
|
+
*
|
|
46
|
+
* Falls back to a plain mono block when the diff text cannot be parsed
|
|
47
|
+
* into known structure.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* <DiffView diff={unifiedDiffText} />
|
|
51
|
+
*/
|
|
52
|
+
export function DiffView({ diff }: { diff: string }) {
|
|
53
|
+
const parsed = useMemo(() => parseUnifiedDiff(diff), [diff]);
|
|
54
|
+
if (!parsed.files.length) {
|
|
55
|
+
return <pre className="diff-preview">{diff}</pre>;
|
|
56
|
+
}
|
|
57
|
+
return (
|
|
58
|
+
<div className="diff-canvas">
|
|
59
|
+
<div className="diff-summary">
|
|
60
|
+
<span>{parsed.files.length} 个文件</span>
|
|
61
|
+
<span className="diff-summary-stats">
|
|
62
|
+
{parsed.additions > 0 && <em className="diff-add-count">+{parsed.additions}</em>}
|
|
63
|
+
{parsed.deletions > 0 && <em className="diff-del-count">−{parsed.deletions}</em>}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
{parsed.files.map((file, index) => <DiffFileBlock file={file} key={index} />)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* DiffView — Codex 式彩色 unified diff。token 依赖消费端 @theme(ink/line/surface/accent/success)。 */
|
|
2
|
+
.diff-canvas { display: grid; gap: 0.85rem; }
|
|
3
|
+
.diff-summary { display: flex; justify-content: space-between; gap: 0.75rem; font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--color-muted); }
|
|
4
|
+
.diff-summary-stats { display: inline-flex; gap: 0.5rem; }
|
|
5
|
+
.diff-add-count { color: var(--color-success); font-style: normal; }
|
|
6
|
+
.diff-del-count { color: #b03a28; font-style: normal; }
|
|
7
|
+
.diff-file { border: 1px solid var(--color-line); border-radius: var(--radius); background: var(--color-surface); overflow: hidden; }
|
|
8
|
+
.diff-file-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--color-line); background: var(--color-surface-strong); }
|
|
9
|
+
.diff-file-path { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--color-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
10
|
+
.diff-file-stats { display: inline-flex; gap: 0.5rem; font-family: var(--font-mono); font-size: 0.625rem; }
|
|
11
|
+
.diff-hunk { display: grid; }
|
|
12
|
+
.diff-line { display: grid; grid-template-columns: 1.25rem minmax(0, 1fr); font-family: var(--font-mono); font-size: 0.65625rem; line-height: 1.6; }
|
|
13
|
+
.diff-gutter { text-align: center; user-select: none; }
|
|
14
|
+
.diff-text { padding-left: 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
15
|
+
.diff-line.hunk { background: color-mix(in oklab, var(--color-accent-ink) 82%, var(--color-accent)); color: var(--color-accent-strong); }
|
|
16
|
+
.diff-line.add { background: color-mix(in oklab, var(--color-success) 16%, var(--color-surface)); color: var(--color-ink); }
|
|
17
|
+
.diff-line.remove { background: color-mix(in oklab, #b03a28 14%, var(--color-surface)); color: var(--color-ink); }
|
|
18
|
+
.diff-line.meta { color: var(--color-muted); background: var(--color-surface); }
|
|
19
|
+
.diff-line.context { color: var(--color-muted); background: var(--color-surface); }
|
|
20
|
+
.diff-line.add .diff-gutter { color: var(--color-success); }
|
|
21
|
+
.diff-line.remove .diff-gutter { color: #b03a28; }
|
|
22
|
+
.diff-preview { max-height: min(48dvh, 34rem); margin: 0; overflow: auto; padding: 0.75rem; border: 1px solid var(--color-line); background: var(--color-ink); color: var(--color-paper); font-family: var(--font-mono); font-size: 0.65625rem; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type DiffLine =
|
|
2
|
+
| { type: "hunk"; oldStart: number; newStart: number; text: string }
|
|
3
|
+
| { type: "context"; text: string }
|
|
4
|
+
| { type: "add"; text: string }
|
|
5
|
+
| { type: "remove"; text: string }
|
|
6
|
+
| { type: "meta"; text: string };
|
|
7
|
+
|
|
8
|
+
export type DiffFile = {
|
|
9
|
+
oldPath: string | null;
|
|
10
|
+
newPath: string | null;
|
|
11
|
+
additions: number;
|
|
12
|
+
deletions: number;
|
|
13
|
+
hunks: Array<{ oldStart: number; newStart: number; lines: DiffLine[] }>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UnifiedDiff = {
|
|
17
|
+
files: DiffFile[];
|
|
18
|
+
additions: number;
|
|
19
|
+
deletions: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function parseHunkHeader(line: string): { oldStart: number; newStart: number } | null {
|
|
23
|
+
const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
return { oldStart: Number.parseInt(match[1], 10), newStart: Number.parseInt(match[2], 10) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses a unified diff (as produced by `git diff` / `createUnifiedDiff`)
|
|
30
|
+
* into structured files/hunks/lines so UIs can render Codex-style colored
|
|
31
|
+
* changes instead of a raw <pre>. Unknown lines degrade to context.
|
|
32
|
+
*/
|
|
33
|
+
export function parseUnifiedDiff(input: string): UnifiedDiff {
|
|
34
|
+
const files: DiffFile[] = [];
|
|
35
|
+
let current: DiffFile | null = null;
|
|
36
|
+
let currentHunk: { oldStart: number; newStart: number; lines: DiffLine[] } | null = null;
|
|
37
|
+
let additions = 0;
|
|
38
|
+
let deletions = 0;
|
|
39
|
+
|
|
40
|
+
for (const raw of input.split("\n")) {
|
|
41
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
42
|
+
if (line.startsWith("diff --git ") || (line.startsWith("--- ") && current !== null && current.oldPath !== null)) {
|
|
43
|
+
if (current && currentHunk) {
|
|
44
|
+
current.hunks.push(currentHunk);
|
|
45
|
+
currentHunk = null;
|
|
46
|
+
}
|
|
47
|
+
current = { oldPath: null, newPath: null, additions: 0, deletions: 0, hunks: [] };
|
|
48
|
+
files.push(current);
|
|
49
|
+
if (line.startsWith("diff --git ")) continue;
|
|
50
|
+
}
|
|
51
|
+
if (line.startsWith("--- ")) {
|
|
52
|
+
if (!current) {
|
|
53
|
+
current = { oldPath: null, newPath: null, additions: 0, deletions: 0, hunks: [] };
|
|
54
|
+
files.push(current);
|
|
55
|
+
}
|
|
56
|
+
current.oldPath = line.slice(4).replace(/^(a|b)\//, "");
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (line.startsWith("+++ ")) {
|
|
60
|
+
if (!current) {
|
|
61
|
+
current = { oldPath: null, newPath: null, additions: 0, deletions: 0, hunks: [] };
|
|
62
|
+
files.push(current);
|
|
63
|
+
}
|
|
64
|
+
current.newPath = line.slice(4).replace(/^(a|b)\//, "");
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (line.startsWith("@@")) {
|
|
68
|
+
const header = parseHunkHeader(line);
|
|
69
|
+
if (current && header) {
|
|
70
|
+
if (currentHunk) current.hunks.push(currentHunk);
|
|
71
|
+
currentHunk = { oldStart: header.oldStart, newStart: header.newStart, lines: [] };
|
|
72
|
+
currentHunk.lines.push({ type: "hunk", oldStart: header.oldStart, newStart: header.newStart, text: line });
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!current || !currentHunk) continue;
|
|
77
|
+
if (line.startsWith("+")) {
|
|
78
|
+
currentHunk.lines.push({ type: "add", text: line.slice(1) });
|
|
79
|
+
current.additions += 1;
|
|
80
|
+
additions += 1;
|
|
81
|
+
} else if (line.startsWith("-")) {
|
|
82
|
+
currentHunk.lines.push({ type: "remove", text: line.slice(1) });
|
|
83
|
+
current.deletions += 1;
|
|
84
|
+
deletions += 1;
|
|
85
|
+
} else if (line.startsWith("\\")) {
|
|
86
|
+
currentHunk.lines.push({ type: "meta", text: line });
|
|
87
|
+
} else {
|
|
88
|
+
currentHunk.lines.push({ type: "context", text: line.startsWith(" ") ? line.slice(1) : line });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (current && currentHunk) current.hunks.push(currentHunk);
|
|
93
|
+
return { files, additions, deletions };
|
|
94
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -48,6 +48,13 @@ export * from "./code-block";
|
|
|
48
48
|
export * from "./multi-step-loader";
|
|
49
49
|
export * from "./timeline";
|
|
50
50
|
|
|
51
|
+
// === Business (agent workflow) ===
|
|
52
|
+
export * from "./markdown";
|
|
53
|
+
export * from "./diff-view";
|
|
54
|
+
export * from "./tool-card";
|
|
55
|
+
export * from "./todo-checklist";
|
|
56
|
+
export * from "./permission-card";
|
|
57
|
+
|
|
51
58
|
// === Text Effects ===
|
|
52
59
|
export * from "./text-generate";
|
|
53
60
|
export * from "./encrypt-text";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Children, memo, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import hljs from "highlight.js/lib/core";
|
|
7
|
+
|
|
8
|
+
import bash from "highlight.js/lib/languages/bash";
|
|
9
|
+
import css from "highlight.js/lib/languages/css";
|
|
10
|
+
import javascript from "highlight.js/lib/languages/javascript";
|
|
11
|
+
import json from "highlight.js/lib/languages/json";
|
|
12
|
+
import markdownLang from "highlight.js/lib/languages/markdown";
|
|
13
|
+
import plaintext from "highlight.js/lib/languages/plaintext";
|
|
14
|
+
import python from "highlight.js/lib/languages/python";
|
|
15
|
+
import typescript from "highlight.js/lib/languages/typescript";
|
|
16
|
+
import xml from "highlight.js/lib/languages/xml";
|
|
17
|
+
|
|
18
|
+
import "./markdown.css";
|
|
19
|
+
|
|
20
|
+
// Curated language set keeps the client bundle small; every other language
|
|
21
|
+
// falls back to plain text.
|
|
22
|
+
hljs.registerLanguage("bash", bash);
|
|
23
|
+
hljs.registerLanguage("sh", bash);
|
|
24
|
+
hljs.registerLanguage("shell", bash);
|
|
25
|
+
hljs.registerLanguage("css", css);
|
|
26
|
+
hljs.registerLanguage("javascript", javascript);
|
|
27
|
+
hljs.registerLanguage("js", javascript);
|
|
28
|
+
hljs.registerLanguage("jsx", javascript);
|
|
29
|
+
hljs.registerLanguage("json", json);
|
|
30
|
+
hljs.registerLanguage("markdown", markdownLang);
|
|
31
|
+
hljs.registerLanguage("plaintext", plaintext);
|
|
32
|
+
hljs.registerLanguage("text", plaintext);
|
|
33
|
+
hljs.registerLanguage("python", python);
|
|
34
|
+
hljs.registerLanguage("py", python);
|
|
35
|
+
hljs.registerLanguage("typescript", typescript);
|
|
36
|
+
hljs.registerLanguage("ts", typescript);
|
|
37
|
+
hljs.registerLanguage("tsx", typescript);
|
|
38
|
+
hljs.registerLanguage("xml", xml);
|
|
39
|
+
hljs.registerLanguage("html", xml);
|
|
40
|
+
|
|
41
|
+
function escapeHtml(value: string): string {
|
|
42
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Streaming deltas re-render frequently; memoize highlight output per exact
|
|
46
|
+
// (language, code) pair so only changed blocks pay for highlighting.
|
|
47
|
+
const highlightCache = new Map<string, string>();
|
|
48
|
+
|
|
49
|
+
function highlightCode(code: string, language: string | undefined): string {
|
|
50
|
+
const key = `${language ?? ""}\u0000${code}`;
|
|
51
|
+
const cached = highlightCache.get(key);
|
|
52
|
+
if (cached !== undefined) return cached;
|
|
53
|
+
let html: string;
|
|
54
|
+
if (language && hljs.getLanguage(language)) {
|
|
55
|
+
try {
|
|
56
|
+
html = hljs.highlight(code, { language }).value;
|
|
57
|
+
} catch {
|
|
58
|
+
html = escapeHtml(code);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
html = escapeHtml(code);
|
|
62
|
+
}
|
|
63
|
+
if (highlightCache.size > 600) highlightCache.clear();
|
|
64
|
+
highlightCache.set(key, html);
|
|
65
|
+
return html;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function CodeBlock({ language, code }: { language: string | undefined; code: string }) {
|
|
69
|
+
const [copied, setCopied] = useState(false);
|
|
70
|
+
const html = useMemo(() => highlightCode(code, language), [code, language]);
|
|
71
|
+
const copy = async () => {
|
|
72
|
+
try {
|
|
73
|
+
await navigator.clipboard.writeText(code);
|
|
74
|
+
setCopied(true);
|
|
75
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
76
|
+
} catch {
|
|
77
|
+
// Clipboard unavailable (e.g. non-secure context); the block stays readable.
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return (
|
|
81
|
+
<div className="md-codeblock">
|
|
82
|
+
<div className="md-codeblock-head">
|
|
83
|
+
<span>{language ?? "text"}</span>
|
|
84
|
+
<button type="button" className="md-copy" onClick={() => void copy()}>{copied ? "已复制" : "复制"}</button>
|
|
85
|
+
</div>
|
|
86
|
+
<pre><code className={language ? `language-${language}` : undefined} dangerouslySetInnerHTML={{ __html: html }} /></pre>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Markdown — streaming GFM renderer.
|
|
93
|
+
*
|
|
94
|
+
* `message.delta` chunks can be fed as partial markdown; block rendering
|
|
95
|
+
* stays stable while the text grows. Fenced code blocks render as
|
|
96
|
+
* highlighted cards with a copy button; bare code is inline.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* <Markdown text={streamingText} />
|
|
100
|
+
*/
|
|
101
|
+
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="markdown-body">
|
|
104
|
+
<ReactMarkdown
|
|
105
|
+
remarkPlugins={[remarkGfm]}
|
|
106
|
+
components={{
|
|
107
|
+
// react-markdown feeds every fenced block through <pre><code>. We
|
|
108
|
+
// intercept the pre to render a highlighted block with a copy
|
|
109
|
+
// button; bare <code> is inline.
|
|
110
|
+
pre({ children }) {
|
|
111
|
+
const child = Children.only(children) as ReactElement<{ className?: string; children?: ReactNode }>;
|
|
112
|
+
const className = typeof child?.props?.className === "string" ? child.props.className : undefined;
|
|
113
|
+
const match = /language-(\w+)/.exec(className ?? "");
|
|
114
|
+
const code = String(child?.props?.children ?? "").replace(/\n$/, "");
|
|
115
|
+
return <CodeBlock language={match?.[1]} code={code} />;
|
|
116
|
+
},
|
|
117
|
+
code({ className, children, ...props }) {
|
|
118
|
+
return <code className={className} {...props}>{children}</code>;
|
|
119
|
+
},
|
|
120
|
+
a({ children, ...props }) {
|
|
121
|
+
return <a {...props} target="_blank" rel="noreferrer">{children}</a>;
|
|
122
|
+
},
|
|
123
|
+
}}
|
|
124
|
+
>{text}</ReactMarkdown>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./Markdown";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Markdown — GFM 流式渲染 + hljs 暖墨代码主题。token 依赖消费端 @theme。 */
|
|
2
|
+
.markdown-body { min-width: 0; font-size: 0.9375rem; line-height: 1.7; overflow-wrap: anywhere; }
|
|
3
|
+
.markdown-body > :first-child { margin-top: 0; }
|
|
4
|
+
.markdown-body > :last-child { margin-bottom: 0; }
|
|
5
|
+
.markdown-body p { margin: 0.6rem 0; }
|
|
6
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 1.2rem 0 0.5rem; line-height: 1.35; letter-spacing: -0.01em; }
|
|
7
|
+
.markdown-body h1 { font-size: 1.25rem; } .markdown-body h2 { font-size: 1.125rem; } .markdown-body h3, .markdown-body h4 { font-size: 1rem; }
|
|
8
|
+
.markdown-body ul, .markdown-body ol { margin: 0.6rem 0; padding-left: 1.35rem; }
|
|
9
|
+
.markdown-body li { margin: 0.25rem 0; }
|
|
10
|
+
.markdown-body blockquote { margin: 0.7rem 0; padding-left: 0.9rem; border-left: 3px solid var(--color-ink); color: var(--color-muted); }
|
|
11
|
+
.markdown-body a { color: var(--color-accent-strong); text-decoration: underline; text-underline-offset: 2px; }
|
|
12
|
+
.markdown-body a:hover { color: var(--color-accent); }
|
|
13
|
+
.markdown-body code { font-family: var(--font-mono); font-size: 0.82em; padding: 0.12em 0.35em; background: var(--color-surface-strong); border: 1px solid var(--color-line); }
|
|
14
|
+
.markdown-body table { border-collapse: collapse; margin: 0.8rem 0; width: 100%; font-size: 0.85rem; }
|
|
15
|
+
.markdown-body th, .markdown-body td { border: 1px solid var(--color-line); padding: 0.4rem 0.6rem; text-align: left; }
|
|
16
|
+
.markdown-body th { background: var(--color-surface-strong); font-weight: 700; }
|
|
17
|
+
.markdown-body hr { border: 0; border-top: 1px solid var(--color-line); margin: 1.2rem 0; }
|
|
18
|
+
.markdown-body .md-codeblock code { padding: 0; border: 0; background: transparent; font-size: inherit; }
|
|
19
|
+
|
|
20
|
+
.md-codeblock { margin: 0.8rem 0; border: 1px solid var(--color-line); border-radius: var(--radius); overflow: hidden; }
|
|
21
|
+
.md-codeblock-head { display: flex; align-items: center; justify-content: space-between; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--color-line); background: var(--color-surface-strong); font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--color-muted); }
|
|
22
|
+
.md-copy { border: 0; border-bottom: 1px solid currentColor; background: transparent; color: var(--color-accent-strong); font: inherit; padding: 0; cursor: pointer; }
|
|
23
|
+
.md-copy:hover { color: var(--color-accent); }
|
|
24
|
+
.md-codeblock pre { max-height: 24rem; margin: 0; overflow: auto; padding: 0.75rem; background: var(--color-ink); color: var(--color-paper); font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.65; }
|
|
25
|
+
.md-codeblock code { font-family: inherit; }
|
|
26
|
+
|
|
27
|
+
/* hljs 暖墨主题(深底暖字) */
|
|
28
|
+
.md-codeblock .hljs-comment, .md-codeblock .hljs-quote { color: #9a9184; font-style: italic; }
|
|
29
|
+
.md-codeblock .hljs-keyword, .md-codeblock .hljs-selector-tag, .md-codeblock .hljs-literal { color: #e08a7a; }
|
|
30
|
+
.md-codeblock .hljs-string, .md-codeblock .hljs-attr, .md-codeblock .hljs-template-tag { color: #c9b896; }
|
|
31
|
+
.md-codeblock .hljs-number, .md-codeblock .hljs-built_in, .md-codeblock .hljs-symbol { color: #d9a05b; }
|
|
32
|
+
.md-codeblock .hljs-title, .md-codeblock .hljs-section, .md-codeblock .hljs-function .hljs-title { color: #f2e9d8; }
|
|
33
|
+
.md-codeblock .hljs-type, .md-codeblock .hljs-class .hljs-title { color: #e8c4a8; }
|
|
34
|
+
.md-codeblock .hljs-variable, .md-codeblock .hljs-template-variable { color: #f2e9d8; }
|
|
35
|
+
.md-codeblock .hljs-params { color: #f2e9d8; }
|
|
36
|
+
.md-codeblock .hljs-meta, .md-codeblock .hljs-tag { color: #9a9184; }
|
|
37
|
+
.md-codeblock .hljs-name { color: #e08a7a; }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "../../components/badge";
|
|
6
|
+
import { Button } from "../../components/button";
|
|
7
|
+
import { Input } from "../../components/input";
|
|
8
|
+
import "./permission-card.css";
|
|
9
|
+
|
|
10
|
+
export type PermissionRequestData = {
|
|
11
|
+
id: string;
|
|
12
|
+
permission: string;
|
|
13
|
+
patterns: string[];
|
|
14
|
+
metadata?: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PermissionReply = "once" | "always" | "reject";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* PermissionCard — 内联审批卡:Agent 请求授权时在消息流中渲染。
|
|
21
|
+
*
|
|
22
|
+
* 印章红描边 + 说明文案 + 三档操作(允许一次 / 始终允许 / 拒绝),
|
|
23
|
+
* 拒绝可附理由(会反馈给 Agent)。
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <PermissionCard request={req} busy={replying} onReply={(reply, feedback) => …} />
|
|
27
|
+
*/
|
|
28
|
+
export function PermissionCard({ request, busy = false, onReply }: {
|
|
29
|
+
request: PermissionRequestData;
|
|
30
|
+
busy?: boolean;
|
|
31
|
+
onReply: (reply: PermissionReply, feedback?: string) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [feedback, setFeedback] = useState("");
|
|
34
|
+
const command = typeof (request.metadata as { command?: unknown } | undefined)?.command === "string" ? (request.metadata as { command: string }).command : null;
|
|
35
|
+
return (
|
|
36
|
+
<article className="zmz-permission-card">
|
|
37
|
+
<div className="zmz-permission-head">
|
|
38
|
+
<Badge variant="solid" size="sm">{request.permission}</Badge>
|
|
39
|
+
<strong>{command ?? request.patterns.join("、")}</strong>
|
|
40
|
+
</div>
|
|
41
|
+
<p className="zmz-permission-note">
|
|
42
|
+
{request.permission === "bash" ? "Agent 请求在隔离沙箱中执行这条命令。批准后本次运行一次有效;选择「始终允许」则同任务内同类命令不再询问。" : "Agent 请求执行此操作。"}
|
|
43
|
+
</p>
|
|
44
|
+
<div className="zmz-permission-actions">
|
|
45
|
+
<Input
|
|
46
|
+
className="zmz-permission-feedback"
|
|
47
|
+
placeholder="拒绝理由(可选,会反馈给 Agent)"
|
|
48
|
+
value={feedback}
|
|
49
|
+
onChange={(event) => setFeedback(event.target.value)}
|
|
50
|
+
/>
|
|
51
|
+
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={() => onReply("reject", feedback || undefined)}>
|
|
52
|
+
拒绝
|
|
53
|
+
</Button>
|
|
54
|
+
<Button type="button" variant="secondary" size="sm" disabled={busy} onClick={() => onReply("always")}>
|
|
55
|
+
始终允许
|
|
56
|
+
</Button>
|
|
57
|
+
<Button type="button" size="sm" disabled={busy} onClick={() => onReply("once")}>
|
|
58
|
+
允许一次
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
</article>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./PermissionCard";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* PermissionCard — 内联审批卡。token 依赖消费端 @theme。 */
|
|
2
|
+
.zmz-permission-card { border: 1px solid var(--color-accent); border-radius: var(--radius); padding: 0.85rem 1rem; background: color-mix(in oklab, var(--color-accent) 4%, var(--color-surface)); margin: 0.5rem 0; }
|
|
3
|
+
.zmz-permission-head { display: flex; align-items: center; gap: 0.6rem; }
|
|
4
|
+
.zmz-permission-head strong { font-family: var(--font-mono); font-size: 0.8125rem; overflow-wrap: anywhere; }
|
|
5
|
+
.zmz-permission-note { margin: 0.5rem 0 0.75rem; font-size: 0.8125rem; color: var(--color-muted); }
|
|
6
|
+
.zmz-permission-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
|
|
7
|
+
.zmz-permission-feedback { flex: 1 1 12rem; min-width: 0; }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "../../components/badge";
|
|
6
|
+
import { Icon } from "../../components/icon/Icon";
|
|
7
|
+
import { ToolCard, type ToolCall } from "../../components/tool-card";
|
|
8
|
+
import "./todo-checklist.css";
|
|
9
|
+
|
|
10
|
+
export type TodoItem = {
|
|
11
|
+
content: string;
|
|
12
|
+
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
13
|
+
priority?: "high" | "medium" | "low";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** 从 todo 工具调用的 completed metadata 读进度游标(约定:N 表示第 N+1 步进行中)。 */
|
|
17
|
+
function metadataCount(call: ToolCall): number | null {
|
|
18
|
+
if (call.state.status !== "completed") return null;
|
|
19
|
+
const value = call.state.metadata?.completed;
|
|
20
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 把工具调用按 todo 进度游标分配到各步骤名下(分支渲染)。 */
|
|
24
|
+
function assignToolsToTodos(todos: TodoItem[], calls: ToolCall[]): ToolCall[][] {
|
|
25
|
+
const branches = todos.map(() => [] as ToolCall[]);
|
|
26
|
+
let branchIndex = 0;
|
|
27
|
+
for (const call of calls) {
|
|
28
|
+
if (call.tool === "todo") {
|
|
29
|
+
const completed = metadataCount(call);
|
|
30
|
+
if (completed !== null) branchIndex = Math.min(Math.max(completed, 0), todos.length - 1);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
branches[branchIndex]?.push(call);
|
|
34
|
+
}
|
|
35
|
+
return branches;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TaskPlanNode({ todo, index, calls }: { todo: TodoItem; index: number; calls: ToolCall[] }) {
|
|
39
|
+
const [expanded, setExpanded] = useState(false);
|
|
40
|
+
const active = todo.status === "in_progress";
|
|
41
|
+
const canExpand = calls.length > 0;
|
|
42
|
+
const open = canExpand && (active || expanded);
|
|
43
|
+
const state = active ? "当前执行" : todo.status === "completed" ? "已完成" : todo.status === "cancelled" ? "已跳过" : "待执行";
|
|
44
|
+
const stateVariant = active ? "accent" : todo.status === "completed" ? "success" : todo.status === "cancelled" ? "danger" : "outline";
|
|
45
|
+
return (
|
|
46
|
+
<li className={`zmz-task-node ${todo.status}`}>
|
|
47
|
+
<button type="button" className="zmz-task-node-trigger" aria-expanded={open} disabled={!canExpand} onClick={() => setExpanded((value) => !value)}>
|
|
48
|
+
<span className="zmz-task-node-marker" aria-hidden>{todo.status === "completed" ? <Icon name="check" size={10} /> : active ? <span className="zmz-todo-spinner" /> : null}</span>
|
|
49
|
+
<span className="zmz-task-node-copy"><span className="zmz-task-node-index">{String(index + 1).padStart(2, "0")}</span><strong>{todo.content}</strong></span>
|
|
50
|
+
{canExpand ? <Badge variant="outline" size="sm">{calls.length} 次执行</Badge> : <Badge variant={stateVariant} size="sm">{state}</Badge>}
|
|
51
|
+
{canExpand && <Icon name="chevron-down" size={12} className={open ? "zmz-chevron open" : "zmz-chevron"} />}
|
|
52
|
+
</button>
|
|
53
|
+
{open && calls.length > 0 && <div className="zmz-task-executions">{calls.map((call) => <ToolCard key={`${call.id}:${call.state.status}`} call={call} />)}</div>}
|
|
54
|
+
</li>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* TodoChecklist — Task Plan 卡:进度摘要 + 步骤清单,每步可展开看工具执行。
|
|
60
|
+
*
|
|
61
|
+
* `calls` 可选:传入会话的工具调用后,按 todo 进度游标自动分配到各步骤
|
|
62
|
+
* 名下(约定 tool 名为 "todo" 的调用、其 completed metadata 为游标)。
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* <TodoChecklist todos={todos} calls={toolCalls} />
|
|
66
|
+
*/
|
|
67
|
+
export function TodoChecklist({ todos, calls = [] }: { todos: TodoItem[]; calls?: ToolCall[] }) {
|
|
68
|
+
if (!todos.length) return null;
|
|
69
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
70
|
+
const current = todos.find((todo) => todo.status === "in_progress");
|
|
71
|
+
const progress = Math.round((done / todos.length) * 100);
|
|
72
|
+
const branches = assignToolsToTodos(todos, calls);
|
|
73
|
+
return (
|
|
74
|
+
<section className="zmz-todo">
|
|
75
|
+
<div className="zmz-todo-head">
|
|
76
|
+
<div className="zmz-todo-heading"><span className="zmz-todo-kicker">Task Plan</span><Badge variant={current ? "accent" : done === todos.length ? "success" : "outline"} size="sm">{current ? "执行中" : done === todos.length ? "已完成" : "待执行"}</Badge></div>
|
|
77
|
+
<span className="zmz-todo-progress"><b>{done}</b>/{todos.length}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="zmz-todo-summary"><span>{current?.content ?? (done === todos.length ? "所有步骤已完成" : "等待 Agent 开始执行")}</span><span>{progress}%</span></div>
|
|
80
|
+
<div className="zmz-todo-progressbar" aria-hidden><span style={{ width: `${progress}%` }} /></div>
|
|
81
|
+
<ol className="zmz-todo-list">
|
|
82
|
+
{todos.map((todo, index) => (
|
|
83
|
+
<TaskPlanNode key={`${todo.content}-${index}`} todo={todo} index={index} calls={branches[index] ?? []} />
|
|
84
|
+
))}
|
|
85
|
+
</ol>
|
|
86
|
+
</section>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./TodoChecklist";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* TodoChecklist — Task Plan 卡。token 依赖消费端 @theme。 */
|
|
2
|
+
@keyframes zmz-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
|
3
|
+
|
|
4
|
+
.zmz-todo { margin: 0 0 1rem; padding: 0.75rem 0.8rem 0.7rem; border: 1px solid var(--color-line); border-left: 2px solid var(--color-ink); border-radius: var(--radius); background: color-mix(in oklab, var(--color-surface) 94%, var(--color-bg)); }
|
|
5
|
+
.zmz-todo-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 0.55rem; }
|
|
6
|
+
.zmz-todo-heading { display: flex; align-items: baseline; gap: 0.55rem; }
|
|
7
|
+
.zmz-todo-kicker { color: var(--color-muted); font-family: var(--font-mono); font-size: 0.58rem; letter-spacing: 0.1em; text-transform: uppercase; }
|
|
8
|
+
.zmz-todo-progress { color: var(--color-muted); font-family: var(--font-mono); font-size: 0.62rem; }
|
|
9
|
+
.zmz-todo-progress b { color: var(--color-ink); font-weight: 700; }
|
|
10
|
+
.zmz-todo-summary { display: flex; justify-content: space-between; gap: 1rem; min-width: 0; margin-bottom: 0.45rem; color: var(--color-ink); font-size: 0.78rem; line-height: 1.35; }
|
|
11
|
+
.zmz-todo-summary span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
12
|
+
.zmz-todo-summary span:last-child { flex: 0 0 auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.6rem; }
|
|
13
|
+
.zmz-todo-progressbar { height: 3px; margin-bottom: 0.65rem; overflow: hidden; background: var(--color-surface-strong); }
|
|
14
|
+
.zmz-todo-progressbar span { display: block; height: 100%; background: var(--color-accent); transition: width 220ms var(--ease-out); }
|
|
15
|
+
.zmz-todo-list { position: relative; display: grid; gap: 0; margin: 0; padding: 0 0 0 0.15rem; list-style: none; }
|
|
16
|
+
.zmz-task-node { position: relative; }
|
|
17
|
+
.zmz-task-node:not(:last-child)::before { position: absolute; z-index: 0; top: 1.35rem; bottom: -0.1rem; left: 0.47rem; width: 1px; background: var(--color-line); content: ""; }
|
|
18
|
+
.zmz-task-node-trigger { position: relative; z-index: 1; display: grid; width: 100%; min-height: 2rem; grid-template-columns: 1rem minmax(0, 1fr) auto auto; align-items: center; gap: 0.45rem; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); padding: 0.35rem 0.25rem; text-align: left; cursor: pointer; transition: background 0.15s var(--ease-out); }
|
|
19
|
+
.zmz-task-node-trigger:hover { background: var(--color-surface); }
|
|
20
|
+
.zmz-task-node-trigger:disabled { cursor: default; }
|
|
21
|
+
.zmz-task-node-trigger:disabled:hover { background: transparent; }
|
|
22
|
+
.zmz-task-node-marker { display: inline-flex; width: 0.9rem; height: 0.9rem; align-items: center; justify-content: center; border: 1px solid var(--color-line); border-radius: 50%; background: var(--color-surface); color: var(--color-success); }
|
|
23
|
+
.zmz-task-node.in_progress .zmz-task-node-marker { border-color: var(--color-accent); }
|
|
24
|
+
.zmz-task-node-copy { display: flex; min-width: 0; align-items: baseline; gap: 0.45rem; }
|
|
25
|
+
.zmz-task-node-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.76rem; font-weight: 600; }
|
|
26
|
+
.zmz-task-node-index { flex: 0 0 auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.55rem; }
|
|
27
|
+
.zmz-task-node.completed .zmz-task-node-copy strong { color: var(--color-muted); text-decoration: line-through; }
|
|
28
|
+
.zmz-task-node.in_progress .zmz-task-node-copy strong { color: var(--color-accent-strong); }
|
|
29
|
+
.zmz-todo-spinner { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--color-accent); animation: zmz-pulse 1s var(--ease-out) infinite; }
|
|
30
|
+
.zmz-chevron { transition: transform 0.15s var(--ease-out); }
|
|
31
|
+
.zmz-chevron.open { transform: rotate(180deg); }
|
|
32
|
+
.zmz-task-executions { position: relative; display: grid; gap: 0; margin: 0 0 0.15rem 1.3rem; padding: 0.05rem 0 0.1rem 1rem; }
|
|
33
|
+
.zmz-task-executions::before { position: absolute; top: 0; bottom: 0.2rem; left: 0.35rem; width: 1px; background: var(--color-line); content: ""; }
|
|
34
|
+
.zmz-task-executions .tool-card::before { left: -0.65rem; width: 0.65rem; }
|
|
35
|
+
.zmz-task-executions .tool-card-detail { margin-left: 1.3rem; }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "../../components/badge";
|
|
6
|
+
import { Icon } from "../../components/icon/Icon";
|
|
7
|
+
import { formatToolInput, toolDuration, type ToolCall } from "./types";
|
|
8
|
+
import "./tool-card.css";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ToolCard — 单次工具调用卡:状态色左边条 + 展开/收起的输入输出详情。
|
|
12
|
+
*
|
|
13
|
+
* 运行中卡常开;进入终态后收起,点标题可再看输出。
|
|
14
|
+
* `sessionIdle=true` 时非终态工具渲染为「失败(中断)」——
|
|
15
|
+
* 会话空闲却停在 running 是崩溃/重启的遗留,不该永远转圈。
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* <ToolCard call={toolCall} sessionIdle={idle} />
|
|
19
|
+
*/
|
|
20
|
+
export function ToolCard({ call, sessionIdle = false }: { call: ToolCall; sessionIdle?: boolean }) {
|
|
21
|
+
const [expanded, setExpanded] = useState(false);
|
|
22
|
+
const state = call.state;
|
|
23
|
+
const running = state.status === "running" || state.status === "pending";
|
|
24
|
+
const interrupted = running && sessionIdle;
|
|
25
|
+
const title = state.status === "completed" ? state.title : state.status === "running" ? (state.title ?? call.tool) : call.tool;
|
|
26
|
+
const output = state.status === "completed" ? state.output : state.status === "error" ? state.error : null;
|
|
27
|
+
const statusClass = state.status === "completed" ? "completed" : interrupted ? "failed" : state.status === "error" ? "failed" : "running";
|
|
28
|
+
const isOpen = running || expanded;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={`tool-card ${statusClass}`}>
|
|
32
|
+
<button type="button" className="tool-card-trigger" aria-expanded={isOpen} onClick={() => setExpanded((value) => !value)}>
|
|
33
|
+
<span className="tool-card-glyph" aria-hidden><Icon name={state.status === "completed" ? "check" : interrupted || state.status === "error" ? "cross" : "chevron-down"} size={12} /></span>
|
|
34
|
+
<span className="tool-card-label">{title}</span>
|
|
35
|
+
{(state.status === "error" || interrupted) && <Badge variant="danger" size="sm">失败</Badge>}
|
|
36
|
+
{running && !interrupted && <Badge variant="warning" size="sm">运行中</Badge>}
|
|
37
|
+
{toolDuration(call) && <span className="tool-card-duration">{toolDuration(call)}</span>}
|
|
38
|
+
<Icon name="chevron-down" size={12} className={isOpen ? "tool-card-chevron open" : "tool-card-chevron"} />
|
|
39
|
+
</button>
|
|
40
|
+
{isOpen && (
|
|
41
|
+
<div className="tool-card-detail">
|
|
42
|
+
<div className="tool-card-detail-section">
|
|
43
|
+
<span className="tool-card-detail-label">输入</span>
|
|
44
|
+
<pre>{formatToolInput(state.input)}</pre>
|
|
45
|
+
</div>
|
|
46
|
+
{output !== null && (
|
|
47
|
+
<div className="tool-card-detail-section">
|
|
48
|
+
<span className="tool-card-detail-label">输出</span>
|
|
49
|
+
<pre>{output}</pre>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
{running && <span className="tool-card-live-note">{interrupted ? "运行已中断(服务重启),可在同一会话继续。" : "正在等待工具返回结果…"}</span>}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ToolGroup — 连续工具调用的折叠组(G1 防淹没)。
|
|
61
|
+
*
|
|
62
|
+
* 运行中/有失败/单条时自动展开;多条完成时折叠为一条摘要行。
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* <ToolGroup calls={toolCalls} sessionIdle={idle} />
|
|
66
|
+
*/
|
|
67
|
+
export function ToolGroup({ calls, sessionIdle = false }: { calls: ToolCall[]; sessionIdle?: boolean }) {
|
|
68
|
+
const [expanded, setExpanded] = useState(false);
|
|
69
|
+
const running = calls.filter((c) => c.state.status === "running" || c.state.status === "pending");
|
|
70
|
+
const failed = calls.filter((c) => c.state.status === "error" || (sessionIdle && (c.state.status === "running" || c.state.status === "pending")));
|
|
71
|
+
const done = calls.filter((c) => c.state.status === "completed");
|
|
72
|
+
const autoExpand = running.length > 0 || failed.length > 0 || calls.length <= 1;
|
|
73
|
+
const open = expanded || autoExpand;
|
|
74
|
+
const glyph = failed.length > 0 ? "cross" : running.length > 0 ? "chevron-down" : "check";
|
|
75
|
+
const summary = [
|
|
76
|
+
running.length > 0 && `${running.length} 个进行中`,
|
|
77
|
+
failed.length > 0 && `${failed.length} 个失败`,
|
|
78
|
+
done.length > 0 && `${done.length} 个完成`,
|
|
79
|
+
].filter(Boolean).join(" · ");
|
|
80
|
+
return (
|
|
81
|
+
<div className="zmz-tool-group">
|
|
82
|
+
<button type="button" className="zmz-tool-group-trigger" aria-expanded={open} onClick={() => setExpanded((value) => !value)} disabled={autoExpand}>
|
|
83
|
+
<span className="tool-card-glyph" aria-hidden><Icon name={glyph} size={12} /></span>
|
|
84
|
+
<span className="zmz-tool-group-label">运行了 {calls.length} 个工具</span>
|
|
85
|
+
{!autoExpand && <small>{summary}</small>}
|
|
86
|
+
<Icon name="chevron-down" size={12} className={open ? "tool-card-chevron open" : "tool-card-chevron"} />
|
|
87
|
+
</button>
|
|
88
|
+
{open && (
|
|
89
|
+
<div className="zmz-tool-group-body">
|
|
90
|
+
{calls.map((call) => <ToolCard key={`${call.id}:${call.state.status}`} call={call} sessionIdle={sessionIdle} />)}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* ToolCard / ToolGroup — 工具调用卡与折叠组。token 依赖消费端 @theme。
|
|
2
|
+
执行树分支线(::before)等上下文增强由消费端自己的 CSS 叠加。 */
|
|
3
|
+
@keyframes zmz-tool-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
|
4
|
+
|
|
5
|
+
.tool-card { position: relative; border-radius: var(--radius-sm); }
|
|
6
|
+
/* 执行树分支线(挂在左侧时间轴上;独立使用时容器需留出左侧空间) */
|
|
7
|
+
.tool-card::before { position: absolute; top: 1.08rem; left: -0.81rem; width: 0.81rem; height: 1px; background: var(--color-line); content: ""; }
|
|
8
|
+
.tool-card-trigger { display: flex; width: 100%; min-height: 2.15rem; align-items: center; gap: 0.5rem; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); padding: 0.38rem 0.4rem; text-align: left; cursor: pointer; transition: background 0.15s var(--ease-out); }
|
|
9
|
+
.tool-card-trigger:hover { background: var(--color-surface); }
|
|
10
|
+
.tool-card-glyph { display: grid; width: 1rem; height: 1rem; flex: 0 0 1rem; place-items: center; border-radius: 50%; color: var(--color-muted); }
|
|
11
|
+
.tool-card.running .tool-card-glyph { color: var(--color-accent); animation: zmz-tool-pulse 1s ease-in-out infinite; }
|
|
12
|
+
.tool-card.completed .tool-card-glyph { color: var(--color-success); }
|
|
13
|
+
.tool-card.failed .tool-card-glyph { color: #b03a28; }
|
|
14
|
+
.tool-card-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.75rem; }
|
|
15
|
+
.tool-card-duration { flex: 0 0 auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.6rem; }
|
|
16
|
+
.tool-card-chevron { flex: 0 0 auto; color: var(--color-muted); transition: transform 0.15s var(--ease-out); }
|
|
17
|
+
.tool-card-chevron.open { transform: rotate(180deg); }
|
|
18
|
+
.tool-card-detail { display: grid; gap: 0.6rem; max-height: 18rem; margin: 0 0 0.25rem 1.5rem; overflow: auto; border-left: 1px solid var(--color-line); padding: 0.45rem 0.65rem; background: color-mix(in oklab, var(--color-surface-strong) 42%, var(--color-surface)); }
|
|
19
|
+
.tool-card-detail-section { display: grid; gap: 0.25rem; }
|
|
20
|
+
.tool-card-detail-label { color: var(--color-muted); font-family: var(--font-mono); font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
21
|
+
.tool-card-detail pre { margin: 0; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.68rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
22
|
+
.tool-card-live-note { color: var(--color-accent-strong); font-family: var(--font-mono); font-size: 0.62rem; }
|
|
23
|
+
|
|
24
|
+
.zmz-tool-group { display: grid; gap: 0.5rem; margin: 0.4rem 0; }
|
|
25
|
+
.zmz-tool-group-trigger { display: flex; align-items: center; gap: 0.5rem; width: 100%; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: color-mix(in oklab, var(--color-surface) 92%, var(--color-bg)); color: var(--color-ink); padding: 0.4rem 0.6rem; cursor: pointer; transition: border-color 0.15s var(--ease-out); }
|
|
26
|
+
.zmz-tool-group-trigger:hover:not(:disabled) { border-color: var(--color-line-strong); }
|
|
27
|
+
.zmz-tool-group-trigger:disabled { cursor: default; opacity: 0.85; }
|
|
28
|
+
.zmz-tool-group-label { font-family: var(--font-mono); font-size: 0.7rem; }
|
|
29
|
+
.zmz-tool-group-trigger small { color: var(--color-muted); font-family: var(--font-mono); font-size: 0.6rem; }
|
|
30
|
+
.zmz-tool-group-body { display: grid; gap: 0.5rem; padding-left: 0.6rem; border-left: 2px solid var(--color-line); }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** 工具调用状态 — 与 agent-framework ToolState 对齐的通用形状。 */
|
|
2
|
+
export type ToolState =
|
|
3
|
+
| { status: "pending"; input: unknown }
|
|
4
|
+
| { status: "running"; input: unknown; title?: string; time: { start: string } }
|
|
5
|
+
| {
|
|
6
|
+
status: "completed";
|
|
7
|
+
input: unknown;
|
|
8
|
+
output: string;
|
|
9
|
+
title: string;
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
time: { start: string; end: string };
|
|
12
|
+
}
|
|
13
|
+
| { status: "error"; input: unknown; error: string; time: { start: string; end: string } };
|
|
14
|
+
|
|
15
|
+
/** 单次工具调用(会话事件投影中的 tool part,去掉 message/session 归属字段)。 */
|
|
16
|
+
export type ToolCall = {
|
|
17
|
+
id: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
state: ToolState;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function toolDuration(call: ToolCall): string | null {
|
|
23
|
+
const state = call.state;
|
|
24
|
+
if (state.status === "pending") return null;
|
|
25
|
+
if (!state.time || !("end" in state.time)) return null;
|
|
26
|
+
const ms = new Date(state.time.end).getTime() - new Date(state.time.start).getTime();
|
|
27
|
+
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
28
|
+
if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`;
|
|
29
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
|
30
|
+
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatToolInput(input: unknown): string {
|
|
34
|
+
if (input === null || input === undefined) return "";
|
|
35
|
+
if (typeof input === "string") return input;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(input, null, 2);
|
|
38
|
+
} catch {
|
|
39
|
+
return String(input);
|
|
40
|
+
}
|
|
41
|
+
}
|