docs: add technical architecture guide

This commit is contained in:
wuyang6
2026-05-18 19:28:02 +08:00
parent c4b935692c
commit 3054b3bc6b
53 changed files with 6692 additions and 2234 deletions
+368
View File
@@ -0,0 +1,368 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
const technicalDocs = {
"01-base-runtime": {
title: "基座 Runtime",
file: "01-base-runtime.md",
image: "/doc-assets/technical/01-base-runtime.png",
},
"02-agent-loop": {
title: "Agent Loop",
file: "02-agent-loop.md",
image: "/doc-assets/technical/02-agent-loop.png",
},
"03-tools": {
title: "工具体系",
file: "03-tools.md",
image: "/doc-assets/technical/03-tools.png",
},
"04-skills": {
title: "Skill 体系",
file: "04-skills.md",
image: "/doc-assets/technical/04-skills.png",
},
"05-workspace-memory-observability": {
title: "工作区、运行态和记忆",
file: "05-workspace-memory-observability.md",
image: "/doc-assets/technical/05-workspace-memory-observability.png",
},
"06-product-data": {
title: "product-data",
file: "06-product-data.md",
image: "/doc-assets/technical/06-product-data.png",
},
"07-online-mining-v2": {
title: "online-mining-v2",
file: "07-online-mining-v2.md",
image: "/doc-assets/technical/07-online-mining-v2.png",
},
"08-label-master": {
title: "标签大师",
file: "08-label-master.md",
image: "/doc-assets/technical/08-label-master.png",
},
"09-external-skills": {
title: "外部系统 Skill",
file: "09-external-skills.md",
image: "/doc-assets/technical/09-external-skills.png",
},
} as const;
type TechnicalDocSlug = keyof typeof technicalDocs;
function getDoc(slug: string) {
if (slug in technicalDocs) {
return technicalDocs[slug as TechnicalDocSlug];
}
return null;
}
function markdownPath(file: string) {
return path.join(
process.cwd(),
"public",
"doc-assets",
"technical-docs",
file,
);
}
function normalizeMarkdown(markdown: string) {
return markdown
.replace(/^\s*#\s+.+\n+/, "")
.replaceAll("](assets/", "](/doc-assets/technical/");
}
function parseInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[([^\]]+)\]\(([^)]+)\))/g;
let cursor = 0;
while (true) {
const match = pattern.exec(text);
if (match === null) break;
if (match.index > cursor) {
nodes.push(text.slice(cursor, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith("`")) {
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
} else if (token.startsWith("**")) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else {
const href = match[3] ?? "";
const isExternal = /^https?:\/\//.test(href);
nodes.push(
<a
href={href}
key={key}
rel={isExternal ? "noopener" : undefined}
target={isExternal ? "_blank" : undefined}
>
{match[2]}
</a>,
);
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return nodes;
}
function splitTableRow(line: string) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim());
}
function isTableDivider(line: string) {
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function startsBlock(line: string, nextLine?: string) {
return (
line.startsWith("```") ||
/^#{1,6}\s+/.test(line) ||
/^[-*]\s+/.test(line) ||
/^\d+\.\s+/.test(line) ||
/^>\s?/.test(line) ||
/^!\[[^\]]*]\([^)]+\)\s*$/.test(line) ||
(line.includes("|") && nextLine !== undefined && isTableDivider(nextLine))
);
}
function renderHeading(depth: number, text: string, key: string) {
if (depth === 1) return <h1 key={key}>{parseInline(text)}</h1>;
if (depth === 2) return <h2 key={key}>{parseInline(text)}</h2>;
if (depth === 3) return <h3 key={key}>{parseInline(text)}</h3>;
if (depth === 4) return <h4 key={key}>{parseInline(text)}</h4>;
if (depth === 5) return <h5 key={key}>{parseInline(text)}</h5>;
return <h6 key={key}>{parseInline(text)}</h6>;
}
function renderMarkdown(markdown: string) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (trimmed.startsWith("```")) {
const language = trimmed.slice(3).trim() || "text";
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith("```")) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push(
<div className="project-doc-code-block" key={`code-${index}`}>
<div>{language}</div>
<pre>
<code>{codeLines.join("\n")}</code>
</pre>
</div>,
);
continue;
}
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push(
renderHeading(heading[1].length, heading[2], `heading-${index}`),
);
index += 1;
continue;
}
const image = trimmed.match(/^!\[([^\]]*)]\(([^)]+)\)\s*$/);
if (image) {
blocks.push(
<Image
alt={image[1]}
height={900}
key={`image-${index}`}
src={image[2]}
width={1600}
/>,
);
index += 1;
continue;
}
if (
trimmed.includes("|") &&
index + 1 < lines.length &&
isTableDivider(lines[index + 1])
) {
const headers = splitTableRow(trimmed);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].trim().includes("|")) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
blocks.push(
<table key={`table-${index}`}>
<thead>
<tr>
{headers.map((cell) => (
<th key={cell}>{parseInline(cell)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.join("|")}>
{row.map((cell) => (
<td key={`${row.join("|")}-${cell}`}>{parseInline(cell)}</td>
))}
</tr>
))}
</tbody>
</table>,
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^[-*]\s+/, ""));
index += 1;
}
blocks.push(
<ul key={`ul-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ul>,
);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\.\s+/, ""));
index += 1;
}
blocks.push(
<ol key={`ol-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ol>,
);
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
index += 1;
}
blocks.push(
<blockquote key={`quote-${index}`}>
{parseInline(quoteLines.join(" "))}
</blockquote>,
);
continue;
}
const paragraphLines = [trimmed];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!startsBlock(lines[index].trim(), lines[index + 1]?.trim())
) {
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<p key={`p-${index}`}>{parseInline(paragraphLines.join(" "))}</p>,
);
}
return blocks;
}
export function generateStaticParams() {
return Object.keys(technicalDocs).map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) return {};
return {
title: `${doc.title} - ZK Data Agent`,
};
}
export default async function TechnicalDocPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) notFound();
const rawMarkdown = await readFile(markdownPath(doc.file), "utf8");
const markdown = normalizeMarkdown(rawMarkdown);
return (
<main className="project-doc-page project-doc-detail-page">
<header className="project-doc-detail-hero">
<nav className="project-doc-nav">
<a href="/doc">ZK Data Agent</a>
<div>
<a href="/doc"></a>
</div>
</nav>
<div className="project-doc-detail-head">
<p className="project-doc-kicker"></p>
<h1>{doc.title}</h1>
<a className="project-doc-back-link" href="/doc">
</a>
</div>
</header>
<section className="project-doc-detail-shell">
<article className="project-doc-article">
{renderMarkdown(markdown)}
</article>
</section>
</main>
);
}
+339 -605
View File
@@ -1,589 +1,266 @@
"use client";
import Image from "next/image";
import { ToolPositionMap } from "./tool-position-map";
import {
ArrowRight,
Check,
CheckCircle2,
CircleDashed,
FolderTree,
Network,
Route,
Sparkles,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
type RoleId = "user" | "builder" | "admin";
type ProductId = "chatgpt" | "cursor" | "claude" | "zk";
type MemoryId = "chatgpt" | "letta" | "mem0" | "rag" | "ours";
type SkillPartId = "skillmd" | "knowledge" | "scripts" | "examples" | "readme" | "update";
const roles: Record<
RoleId,
{
name: string;
title: string;
question: string;
answer: string;
workflow: string[];
proof: string[];
}
> = {
user: {
name: "普通使用者",
title: "我想让它替我完成一段工作",
question: "我为什么不用 ChatGPT 网页版问一问?",
answer:
"因为这里不是只回答一句话,而是把输入、计划、人工 review、脚本执行、文件产物和后续导出放进同一个任务空间。",
workflow: ["选择 Skill", "描述目标", "review 计划", "等待执行", "拿 output 文件"],
proof: ["会话文件面板", "右侧活动链路", "output/ 产物", "飞书在线文档转换"],
},
builder: {
name: "Skill 作者",
title: "我想把自己的经验变成团队能力",
question: "为什么不是每个人自己写脚本、自己维护 prompt?",
answer:
"Skill 把流程、知识、脚本、样例和维护说明放到一个目录。别人不用理解你的全部细节,也能在 Web 上调用这份能力。",
workflow: ["建 skill 目录", "写 SKILL.md", "沉淀 knowledge", "放 scripts", "提交 Git", "页面更新 Skill"],
proof: ["skills/product-data", "skills/online-mining-v2", "skills/label-master", "Skill 更新按钮"],
},
admin: {
name: "平台维护者",
title: "我想让多人稳定使用,而不是每个人本地各跑各的",
question: "为什么不用 Claude Code 本地跑?",
answer:
"Claude Code 很适合个人本地工程闭环;这里补的是团队 Web 入口、多用户会话、账号记忆、管理后台、共享 Skill 和统一部署。",
workflow: ["管理用户", "看会话与用量", "处理记忆队列", "更新服务", "维护公共 Skill"],
proof: ["/admin", "memory.db", "systemd 服务", "dev / online 环境", "会话隔离"],
},
type Section = {
id: string;
no: string;
title: string;
summary: string;
image?: string;
doc?: string;
points: string[];
code: string[];
decision: string;
};
const products: Record<
ProductId,
{
name: string;
position: string;
bestFor: string;
limits: string;
tech: string[];
}
> = {
chatgpt: {
name: "ChatGPT 网页版",
position: "最好的通用对话入口",
bestFor: "问答、写作、临时分析、个人信息辅助。",
limits: "不天然绑定团队数据源、产物目录、Skill 更新和多用户运维。",
tech: ["Saved Memory", "Reference Chat History", "Files", "Connectors"],
},
cursor: {
name: "Cursor",
position: "IDE 内的代码协作体验",
bestFor: "围绕代码仓库改文件、补全、解释、局部重构。",
limits: "偏个人 IDE 场景,业务流程共享、产物管理和多人 Web 入口不是核心。",
tech: ["Codebase Index", "Composer", "IDE Context", "Rules"],
},
claude: {
name: "Claude Code",
position: "本地工程 Agent 的强基准",
bestFor: "读代码、改文件、跑测试、长任务、多工具工程闭环。",
limits: "团队业务能力共建、账号级 Skill 记忆、Web 管理和数据开发产物链需要另搭平台。",
tech: ["Agent Loop", "Tools", "Skills", "MCP", "Terminal Workspace"],
},
zk: {
name: "ZK Data Agent",
position: "团队业务流程 Agent 工作台",
bestFor: "把数据开发、标签判断、线上挖掘、格式导出沉淀成共享 Skill。",
limits: "不追求替代所有 IDE/终端 Agent,优先服务团队业务流程沉淀。",
tech: ["Skill Registry", "Review Gate", "Session Workspace", "Skill Memory", "Admin"],
},
};
const assetPrefix = "/doc-assets/technical";
const architectureImage = `${assetPrefix}/00-zk-data-agent-architecture.png`;
const comparisonRows = [
{ label: "Web 多用户入口", chatgpt: "partial", cursor: "no", claude: "no", zk: "yes" },
{ label: "团队共享 Skill", chatgpt: "partial", cursor: "partial", claude: "partial", zk: "yes" },
{ label: "业务数据源接入", chatgpt: "partial", cursor: "no", claude: "partial", zk: "yes" },
{ label: "会话产物管理", chatgpt: "partial", cursor: "no", claude: "partial", zk: "yes" },
{ label: "人类 Review Gate", chatgpt: "partial", cursor: "partial", claude: "partial", zk: "yes" },
{ label: "Skill 级记忆", chatgpt: "no", cursor: "no", claude: "partial", zk: "yes" },
{ label: "管理后台/用量/队列", chatgpt: "no", cursor: "no", claude: "no", zk: "yes" },
{ label: "数据开发链路沉淀", chatgpt: "partial", cursor: "no", claude: "partial", zk: "yes" },
] as const;
const objectives = [
const sections: Section[] = [
{
goal: "先回答为什么用我们",
frontend: "用产品对比矩阵 + 选中产品详情,明确展示我们补的是团队流程平台能力。",
message: "不是模型比 Claude Code 强,而是把 Web 多用户、共享 Skill、产物管理、记忆和管理后台补齐。",
id: "base",
no: "01",
title: "基座 Runtime",
summary:
"基座把本地工程 Agent 的循环执行能力,包装成多人可使用、可观测、可沉淀、可扩展的 Web 工作台。",
image: `${assetPrefix}/01-base-runtime.png`,
doc: "/doc/01-base-runtime",
points: [
"基于 LocalCodingAgent、OpenAI-compatible 模型调用、Tool Runtime、Session Workspace 和 Skill System 组合而成。",
"解决个人脚本和一次性对话难以团队复用的问题:流程、工具、知识、产物都挂到同一个会话空间。",
"在 product-data、online-mining-v2、标签大师、飞书在线文档转换等场景中验证了基座的通用承载能力。",
],
code: [
"backend/api/server.py",
"src/agent_runtime.py",
"src/agent_prompting.py",
"src/openai_compat.py",
],
decision:
"基座只承载跨业务稳定能力;变化快的业务判断、流程经验和格式转换下沉到 Skill。",
},
{
goal: "让不同同事知道怎么融入工作",
frontend: "用角色切换:普通使用者、Skill 作者、平台维护者分别看到自己的路径。",
message: "看完不是只知道概念,而是知道自己明天怎么开始用。",
id: "loop",
no: "02",
title: "Agent Loop",
summary:
"模型不是只回答一次,而是在多轮循环里阅读上下文、请求工具、观察结果、继续判断。",
image: `${assetPrefix}/02-agent-loop.png`,
doc: "/doc/02-agent-loop",
points: [
"模型是否返回 tool_calls 由模型基于 messages、工具描述、Skill 和上下文自行决定。",
"工具结果写回 session,成为下一轮模型判断的输入。",
"review gate 不是特殊流程,而是一次运行自然暂停,等待用户下一轮确认后 resume。",
],
code: [
"LocalCodingAgent.run",
"LocalCodingAgent.resume",
"_run_prompt",
"_query_model",
],
decision:
"复杂任务需要执行后再判断;把工具观察结果纳入下一轮上下文,比一次性输出更可靠。",
},
{
goal: "证明不是闭门造车",
frontend: "用记忆方案谱系,把 ChatGPT、Letta、Mem0、RAG 和我们的取舍放在同一屏。",
message: "我们参考了主流方案,但按团队 Skill 场景做了轻量可控实现。",
id: "tools",
no: "03",
title: "工具体系",
summary:
"工具由 Tool spec 和 handler 两部分组成:spec 给模型选择,handler 在后端执行。",
image: `${assetPrefix}/03-tools.png`,
doc: "/doc/03-tools",
points: [
"Tool registry 汇总文件、执行、搜索、Skill、data_agent、MCP 等能力。",
"模型只看到 name、description 和参数 schema,看不到 handler 实现。",
"结构化分析和复杂文件写入优先走 python_execwrite_file 只适合小文件。",
],
code: [
"src/agent_tools.py",
"src/agent_tool_specs/files.py",
"src/agent_tool_specs/execution.py",
],
decision:
"模型负责选择能力,代码负责稳定执行能力;两者分开后才能做权限、取消、路径和审计。",
},
{
goal: "解释 Skill 为什么能沉淀能力",
frontend: "用可点击文件树解剖 SKILL.md、knowledge、scripts、examples、README 和更新机制。",
message: "Skill 不是 prompt,而是可维护、可安装、可更新的能力包。",
id: "skills",
no: "04",
title: "Skill 体系",
summary:
"Skill 是能力包,包含流程协议、领域知识、确定性脚本、样例和维护说明。",
image: `${assetPrefix}/04-skills.png`,
doc: "/doc/04-skills",
points: [
"SKILL.md 定义触发场景、执行流程、review 边界和产物约定。",
"knowledge/ 放业务定义和边界经验,scripts/ 放稳定脚本。",
"项目级 skills/ 可以通过页面更新机制刷新到模型可见能力列表。",
],
code: [
"src/bundled_skills.py",
"skills/*/SKILL.md",
"frontend Skills 面板",
],
decision:
"团队经验只有形成可安装、可更新、可阅读、可执行的目录结构,才方便多人复用和维护。",
},
{
goal: "让技术同事能追代码",
frontend: "每条链路都给出代码路径、落盘位置和真实运行节点。",
message: "页面不是海报,是技术讲解和后续维护入口。",
id: "workspace",
no: "05",
title: "工作区、运行态和记忆",
summary:
"每个账号和会话都有独立空间,运行过程通过事件流展示,交互结束后异步整理记忆。",
image: `${assetPrefix}/05-workspace-memory-observability.png`,
doc: "/doc/05-workspace-memory-observability",
points: [
"session/input 放输入材料,session/scratchpad 放中间过程,session/output 放最终产物。",
"右侧活动区和摘要行来自运行态事件流,刷新后通过会话记录恢复。",
"用户记忆和 Skill 记忆分开保存,只在需要的上下文中注入。",
],
code: [
".port_sessions/accounts/{account}/sessions/{session}",
"RunStateStore",
"src/personal_memory.py",
],
decision: "多人使用时,隔离、恢复、取消、观测和产物管理比单次回答更重要。",
},
];
const memoryPatterns: Record<
MemoryId,
const skillSections: Section[] = [
{
name: string;
source: string;
borrowed: string;
notEnough: string;
ourChoice: string;
docs: string;
href: string;
}
> = {
chatgpt: {
name: "ChatGPT Saved Memory / Reference Chat History",
source: "OpenAI 的记忆分成显式 Saved Memories 和从历史中提炼的 Reference Chat History。",
borrowed: "借鉴:用户可管理、可删除、可开关;显式记忆和历史参考分层。",
notEnough: "不足:它面向通用个性化,不知道某个团队 Skill 的操作经验应该只在该 Skill 生效。",
ourChoice: "我们的取舍:用户记忆全局注入,Skill 使用记忆只在启用对应 Skill 时注入。",
docs: "OpenAI Memory FAQ",
href: "https://help.openai.com/en/articles/8590148-memory-faq",
},
letta: {
name: "Letta / MemGPT Core + Archival Memory",
source: "Letta 把 stateful agent 的消息、工具调用、记忆持久化,并区分 in-context memory blocks 和 archival memory。",
borrowed: "借鉴:核心记忆常驻上下文,外部记忆通过工具或检索进入;记忆是 Agent state 的一部分。",
notEnough: "不足:完整自管理记忆体系复杂,早期直接照搬会增加主链路风险和运维成本。",
ourChoice: "我们的取舍:不让主 Agent 实时自改记忆,而是把交互事件异步交给后台 worker 整理。",
docs: "Letta Stateful Agents / Archival Memory",
href: "https://docs.letta.com/guides/core-concepts/stateful-agents",
},
mem0: {
name: "Mem0 / Managed Memory Layer",
source: "Mem0 把用户、Agent、Session 记忆做成可托管的长期记忆层,并支持 add/search/update/delete。",
borrowed: "借鉴:每次交互后抽取记忆;调用前按用户请求检索并注入。",
notEnough: "不足:我们当前更需要可审阅、可编辑、可随项目部署的轻量实现,而不是先接托管记忆服务。",
ourChoice: "我们的取舍:SQLite 做事件队列,Markdown 做人可编辑记忆正文,后续可替换成 Mem0/向量服务。",
docs: "Mem0 Platform Overview",
href: "https://docs.mem0.ai/platform/overview",
},
rag: {
name: "RAG / Vector Memory",
source: "把资料切片、embedding、向量检索,再把相关片段注入上下文。",
borrowed: "借鉴:大规模知识不应全部塞进 prompt,要按任务召回。",
notEnough: "不足:Skill 使用经验通常是流程偏好和纠错结论,不是纯语义相似文本召回。",
ourChoice: "我们的取舍:业务知识先放 skill/knowledge;记忆先用结构化 Markdown,后续知识膨胀再接召回索引。",
docs: "Letta Archival Memory",
href: "https://docs.letta.com/guides/ade/archival-memory",
},
ours: {
name: "ZK Data Agent Memory",
source: "结合团队 Skill 场景:用户偏好和 Skill 使用经验是两类不同记忆。",
borrowed: "借鉴:显式记忆、历史抽取、长期持久化、用户可编辑、异步后台处理。",
notEnough: "主动放弃:不做实时自修改、不强依赖向量库、不把所有历史都注入。",
ourChoice: "最终形态:user.md + skills/*.md + memory.db queue + worker + enabled skill 注入。",
docs: "本项目实现",
href: "#memory-implementation",
},
};
const memoryPipeline = [
{ title: "检测信号", text: "显式记住 / 纠错 / Skill 流程 / 工具失败", code: "detect_memory_signals" },
{ title: "事件入队", text: "主链路只写 pending event,不同步整理", code: "enqueue_interaction" },
{ title: "批量整理", text: "后台 worker 每轮最多取 8 条,按 priority 处理", code: "_process_account_events" },
{ title: "LLM 合并", text: "合并旧记忆,不追加流水账;失败走规则 fallback", code: "_generate_memory_updates" },
{ title: "Markdown 落盘", text: "用户可看、可改、可删;revision 可追踪", code: "user.md / skills/*.md" },
{ title: "按需注入", text: "只注入用户记忆和当前启用 Skill 的记忆", code: "render_injection" },
];
const skills: Record<
SkillPartId,
{
name: string;
path: string;
purpose: string;
agentSees: string;
maintainerDoes: string;
}
> = {
skillmd: {
name: "SKILL.md",
path: "skills/<name>/SKILL.md",
purpose: "定义 Agent 如何识别任务、何时澄清、何时 review、何时调用脚本。",
agentSees: "流程协议、工具优先级、产物约定、人类确认边界。",
maintainerDoes: "把经验写成可执行步骤,而不是把所有业务细节塞成一大段 prompt。",
},
knowledge: {
name: "knowledge/",
path: "skills/<name>/knowledge/",
purpose: "放业务定义、标签边界、字段说明、判断准则。",
agentSees: "按需读取具体知识文件,用于分析和生成。",
maintainerDoes: "把经常变的业务规则放这里,降低改 SKILL.md 的频率。",
},
scripts: {
name: "scripts/",
path: "skills/<name>/scripts/",
purpose: "放确定性能力:校验、格式转换、导出、数据清洗。",
agentSees: "通过 python_exec 执行脚本,不再现场手写复杂转换逻辑。",
maintainerDoes: "把易错代码沉淀成可测试脚本,输入输出写清楚。",
},
examples: {
name: "examples/",
path: "skills/<name>/examples/",
purpose: "放典型输入输出,帮助 Agent 对齐格式和边界。",
agentSees: "少量高质量样例,比长篇抽象说明更稳。",
maintainerDoes: "新增失败 case 的最小复现,作为回归材料。",
},
readme: {
name: "README.md",
path: "skills/<name>/README.md",
purpose: "给人看的维护说明:这是什么、怎么测、怎么扩展。",
agentSees: "通常不强制注入,但可被读取用于理解项目背景。",
maintainerDoes: "保证同事能接手,不必找原作者口头说明。",
},
update: {
name: "更新 Skill",
path: "Web 会话页 > Skills > 更新 Skill",
purpose: "同事提交 Git 后,页面内拉取最新 Skill 文件,刷新可用能力。",
agentSees: "更新后的 Skill 列表会进入模型可见能力列表。",
maintainerDoes: "提交 Skill 改动后提醒使用者点更新;平台发布时再统一收敛。",
},
};
const taskFlows = [
{
name: "产品定义 → 数据集",
skill: "product-data",
input: "产品文档 / 手写标签边界 / 示例 query",
review: "目标标签、complex、覆盖范围、数量计划",
outputs: ["records.jsonl", "review.csv", "train.jsonl", "eval.csv"],
id: "product",
no: "06",
title: "product-data",
summary:
"从产品定义、标签边界、样例 query 或 badcase 出发,生成标准 canonical records 并导出多种数据格式。",
image: `${assetPrefix}/06-product-data.png`,
doc: "/doc/06-product-data",
points: [
"先抽取 generation goal,再让用户 review 标签、complex、覆盖范围和排除边界。",
"确认后生成 generation plan,再确认数量、轮次、路径和导出目标。",
"dataset draft text 通过 scripts 转成 canonical records,再导出 records.jsonl、流转 CSV、训练 JSONL、评测 CSV。",
],
code: [
"skills/product-data/SKILL.md",
"skills/product-data/scripts/*",
"canonical_record_v1",
],
decision:
"数据生成质量不只取决于模型文本,还取决于格式规范、校验脚本和 review 门禁。",
},
{
name: "线上日志 → 专项样本",
skill: "online-mining-v2 + elk-fetch",
input: "线上 badcase / query 特征 / domain 条件 / ELK 表",
review: "筛选策略、候选质量、是否直接转样本还是继续生成",
outputs: ["candidates.csv", "sample_review.md", "records.jsonl"],
id: "online",
no: "07",
title: "online-mining-v2",
summary:
"基于 ELK 线上日志挖掘候选样本,支持策略 review、抽样分析,以及直接转换为标准数据。",
image: `${assetPrefix}/07-online-mining-v2.png`,
doc: "/doc/07-online-mining-v2",
points: [
"从需求、badcase 或示例 query 分析查询策略。",
"通过 ELK 表获取 req_id、session、模型 prompt、模型输出、domain 等字段。",
"候选结果先抽样 review,再决定直接转样本还是进入 product-data 补数链路。",
],
code: [
"skills/online-mining-v2/SKILL.md",
"skills/elk-fetch/",
"build_online_records.py",
],
decision: "线上挖掘不是一次查询,策略需要多轮调整,候选质量需要人工确认。",
},
{
name: "标签定义 → 判断辅助",
skill: "标签大师",
input: "query / 多轮上下文 / 标签定义知识库",
review: "复杂度、多指令、自动任务、业务标签、输出形态",
outputs: ["label decision", "boundary evidence", "target function"],
id: "label",
no: "08",
title: "标签大师",
summary:
"把标签定义、复杂度、多指令、自动任务和边界经验组织为可检索、可解释的知识体系。",
image: `${assetPrefix}/08-label-master.png`,
doc: "/doc/08-label-master",
points: [
"复杂度、标签、多指令、自动任务是不同判断维度。",
"标签知识通过目录、索引、边界卡片和 manifest 组织。",
"输出需要同时满足标签合法性、输出形态和 complex 独立字段要求。",
],
code: [
"skills/label-master/knowledge",
"build_label_manifest.py",
"validate_label_output.py",
],
decision:
"标签判断需要逐步分析和引用知识,而不是把所有定义塞进一次模型输入。",
},
{
id: "external",
no: "09",
title: "外部系统 Skill",
summary:
"ELK、SQL、模型迭代、飞书文档等外部能力通过 Skill 包方式接入平台。",
image: `${assetPrefix}/09-external-skills.png`,
doc: "/doc/09-external-skills",
points: [
"SKILL.md 描述外部系统能力边界和使用流程。",
"scripts/ 负责调用外部 API、鉴权、查询和格式转换。",
"最终结果统一回到当前会话 output,而不是散落在项目根目录。",
],
code: [
"skills/elk-fetch",
"skills/data-factory-sql",
"skills/model-iteration",
"Feishu MCP 转在线文档",
],
decision: "外部系统能力变化快,适合跟随 Skill 独立迭代,避免侵入基座。",
},
];
const runtimeTrace = [
{ stage: "前端发送", text: "携带 account/session/model/enabled skills", path: "app/api/chat/route.ts" },
{ stage: "上下文拼接", text: "Skill 列表 + 用户记忆 + Skill 记忆 + workspace", path: "backend/api/server.py" },
{ stage: "Agent Loop", text: "模型决定回复、调用工具、执行脚本或等待 review", path: "src/agent_runtime.py" },
{ stage: "工具执行", text: "python_exec / file / search / MCP / Skill scripts", path: "src/agent_tools.py" },
{ stage: "事件流", text: "tool_start、tool_end、content_delta、tool_delta", path: "RunStateStore" },
{ stage: "产物落盘", text: "scratchpad 存中间过程,output 存最终文件", path: ".port_sessions/.../sessions" },
{ stage: "后台记忆", text: "交互完成后异步整理长期经验", path: "src/personal_memory.py" },
];
const sourceLinks = [
{ label: "OpenAI Memory FAQ", href: "https://help.openai.com/en/articles/8590148-memory-faq" },
{ label: "Letta Stateful Agents", href: "https://docs.letta.com/guides/core-concepts/stateful-agents" },
{ label: "Letta Archival Memory", href: "https://docs.letta.com/guides/ade/archival-memory" },
{ label: "Mem0 Overview", href: "https://docs.mem0.ai/platform/overview" },
{ label: "Semantic Kernel + Mem0", href: "https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-memory" },
];
function StatusIcon({ value }: { value: "yes" | "partial" | "no" }) {
if (value === "yes") return <Check className="doc-status yes" />;
if (value === "partial") return <CircleDashed className="doc-status partial" />;
return <X className="doc-status no" />;
}
function ProductComparison() {
const [selected, setSelected] = useState<ProductId>("zk");
const detail = products[selected];
const productIds: ProductId[] = ["chatgpt", "cursor", "claude", "zk"];
function SectionBlock({ section }: { section: Section }) {
return (
<section className="doc-section doc-compare-lab" id="why-us">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2> Claude Code / ChatGPT / Cursor </h2>
<p>
Skill
</p>
<section className="project-doc-section" id={section.id}>
<div className="project-doc-section-head">
<span>{section.no}</span>
<div>
<h2>{section.title}</h2>
<p>{section.summary}</p>
</div>
</div>
<div className="doc-compare-layout">
<div className="doc-product-switcher">
{productIds.map((id) => (
<button
className={selected === id ? "is-selected" : ""}
key={id}
onClick={() => setSelected(id)}
type="button"
>
<span>{products[id].name}</span>
<small>{products[id].position}</small>
</button>
{section.image ? (
<a
className="project-doc-figure"
href={section.image}
rel="noopener"
target="_blank"
>
<Image
alt={section.title}
height={900}
priority={section.id === "base"}
src={section.image}
width={1600}
/>
</a>
) : null}
<div className="project-doc-grid">
<div>
<h3></h3>
{section.points.map((point) => (
<p className="project-doc-point" key={point}>
{point}
</p>
))}
</div>
<div className="doc-product-card">
<p className="doc-kicker"></p>
<h3>{detail.name}</h3>
<div className="doc-product-facts">
<div>
<span></span>
<p>{detail.bestFor}</p>
</div>
<div>
<span></span>
<p>{detail.limits}</p>
</div>
</div>
<div className="doc-chip-list">
{detail.tech.map((item) => (
<span className="doc-chip" key={item}>{item}</span>
))}
</div>
</div>
</div>
<div className="doc-matrix">
<div className="doc-matrix-head">
<span></span>
{productIds.map((id) => <strong key={id}>{products[id].name}</strong>)}
</div>
{comparisonRows.map((row) => (
<div className="doc-matrix-row" key={row.label}>
<span>{row.label}</span>
{productIds.map((id) => (
<div className={selected === id ? "is-highlighted" : ""} key={id}>
<StatusIcon value={row[id]} />
</div>
))}
</div>
))}
</div>
</section>
);
}
function RoleSwitcher() {
const [selected, setSelected] = useState<RoleId>("user");
const role = roles[selected];
return (
<section className="doc-section doc-role-lab" id="roles">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2></h2>
</div>
<div className="doc-role-grid">
<div className="doc-role-tabs">
{(Object.keys(roles) as RoleId[]).map((id) => (
<button
className={selected === id ? "is-selected" : ""}
key={id}
onClick={() => setSelected(id)}
type="button"
>
{roles[id].name}
</button>
<div>
<h3></h3>
{section.code.map((item) => (
<code key={item}>{item}</code>
))}
{section.doc ? (
<a className="project-doc-link" href={section.doc} rel="noopener">
</a>
) : null}
</div>
<div className="doc-role-panel">
<h3>{role.title}</h3>
<div className="doc-role-question">{role.question}</div>
<p>{role.answer}</p>
<div className="doc-flow-chips">
{role.workflow.map((step) => (
<span key={step}>{step}</span>
))}
</div>
<div>
<h3></h3>
<p>{section.decision}</p>
</div>
<div className="doc-proof-panel">
<p className="doc-kicker"></p>
{role.proof.map((item) => (
<div className="doc-proof-item" key={item}>
<CheckCircle2 />
<span>{item}</span>
</div>
))}
</div>
</div>
</section>
);
}
function ObjectiveBoard() {
return (
<section className="doc-section doc-objectives" id="design-goals">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2></h2>
</div>
<div className="doc-objective-grid">
{objectives.map((item, index) => (
<article className="doc-objective" key={item.goal}>
<span>{String(index + 1).padStart(2, "0")}</span>
<h3>{item.goal}</h3>
<p>{item.frontend}</p>
<strong>{item.message}</strong>
</article>
))}
</div>
</section>
);
}
function MemoryAtlas() {
const [selected, setSelected] = useState<MemoryId>("ours");
const item = memoryPatterns[selected];
const order: MemoryId[] = ["chatgpt", "letta", "mem0", "rag", "ours"];
return (
<section className="doc-section doc-memory-lab" id="memory">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2></h2>
<p>
Agent Skill
</p>
</div>
<div className="doc-memory-map">
<div className="doc-memory-rail">
{order.map((id, index) => (
<button
className={selected === id ? "is-selected" : ""}
key={id}
onClick={() => setSelected(id)}
type="button"
>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{memoryPatterns[id].name}</strong>
</button>
))}
</div>
<div className="doc-memory-detail-v2">
<div className="doc-memory-source">
<a href={item.href}>{item.docs}</a>
</div>
<h3>{item.name}</h3>
<div className="doc-memory-grid-v2">
<div><span></span><p>{item.source}</p></div>
<div><span></span><p>{item.borrowed}</p></div>
<div><span></span><p>{item.notEnough}</p></div>
<div><span></span><p>{item.ourChoice}</p></div>
</div>
</div>
</div>
<div className="doc-memory-implementation" id="memory-implementation">
<div className="doc-section-heading compact">
<p className="doc-kicker"></p>
<h2> + Skill + </h2>
</div>
<div className="doc-memory-pipeline-v2">
{memoryPipeline.map((step, index) => (
<div className="doc-memory-step" key={step.title}>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{step.title}</strong>
<p>{step.text}</p>
<code>{step.code}</code>
</div>
))}
</div>
</div>
</section>
);
}
function SkillWorkbench() {
const [selected, setSelected] = useState<SkillPartId>("skillmd");
const part = skills[selected];
const order: SkillPartId[] = ["skillmd", "knowledge", "scripts", "examples", "readme", "update"];
return (
<section className="doc-section doc-skill-workbench" id="skill-dev">
<div className="doc-section-heading">
<p className="doc-kicker">Skill </p>
<h2></h2>
</div>
<div className="doc-skill-workbench-grid">
<div className="doc-file-tree-v2">
{order.map((id) => (
<button className={selected === id ? "is-selected" : ""} key={id} onClick={() => setSelected(id)} type="button">
<FolderTree />
<span>{skills[id].name}</span>
</button>
))}
</div>
<div className="doc-skill-part">
<code>{part.path}</code>
<h3>{part.name}</h3>
<div className="doc-skill-part-grid">
<div><span></span><p>{part.purpose}</p></div>
<div><span>Agent </span><p>{part.agentSees}</p></div>
<div><span></span><p>{part.maintainerDoes}</p></div>
</div>
</div>
<div className="doc-skill-steps">
<h3> Skill </h3>
{["创建目录", "补 SKILL.md", "沉淀脚本/知识", "本地测试", "提交 Git", "页面点更新 Skill"].map((step, index) => (
<div key={step}><span>{index + 1}</span>{step}</div>
))}
</div>
</div>
</section>
);
}
function TaskFlowMap() {
return (
<section className="doc-section doc-task-map" id="tasks">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2></h2>
</div>
<div className="doc-task-grid">
{taskFlows.map((flow) => (
<article className="doc-task-card" key={flow.name}>
<div className="doc-task-title"><Route /><h3>{flow.name}</h3></div>
<div className="doc-task-meta"><span>Skill</span><strong>{flow.skill}</strong></div>
<div className="doc-task-meta"><span></span><p>{flow.input}</p></div>
<div className="doc-task-meta"><span>Review </span><p>{flow.review}</p></div>
<div className="doc-output-list">
{flow.outputs.map((output) => <span key={output}>{output}</span>)}
</div>
</article>
))}
</div>
</section>
);
}
function RuntimeTrace() {
return (
<section className="doc-section doc-runtime-trace" id="runtime">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2></h2>
</div>
<div className="doc-runtime-rail">
{runtimeTrace.map((step, index) => (
<div className="doc-runtime-node" key={step.stage}>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{step.stage}</strong>
<p>{step.text}</p>
<code>{step.path}</code>
</div>
))}
</div>
</section>
);
@@ -591,62 +268,119 @@ function RuntimeTrace() {
export default function DocPage() {
return (
<main className="doc-page doc-page-v2">
<header className="doc-hero-v2">
<nav className="doc-nav">
<a className="doc-brand" href="/">
<span className="doc-brand-mark">ZK</span>
<span>Data Agent</span>
</a>
<div className="doc-nav-links">
<a href="#why-us"></a>
<a href="#roles"></a>
<a href="#memory"></a>
<a href="#skill-dev"> Skill</a>
<a href="#runtime"></a>
<main className="project-doc-page">
<header className="project-doc-hero">
<nav className="project-doc-nav">
<a href="/">ZK Data Agent</a>
<div>
<a href="#base"></a>
<a href="#loop">Agent Loop</a>
<a href="#skills">Skill</a>
<a href="#product"></a>
<a href="#code-index"></a>
</div>
</nav>
<section className="doc-hero-v2-grid">
<div className="doc-hero-v2-copy">
<p className="doc-kicker"></p>
<h1> Skill Agent </h1>
<p>
Skill线Skill
</p>
<div className="doc-hero-actions">
<a className="doc-primary-link" href="#why-us"> <ArrowRight /></a>
<a className="doc-secondary-link" href="#skill-dev"> Skill</a>
</div>
</div>
<div className="doc-command-wall">
<div className="doc-command-head"><Sparkles /> </div>
{["选择 product-data Skill", "对齐标签和 complex", "生成可 review 计划", "调用 scripts 导出 records/train/eval", "文件进入当前会话 output"].map((item, index) => (
<div className="doc-command-row" key={item}>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{item}</strong>
</div>
))}
</div>
</section>
<div className="project-doc-hero-inner">
<p className="project-doc-kicker"></p>
<h1>ZK Data Agent</h1>
<p>
AgentSkill
线
</p>
</div>
</header>
<ObjectiveBoard />
<ProductComparison />
<RoleSwitcher />
<TaskFlowMap />
<MemoryAtlas />
<SkillWorkbench />
<RuntimeTrace />
<ToolPositionMap />
<section className="doc-section doc-source-links">
<div className="doc-section-heading">
<p className="doc-kicker"></p>
<h2> Agent </h2>
<section className="project-doc-section" id="architecture">
<div className="project-doc-section-head">
<span>00</span>
<div>
<h2>ZK-Data-Agent </h2>
<p>
Code Agent Loop
</p>
</div>
</div>
<div className="doc-source-grid">
{sourceLinks.map((link) => (
<a href={link.href} key={link.href}><Network />{link.label}</a>
))}
<a
className="project-doc-figure"
href={architectureImage}
rel="noopener"
target="_blank"
>
<Image
alt="ZK-Data-Agent 架构"
height={900}
priority
src={architectureImage}
width={1600}
/>
</a>
<div className="project-doc-grid">
<div>
<h3></h3>
<p>
Code Agent
</p>
</div>
<div>
<h3></h3>
<p>
ZK-Data-Agent Skill Skill Skill portable scripts
</p>
</div>
<div>
<h3></h3>
<p>
Skill Git Skill
</p>
</div>
</div>
</section>
{sections.map((section) => (
<SectionBlock key={section.id} section={section} />
))}
{skillSections.map((section) => (
<SectionBlock key={section.id} section={section} />
))}
<section className="project-doc-section" id="code-index">
<div className="project-doc-section-head">
<span>I</span>
<div>
<h2></h2>
<p>
Skill
</p>
</div>
</div>
<div className="project-doc-index">
<div>
<h3>Runtime</h3>
<code>backend/api/server.py</code>
<code>src/agent_runtime.py</code>
<code>src/openai_compat.py</code>
</div>
<div>
<h3>Prompt / Tool</h3>
<code>src/agent_prompting.py</code>
<code>src/agent_tools.py</code>
<code>src/agent_tool_specs/</code>
</div>
<div>
<h3>Skill</h3>
<code>src/bundled_skills.py</code>
<code>skills/product-data/</code>
<code>skills/label-master/</code>
</div>
<div>
<h3>State</h3>
<code>src/personal_memory.py</code>
<code>.port_sessions/accounts/</code>
<code>frontend/app/app/doc/page.tsx</code>
</div>
</div>
</section>
</main>
+566
View File
@@ -0,0 +1,566 @@
"use client";
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from "react";
type ToolPosition = {
id: string;
name: string;
x: number;
y: number;
z: number;
type: string;
color: string;
summary: string;
fit: string;
limit: string;
};
type Point3D = {
x: number;
y: number;
z: number;
};
type ProjectionMode = "xy" | "xz" | "yz" | "free";
const tools: ToolPosition[] = [
{
id: "manual",
name: "纯人工",
x: 24,
y: 82,
z: 22,
type: "人主导",
color: "#64748b",
summary: "最懂业务,但查、写、跑、改、整理都靠人推进。",
fit: "复杂边界判断、探索性分析、最终质量把关。",
limit: "执行成本高,流程复用依赖个人习惯和文档质量。",
},
{
id: "web-ai",
name: "网页版 AI",
x: 54,
y: 24,
z: 18,
type: "通用 AI",
color: "#38bdf8",
summary: "适合问答、总结、生成草稿,启动快。",
fit: "通用解释、文本生成、局部判断辅助。",
limit: "不天然接入内部日志、标签规则、标准产物和团队流程。",
},
{
id: "copilot",
name: "IDE Copilot",
x: 62,
y: 34,
z: 28,
type: "编码辅助",
color: "#60a5fa",
summary: "在编辑器里提升局部编码效率。",
fit: "补全、局部函数、单文件修改。",
limit: "更偏代码片段,不负责跨系统数据流和业务产物链。",
},
{
id: "cursor",
name: "Cursor",
x: 76,
y: 46,
z: 38,
type: "IDE Agent",
color: "#818cf8",
summary: "围绕代码仓库做理解、修改和多文件协作。",
fit: "工程代码迭代、仓库内上下文开发。",
limit: "团队业务流程、线上数据、标准导出和共享 Skill 需要另行组织。",
},
{
id: "code-agent",
name: "Claude Code / Codex",
x: 84,
y: 58,
z: 45,
type: "工程 Agent",
color: "#a78bfa",
summary: "能读代码、调工具、执行多步工程任务。",
fit: "本地工程闭环、复杂代码修改、命令执行。",
limit: "强在个人工作区;团队级业务流程沉淀和 Web 多用户协作不是默认形态。",
},
{
id: "notebook",
name: "个人自动化脚本",
x: 66,
y: 74,
z: 48,
type: "个人自动化",
color: "#f59e0b",
summary: "贴近业务数据,能把部分流程脚本化。",
fit: "数据清洗、临时统计、可重复实验。",
limit: "入口、权限、review、产物管理和团队复用通常需要人额外维护。",
},
{
id: "prompt-doc",
name: "经验文档",
x: 38,
y: 62,
z: 60,
type: "经验沉淀",
color: "#10b981",
summary: "能记录规则和经验,便于传播。",
fit: "标签边界、流程说明、操作规范。",
limit: "本身不执行任务,仍需要人把文档转成操作。",
},
{
id: "zk",
name: "ZK Data Agent",
x: 80,
y: 88,
z: 86,
type: "业务 Agent 工作台",
color: "#2563eb",
summary:
"把 Agent Loop、工具执行、业务知识、review 和产物管理组织成团队流程。",
fit: "数据生成、线上挖掘、标签判断、外部系统 Skill 接入。",
limit: "不替代人的业务判断;重点是把判断节点放进可复用流程。",
},
];
const presets: Array<{
mode: ProjectionMode;
name: string;
rotateX: number;
rotateY: number;
}> = [
{ mode: "xy", name: "研发 × 业务", rotateX: 0, rotateY: 0 },
{ mode: "xz", name: "研发 × 复用", rotateX: -90, rotateY: 0 },
{ mode: "yz", name: "业务 × 复用", rotateX: 0, rotateY: -90 },
];
const dimensionCards = [
{
title: "研发执行效率",
text: "读代码、调工具、跑脚本、生成产物。",
},
{
title: "业务嵌入深度",
text: "接住日志、标签体系、数据格式和团队规则。",
},
{
title: "流程可复用度",
text: "把一次解决方案沉淀成团队能继续调用的能力。",
},
];
const frictionCases = [
{ text: "标签边界依赖经验", weight: 5 },
{ text: "格式手工补齐", weight: 4 },
{ text: "规则散在文档", weight: 4 },
{ text: "线上数据难取", weight: 4 },
{ text: "经验靠口口相传", weight: 3 },
{ text: "脚本只在个人机器", weight: 3 },
{ text: "Review 断在中间", weight: 3 },
{ text: "样例难复用", weight: 2 },
{ text: "产物路径分散", weight: 2 },
{ text: "路径和权限反复确认", weight: 2 },
];
const cubeSize = 360;
const cubeCenter = cubeSize / 2;
const stageCenter = 280;
const perspective = 950;
const axisOrigin = { x: -cubeCenter, y: cubeCenter, z: -cubeCenter };
const axisDefinitions = [
{
id: "x",
name: "研发执行效率",
color: "#2563eb",
start: axisOrigin,
end: { x: cubeCenter, y: cubeCenter, z: -cubeCenter },
},
{
id: "y",
name: "业务嵌入深度",
color: "#0f766e",
start: axisOrigin,
end: { x: -cubeCenter, y: -cubeCenter, z: -cubeCenter },
},
{
id: "z",
name: "流程可复用度",
color: "#9333ea",
start: axisOrigin,
end: { x: -cubeCenter, y: cubeCenter, z: cubeCenter },
},
];
const visibleAxesByMode: Record<ProjectionMode, string[]> = {
xy: ["x", "y"],
xz: ["x", "z"],
yz: ["z", "y"],
free: ["x", "y", "z"],
};
function score(tool: ToolPosition) {
return Math.round((tool.x + tool.y + tool.z) / 3);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getPointCoordinates(tool: ToolPosition) {
return {
x: (tool.x / 100) * cubeSize - cubeCenter,
y: cubeCenter - (tool.y / 100) * cubeSize,
z: (tool.z / 100) * cubeSize - cubeCenter,
};
}
function projectPoint(
point: Point3D,
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
if (mode === "xy") {
return {
x: stageCenter + point.x,
y: stageCenter + point.y,
z: point.z,
scale: 1,
};
}
if (mode === "xz") {
return {
x: stageCenter + point.x,
y: stageCenter - point.z,
z: -point.y,
scale: 1,
};
}
if (mode === "yz") {
return {
x: stageCenter + point.z,
y: stageCenter + point.y,
z: point.x,
scale: 1,
};
}
const angleX = (rotateX * Math.PI) / 180;
const angleY = (rotateY * Math.PI) / 180;
const cosX = Math.cos(angleX);
const sinX = Math.sin(angleX);
const cosY = Math.cos(angleY);
const sinY = Math.sin(angleY);
const afterY = {
x: point.x * cosY + point.z * sinY,
y: point.y,
z: -point.x * sinY + point.z * cosY,
};
const rotated = {
x: afterY.x,
y: afterY.y * cosX - afterY.z * sinX,
z: afterY.y * sinX + afterY.z * cosX,
};
const scale = perspective / (perspective - rotated.z);
return {
x: stageCenter + rotated.x * scale,
y: stageCenter + rotated.y * scale,
z: rotated.z,
scale,
};
}
function toolAnchorStyle(
tool: ToolPosition,
projected: ReturnType<typeof projectPoint>,
) {
const scale = Math.min(1.08, Math.max(0.88, projected.scale));
return {
"--tool-x": `${projected.x}px`,
"--tool-y": `${projected.y}px`,
"--label-scale": `${scale}`,
"--point-color": tool.color,
zIndex: Math.round(500 + projected.z),
} as CSSProperties;
}
function getProjectedAxes(
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
const visibleAxes = new Set(visibleAxesByMode[mode]);
return axisDefinitions
.filter((axis) => visibleAxes.has(axis.id))
.map((axis) => {
const start = projectPoint(axis.start, rotateX, rotateY, mode);
const end = projectPoint(axis.end, rotateX, rotateY, mode);
const vectorX = end.x - start.x;
const vectorY = end.y - start.y;
const length = Math.max(1, Math.hypot(vectorX, vectorY));
const labelX = clamp(end.x + (vectorX / length) * 34, 44, 516);
const labelY = clamp(end.y + (vectorY / length) * 34, 32, 528);
return {
...axis,
start,
end,
labelStyle: {
"--axis-label-x": `${labelX}px`,
"--axis-label-y": `${labelY}px`,
"--axis-color": axis.color,
} as CSSProperties,
};
});
}
export function ToolPositionMap() {
const [projectionMode, setProjectionMode] = useState<ProjectionMode>("xy");
const [rotateX, setRotateX] = useState(0);
const [rotateY, setRotateY] = useState(0);
const [selectedId, setSelectedId] = useState("zk");
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<{
x: number;
y: number;
rotateX: number;
rotateY: number;
} | null>(null);
const selected = useMemo(
() => tools.find((tool) => tool.id === selectedId) ?? tools[0],
[selectedId],
);
const projectedTools = useMemo(
() =>
tools
.map((tool) => ({
tool,
projected: projectPoint(
getPointCoordinates(tool),
rotateX,
rotateY,
projectionMode,
),
}))
.sort((a, b) => a.projected.z - b.projected.z),
[rotateX, rotateY, projectionMode],
);
const projectedAxes = useMemo(
() => getProjectedAxes(rotateX, rotateY, projectionMode),
[rotateX, rotateY, projectionMode],
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
setProjectionMode("free");
dragRef.current = {
x: event.clientX,
y: event.clientY,
rotateX,
rotateY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const deltaX = event.clientX - dragRef.current.x;
const deltaY = event.clientY - dragRef.current.y;
setRotateX(dragRef.current.rotateX - deltaY * 0.45);
setRotateY(dragRef.current.rotateY + deltaX * 0.45);
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
dragRef.current = null;
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<section className="project-doc-section project-doc-position" id="position">
<div className="position-framing">
<div className="position-framing-copy">
<span></span>
<h3 className="position-formula">
<span></span>
<b></b>
<span></span>
<b>×</b>
<span></span>
<b>×</b>
<span></span>
</h3>
<p>
AI
</p>
</div>
<div className="position-dimension-grid">
{dimensionCards.map((card) => (
<div key={card.title}>
<strong>{card.title}</strong>
<p>{card.text}</p>
</div>
))}
</div>
<div className="position-friction-cloud">
<strong></strong>
{frictionCases.map((item) => (
<span className={`is-weight-${item.weight}`} key={item.text}>
{item.text}
</span>
))}
<em></em>
</div>
</div>
<div className="position-map-layout">
<div className="position-stage-card">
<div className="position-controls">
<div>
<span>
{projectionMode === "free"
? "拖动坐标系自由旋转"
: "二维投影视图"}
</span>
<strong>
{projectionMode === "free"
? `X ${Math.round(rotateX)} / Y ${Math.round(rotateY)}`
: presets.find((preset) => preset.mode === projectionMode)
?.name}
</strong>
</div>
<div className="position-preset-row">
{presets.map((preset) => (
<button
className={
projectionMode === preset.mode ? "is-selected" : undefined
}
key={preset.name}
onClick={() => {
setProjectionMode(preset.mode);
setRotateX(preset.rotateX);
setRotateY(preset.rotateY);
}}
type="button"
>
{preset.name}
</button>
))}
</div>
</div>
<div
className={
isDragging ? "position-stage is-dragging" : "position-stage"
}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
>
<div className="position-scene">
<div className="position-space-grid" />
<svg
aria-hidden="true"
className="position-axis-overlay"
viewBox="0 0 560 560"
>
{projectedAxes.map((axis) => (
<line
key={axis.id}
stroke={axis.color}
x1={axis.start.x}
x2={axis.end.x}
y1={axis.start.y}
y2={axis.end.y}
/>
))}
</svg>
<div className="position-axis-label-layer">
{projectedAxes.map((axis) => (
<span
className="position-axis-label"
key={axis.id}
style={axis.labelStyle}
>
{axis.name}
</span>
))}
</div>
<div className="position-tool-layer">
{projectedTools.map(({ tool, projected }) => (
<button
className={[
"position-tool-anchor",
tool.id === selectedId ? "is-selected" : "",
tool.id === hoveredId ? "is-hovered" : "",
]
.filter(Boolean)
.join(" ")}
key={tool.id}
onClick={() => setSelectedId(tool.id)}
onBlur={() => setHoveredId(null)}
onFocus={() => setHoveredId(tool.id)}
onMouseEnter={() => setHoveredId(tool.id)}
onMouseLeave={() => setHoveredId(null)}
onPointerDown={(event) => event.stopPropagation()}
style={toolAnchorStyle(tool, projected)}
type="button"
>
<span className="position-ball" />
<span className="position-tool-label">{tool.name}</span>
</button>
))}
</div>
</div>
</div>
</div>
<aside className="position-detail-card">
<div className="position-score">
<span>{selected.type}</span>
<strong>{score(selected)}</strong>
</div>
<h3>{selected.name}</h3>
<p>{selected.summary}</p>
<div className="position-meter-list">
<div>
<span></span>
<i style={{ width: `${selected.x}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.y}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.z}%` }} />
</div>
</div>
<dl>
<div>
<dt></dt>
<dd>{selected.fit}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.limit}</dd>
</div>
</dl>
</aside>
</div>
</section>
);
}
+1025 -1629
View File
File diff suppressed because it is too large Load Diff