feat: accumulated frontend enhancements, docs, and static assets

- Frontend view updates (ChatView, EvolutionView, SkillsView, etc.)
- Updated portal routes and chat store
- New frontend components (FilePreview, ToolCallCard, IconNav)
- Updated static build assets
- New test files (merged router, parallel tools, ReWOO fallback)
- Documentation and brainstorm files
- Codegraph and understand-anything artifacts
This commit is contained in:
chiguyong 2026-06-14 16:35:01 +08:00
parent 6e0e081f23
commit 94c4c8b887
124 changed files with 64726 additions and 937 deletions

View File

@ -0,0 +1,142 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add <package>` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Check the Leaderboard First
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
For example, top skills for web development include:
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
### Step 3: Search for Skills
If the leaderboard doesn't cover the user's need, run the find command:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 4: Verify Quality Before Recommending
**Do not recommend a skill based solely on search results.** Always verify:
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
### Step 5: Present Options to the User
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The install count and source
3. The install command they can run
4. A link to learn more at skills.sh
Example response:
```
I found a skill that might help! The "react-best-practices" skill provides
React and Next.js performance optimization guidelines from Vercel Engineering.
(185K installs)
To install it:
npx skills add vercel-labs/agent-skills@react-best-practices
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
```
### Step 6: Offer to Install
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add <owner/repo@skill> -g -y
```
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
## Common Skill Categories
When searching, consider these common categories:
| Category | Example Queries |
| --------------- | ---------------------------------------- |
| Web Development | react, nextjs, typescript, css, tailwind |
| Testing | testing, jest, playwright, e2e |
| DevOps | deploy, docker, kubernetes, ci-cd |
| Documentation | docs, readme, changelog, api-docs |
| Code Quality | review, lint, refactor, best-practices |
| Design | ui, ux, design-system, accessibility |
| Productivity | workflow, automation, git |
## Tips for Effective Searches
1. **Use specific keywords**: "react testing" is better than just "testing"
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
## When No Skills Are Found
If no relevant skills exist:
1. Acknowledge that no existing skill was found
2. Offer to help with the task directly using your general capabilities
3. Suggest the user could create their own skill with `npx skills init`
Example:
```
I searched for skills related to "xyz" but didn't find any matches.
I can still help you with this task directly! Would you like me to proceed?
If this is something you do often, you could create your own skill:
npx skills init my-xyz-skill
```

View File

@ -0,0 +1,231 @@
---
name: open-code-review
description: >
Performs AI-powered code review on Git changes using the `ocr` CLI from
alibaba/open-code-review. Use when the user asks to review code, review
a pull request, review staged/unstaged changes, review a commit, or
compare branches for code quality issues. Produces line-level review
comments and can automatically apply fixes when requested. With appropriate
review rules, can detect various types of issues including bugs, security
vulnerabilities, performance problems, and code quality concerns.
license: Apache-2.0
compatibility: >
Requires the `ocr` CLI installed (via `npm install -g
@alibaba-group/open-code-review` or GitHub release binary). Requires a
configured LLM (Anthropic or OpenAI-compatible) before first run.
metadata:
author: alibaba
homepage: https://github.com/alibaba/open-code-review
version: "1.0.0"
---
# Open Code Review
A skill for invoking [open-code-review](https://github.com/alibaba/open-code-review) (`ocr`) — an open-source AI code review CLI that reads Git diffs and generates structured, line-level review comments.
## Prerequisites check
Before starting a review, verify the environment:
```bash
# 1. Check the CLI is installed
which ocr || echo "NOT INSTALLED"
# 2. Verify LLM connectivity
ocr llm test
```
If `ocr` is not installed, install it first:
```bash
npm install -g @alibaba-group/open-code-review
```
If `ocr llm test` fails, the user must configure an LLM. Guide them with one of these options:
**Option A — Environment variables (highest priority, recommended for CI):**
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=<api-key>
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
**Option B — Persistent config:**
```bash
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token <api-key>
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
```
Stop here and ask the user to provide credentials — never invent or hardcode API keys.
## Workflow
### Step 1: Gather Business Context
Analyze the review target (commits, branch, or changes) to extract concise business context. Pass this context via `--background` to improve review quality.
### Step 2: Run Code Review
Run the OCR command with appropriate flags. **Always pass business context via `--background`** when available:
```bash
ocr review --audience agent --background "business context here" [user-args]
```
**Argument handling:**
- **Background context** (RECOMMENDED): use `--background "context"` or `-b "context"` to provide business context for better review quality
- **Default** (no user arguments): reviews staged, unstaged, and untracked changes (workspace mode)
- **Specific commit**: use `--commit` or `-c` to review a single commit against its parent
- **Branch comparison**: use `--from <ref>` and `--to <ref>` to review diff between two refs
- **Timeout**: default timeout is 10 minutes per file; adjust with `--timeout <minutes>`
- **Concurrency**: default concurrency is 8 file workers; reduce with `--concurrency <n>` if rate limits are hit
- **Preview mode**: use `--preview` or `-p` to preview which files will be reviewed without running the LLM
- **Installation**: if `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`
**Common invocation patterns:**
| User says | Command to run |
|-----------|---------------|
| "review my changes" / "review the working copy" | `ocr review --audience agent -b "context"` |
| "review this PR" / "review feature branch" | `ocr review --audience agent -b "context" --from main --to <branch>` |
| "review commit abc123" | `ocr review --audience agent -b "context" --commit abc123` |
| "what would be reviewed?" (dry-run) | `ocr review --preview` |
**Output mode:**
- Always use `--audience agent` to suppress progress UI and emit only the final summary
### Step 3: Classify and Report
For each comment from the review output, classify by priority and report all issues to the user:
- **High**: Obvious bugs, security issues, clear mistakes, or well-founded suggestions with precise fix proposals
- **Medium**: Reasonable concerns but context-dependent, style/performance suggestions, or fixes that require manual implementation
- **Low**: Likely false positives, lacking sufficient context, nitpicks, or meaningless suggestions
Report all comments grouped by priority level.
### Step 4: Fix
Before applying fixes, check whether the user requested automatic fixes:
- If the user explicitly requested "review and fix" or similar, proceed with automatic fixes
- If the user only requested "review" without fix intent, ask for permission before applying any changes
When fixing issues and suggestions:
- Focus on High and Medium priority items
- Apply fixes directly to the code when safe and well-defined
- For complex fixes requiring manual intervention, clearly describe what needs to be done
- Always verify fixes with the user before committing
## Output Format
Each comment contains:
- `path`: File path
- `content`: Review comment text
- `start_line` / `end_line`: Line range (both 0 means positioning failed)
- `suggestion_code`: Optional fix suggestion
- `existing_code`: Optional original code snippet
- `thinking`: Optional LLM reasoning process
After filtering comments by priority, present results using this template:
```markdown
## Code Review Results
**Files reviewed**: N
**Issues found**: X high priority / Y medium priority
### High Priority
- **`path/to/file.java:42`** — Brief description
> Recommendation: How to fix
### Medium Priority
- **`path/to/file.ts:88`** — Brief description
> Recommendation: How to fix (if applicable)
```
If the review found no issues after filtering, simply state: "Review complete — no issues found in N files."
**Priority classification:**
- **High**: Obvious bugs, security issues, clear mistakes, or well-founded suggestions with precise fix proposals
- **Medium**: Reasonable concerns but context-dependent, style/performance suggestions, or fixes that require manual implementation
- **Low**: Discarded silently (likely false positives, lacking context, nitpicks, or meaningless suggestions)
**Handling mispositioned comments:**
When `start_line` and `end_line` are both `0`, the comment failed to locate the exact position in the file. In such cases:
1. Read the comment content to understand the issue
2. Examine the target file mentioned in the comment
3. Identify the relevant code section based on the comment's context
4. Apply the fix or suggestion to the correct location
## Custom Review Rules
If the user wants project-specific rules, OCR resolves them in this priority order:
1. `--rule <path>` flag (highest)
2. `<repo>/.opencodereview/rule.json`
3. `~/.opencodereview/rule.json`
4. Built-in system defaults (lowest)
Rule file format:
```json
{
"rules": [
{
"path": "**/*.java",
"rule": "All new methods must validate required parameters for null"
},
{
"path": "**/*mapper*.xml",
"rule": "Check SQL for injection risks and missing closing tags"
}
]
}
```
To preview which rule applies to a file before reviewing:
```bash
ocr rules check src/main/java/com/example/Foo.java
```
## Gotchas
- **LLM must be configured first**`ocr review` will fail loudly if no LLM is reachable. Always run `ocr llm test` before the first review.
- **Working directory matters**`ocr review` operates on the Git repo at the current directory. Use `--repo /path/to/repo` to run from elsewhere.
- **Untracked files are reviewed in workspace mode** — running bare `ocr review` includes staged, unstaged, *and* untracked changes. Stage selectively if you want narrower scope.
- **Large diffs may hit token limits** — files with very large diffs may be truncated. The default `MAX_TOKENS` is 58888 per request.
- **Plan phase triggers at 50 lines** — diffs exceeding 50 changed lines run an extra risk-analysis phase before main review. This adds latency but improves quality.
- **Don't pass `--audience human`** — it streams progress UI that pollutes output. Always use `--audience agent`.
- **Comment language follows config** — set `language` config to `English` or `Chinese` (default: Chinese) to control review comment language.
## Validation
After the review completes, verify success by checking:
1. The command exited with code 0
2. Comments were generated (or "No comments generated" message appears)
3. Warnings (if any) are displayed in stderr
If errors occurred, check the stderr warnings for details about which files failed and why.
## References
- Full docs: https://github.com/alibaba/open-code-review
- NPM package: https://www.npmjs.com/package/@alibaba-group/open-code-review
- Issue tracker: https://github.com/alibaba/open-code-review/issues

16
.codegraph/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View File

@ -0,0 +1,39 @@
---
description: CodeGraph MCP usage guide — when to use which tool
alwaysApply: true
---
<!-- CODEGRAPH_START -->
## CodeGraph
This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot.
### When to prefer codegraph over native search
Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open.
| Question | Tool |
|---|---|
| "Where is X defined?" / "Find symbol named X" | `codegraph_search` |
| "What calls function Y?" | `codegraph_callers` |
| "What does Y call?" | `codegraph_callees` |
| "How does X reach/become Y? / trace the flow from X to Y" | `codegraph_trace` (one call = the whole path, incl. callback/React/JSX dynamic hops) |
| "What would break if I changed Z?" | `codegraph_impact` |
| "Show me Y's signature / source / docstring" | `codegraph_node` |
| "Give me focused context for a task/area" | `codegraph_context` |
| "See several related symbols' source at once" | `codegraph_explore` |
| "What files exist under path/" | `codegraph_files` |
| "Is the index healthy?" | `codegraph_status` |
### Rules of thumb
- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with `codegraph_trace` from→to — one call returns the whole path with dynamic hops bridged — then ONE `codegraph_explore` for the bodies; don't rebuild the path with `codegraph_search` + `codegraph_callers`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.
- **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. `codegraph_status` also lists pending files under "Pending sync".
### If `.codegraph/` doesn't exist
The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"*
<!-- CODEGRAPH_END -->

View File

@ -0,0 +1,818 @@
#!/usr/bin/env python3
"""Knowledge Graph Builder for Fischer AgentKit
Scans all Python source files under src/agentkit/ and configs/,
extracts classes, functions, imports, and builds a comprehensive
knowledge graph JSON file.
"""
import ast
import json
import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Project root
PROJECT_ROOT = Path("/Users/Chiguyong/Code/Fischer/fischer-agentkit")
OUTPUT_PATH = PROJECT_ROOT / ".understand-anything" / "knowledge-graph.json"
# Directories to scan
SCAN_DIRS = [
PROJECT_ROOT / "src" / "agentkit",
PROJECT_ROOT / "configs",
]
# Architecture layer mapping
LAYER_MAP = {
"server": "api",
"cli": "api",
"core": "service",
"orchestrator": "service",
"skills": "service",
"router": "service",
"memory": "data",
"session": "data",
"bus": "data",
"llm": "utility",
"mcp": "utility",
"tools": "utility",
"telemetry": "utility",
"prompts": "utility",
"quality": "utility",
"evaluation": "utility",
"evolution": "utility",
"configs": "utility",
}
# Chinese summaries for modules
MODULE_SUMMARIES = {
"core": "核心模块 - 定义Agent基类、通信协议、ReAct引擎、任务分发、注册中心等基础组件",
"core.base": "Agent基类 - 统一Agent生命周期管理包括启动、停止、任务执行、Handoff、进度上报",
"core.protocol": "通信协议定义 - 统一消息格式包括TaskMessage、TaskResult、TaskProgress、HandoffMessage等",
"core.react": "ReAct推理-行动循环引擎 - 实现Think→Act→Observe循环支持工具调用和文本解析模式",
"core.exceptions": "自定义异常体系 - 定义Agent框架所有异常类型",
"core.dispatcher": "任务分发器 - 通过Redis Queue将任务分发给Agent支持回调、重试、进度上报",
"core.registry": "Agent注册中心 - 管理Agent的注册、发现、状态、心跳和负载均衡",
"core.config_driven": "配置驱动Agent - 从YAML/Dict配置自动组装Agent支持llm_generate/tool_call/custom三种模式",
"core.compressor": "上下文压缩器 - 长会话自动压缩历史消息支持LLM摘要和简单截断策略",
"core.trace": "执行轨迹记录器 - 记录ReAct执行过程中的完整轨迹为反思和可观测性提供数据",
"core.shared_workspace": "共享工作空间 - 基于Redis的Agent间共享状态存储支持读写、锁操作",
"core.agent_pool": "Agent实例池 - 运行时管理Agent的创建、获取、删除",
"core.orchestrator": "多Agent协作编排器 - 实现Orchestrator-Worker模式支持任务分解、并行执行、自适应编排",
"core.headroom_compressor": "Headroom AI压缩器 - 基于Headroom AI的上下文压缩实现",
"core.logging": "日志配置 - 统一日志格式和配置",
"core.standalone": "独立运行模式 - 支持Agent脱离框架独立运行",
"llm": "LLM网关模块 - 多Provider统一网关支持OpenAI/Anthropic/Gemini/文心/豆包/元宝等",
"llm.gateway": "LLM网关 - 统一多Provider调用接口支持路由、重试、流式输出",
"llm.protocol": "LLM协议定义 - 定义LLMProvider、LLMRequest、LLMResponse等接口",
"llm.config": "LLM配置 - 模型别名、Provider配置管理",
"llm.retry": "LLM重试策略 - 指数退避重试和错误处理",
"llm.providers": "LLM Provider实现 - 各大模型服务商的具体适配器",
"llm.providers.openai": "OpenAI Provider - 支持GPT-4/GPT-3.5等模型",
"llm.providers.anthropic": "Anthropic Provider - 支持Claude系列模型",
"llm.providers.gemini": "Gemini Provider - 支持Google Gemini模型",
"llm.providers.wenxin": "文心一言Provider - 支持百度文心大模型",
"llm.providers.doubao": "豆包Provider - 支持字节豆包大模型",
"llm.providers.yuanbao": "元宝Provider - 支持腾讯元宝大模型",
"llm.providers.tracker": "LLM调用追踪器 - 记录和统计LLM调用",
"tools": "工具模块 - 提供Agent可调用的各类工具",
"tools.base": "工具基类 - 定义Tool接口和标准执行流程",
"tools.registry": "工具注册中心 - 管理工具的注册、发现、获取",
"tools.shell": "Shell工具 - 执行系统命令",
"tools.web_search": "Web搜索工具 - 执行网络搜索",
"tools.web_crawl": "Web爬取工具 - 爬取网页内容",
"tools.memory_tool": "记忆工具 - Agent记忆读写操作",
"tools.ask_human": "人工介入工具 - 请求人类输入",
"tools.schema_tools": "Schema工具 - JSON Schema相关操作",
"tools.function_tool": "函数工具 - 将Python函数包装为Tool",
"tools.agent_tool": "Agent工具 - 将Agent包装为可调用Tool",
"tools.mcp_tool": "MCP工具 - MCP协议工具适配器",
"tools.composition": "工具组合 - 支持工具链式组合",
"tools.baidu_search": "百度搜索工具 - 百度搜索引擎集成",
"tools.headroom_retrieve": "Headroom检索工具 - Headroom AI知识检索",
"memory": "记忆模块 - 多层记忆系统,支持工作记忆、情景记忆、语义记忆",
"memory.base": "记忆基类 - 定义Memory接口",
"memory.working": "工作记忆 - 基于Redis的短期工作记忆",
"memory.episodic": "情景记忆 - 基于向量数据库的长期情景记忆",
"memory.semantic": "语义记忆 - 基于RAG服务的语义知识检索",
"memory.profile": "用户画像 - 用户偏好和历史信息管理",
"memory.retriever": "记忆检索器 - 统一多层记忆检索接口",
"memory.embedder": "嵌入器 - 文本向量化支持OpenAI Embedding",
"memory.models": "记忆数据模型 - Pydantic模型定义",
"memory.rag_loop": "RAG循环 - 检索增强生成的迭代循环",
"memory.query_transformer": "查询转换器 - 优化检索查询",
"memory.relevance_scorer": "相关性评分器 - 评估检索结果相关性",
"memory.contextual_retrieval": "上下文检索 - 基于上下文的检索增强",
"memory.http_rag": "HTTP RAG服务 - 远程RAG API客户端",
"skills": "技能模块 - 定义可复用的Agent技能包含意图、工具和质量门控",
"skills.base": "技能基类 - 定义Skill、SkillConfig、IntentConfig等",
"skills.registry": "技能注册中心 - 管理技能的注册、发现、获取",
"skills.loader": "技能加载器 - 从YAML配置加载技能定义",
"skills.pipeline": "技能Pipeline - 技能编排流程",
"skills.skill_md": "Markdown技能 - 从Markdown文档生成技能",
"skills.geo_pipeline": "GEO Pipeline - 地理信息处理Pipeline",
"orchestrator": "编排模块 - Pipeline编排引擎支持DAG工作流",
"orchestrator.pipeline_engine": "Pipeline引擎 - 执行DAG定义的工作流",
"orchestrator.pipeline_schema": "Pipeline Schema - Pipeline配置模型定义",
"orchestrator.pipeline_state": "Pipeline状态 - Pipeline执行状态管理",
"orchestrator.pipeline_models": "Pipeline模型 - Pipeline数据模型",
"orchestrator.pipeline_loader": "Pipeline加载器 - 从YAML加载Pipeline定义",
"orchestrator.reflection": "反思模块 - 执行后反思和改进",
"orchestrator.retry": "重试策略 - Pipeline步骤重试机制",
"orchestrator.compensation": "补偿机制 - Pipeline失败时的补偿操作",
"orchestrator.handoff": "Handoff - Agent间任务转交",
"orchestrator.dynamic_pipeline": "动态Pipeline - 运行时动态构建Pipeline",
"router": "路由模块 - 意图路由,将用户输入匹配到对应技能",
"router.intent": "意图路由器 - 基于LLM的意图识别和路由",
"quality": "质量模块 - 输出质量门控和标准化",
"quality.gate": "质量门控 - 检查Agent输出是否满足质量要求",
"quality.output": "输出标准化 - 统一Agent输出格式",
"prompts": "Prompt模块 - Prompt模板和渲染",
"prompts.template": "Prompt模板 - 支持变量替换和Section组合",
"prompts.section": "Prompt Section - 定义Prompt的各组成部分",
"bus": "消息总线模块 - Agent间异步通信",
"bus.protocol": "总线协议 - 定义消息总线接口",
"bus.message": "消息定义 - Agent间通信消息格式",
"bus.memory_bus": "内存消息总线 - 基于进程内队列的消息总线",
"bus.redis_bus": "Redis消息总线 - 基于Redis Pub/Sub的消息总线",
"session": "会话模块 - 会话管理和持久化",
"session.manager": "会话管理器 - 管理对话会话的创建、获取、更新",
"session.store": "会话存储 - 会话数据的持久化存储",
"session.models": "会话模型 - 会话相关的数据模型",
"server": "服务器模块 - FastAPI HTTP/WebSocket服务",
"server.app": "FastAPI应用 - 创建和配置FastAPI应用实例",
"server.config": "服务器配置 - 服务器运行参数配置",
"server.runner": "服务器运行器 - 启动和管理服务器进程",
"server.middleware": "中间件 - 请求处理中间件",
"server.client": "API客户端 - 服务端API客户端封装",
"server.client_config": "客户端配置 - API客户端配置管理",
"server.task_store": "任务存储 - 服务端任务状态存储",
"server.routes": "路由模块 - HTTP/WebSocket路由定义",
"server.routes.chat": "聊天路由 - 对话API端点",
"server.routes.ws": "WebSocket路由 - 实时通信端点",
"server.routes.tasks": "任务路由 - 任务管理API",
"server.routes.agents": "Agent路由 - Agent管理API",
"server.routes.skills": "技能路由 - 技能管理API",
"server.routes.memory": "记忆路由 - 记忆管理API",
"server.routes.llm": "LLM路由 - LLM配置和调用API",
"server.routes.health": "健康检查路由 - 服务健康状态端点",
"server.routes.metrics": "指标路由 - 运行指标API",
"server.routes.evolution": "进化路由 - Agent进化管理API",
"cli": "命令行模块 - CLI工具",
"cli.main": "CLI入口 - Typer应用主入口",
"cli.chat": "聊天命令 - 交互式对话命令",
"cli.init": "初始化命令 - 项目初始化",
"cli.onboarding": "引导命令 - 新用户引导流程",
"cli.skill": "技能命令 - 技能管理CLI",
"cli.task": "任务命令 - 任务提交和管理CLI",
"cli.pair": "配对命令 - Agent配对",
"cli.usage": "使用统计命令 - 使用情况统计",
"cli.templates": "模板命令 - Agent模板管理",
"mcp": "MCP协议模块 - Model Context Protocol集成",
"mcp.client": "MCP客户端 - 连接MCP服务器",
"mcp.server": "MCP服务器 - 提供MCP服务",
"mcp.manager": "MCP管理器 - 管理MCP连接",
"mcp.transport": "MCP传输层 - MCP通信传输实现",
"telemetry": "遥测模块 - 可观测性支持",
"telemetry.tracing": "分布式追踪 - OpenTelemetry追踪集成",
"telemetry.metrics": "指标收集 - 运行指标收集和导出",
"telemetry.setup": "遥测设置 - 初始化遥测组件",
"evolution": "进化模块 - Agent自我进化能力",
"evolution.lifecycle": "进化生命周期 - EvolutionMixin任务后触发进化",
"evolution.reflector": "反思器 - 分析任务执行结果,生成改进建议",
"evolution.llm_reflector": "LLM反思器 - 使用LLM进行深度反思",
"evolution.prompt_optimizer": "Prompt优化器 - 自动优化Agent Prompt",
"evolution.strategy_tuner": "策略调优器 - 调整Agent执行策略",
"evolution.genetic": "遗传算法 - 基于遗传算法的Prompt进化",
"evolution.fitness": "适应度评估 - 评估进化变体的质量",
"evolution.ab_tester": "A/B测试 - 对比测试不同进化变体",
"evolution.evolution_store": "进化存储 - 持久化进化历史",
"evolution.models": "进化模型 - 进化相关数据模型",
"evaluation": "评估模块 - Agent输出质量评估",
"evaluation.ragas_evaluator": "RAGAS评估器 - 使用RAGAS框架评估RAG质量",
"configs": "配置模块 - Pipeline和技能YAML配置",
"configs.geo_server": "GEO服务器 - 地理信息HTTP服务",
"configs.geo_handlers": "GEO处理器 - 地理信息请求处理",
"configs.geo_tools": "GEO工具 - 地理信息相关工具定义",
}
def get_layer(file_path: str) -> str:
"""Determine architecture layer from file path."""
parts = file_path.replace("\\", "/").split("/")
# Check for configs/ prefix
if "configs" in parts:
return "utility"
# For src/agentkit/__init__.py and __main__.py, treat as service
if parts[-1] in ("__init__.py", "__main__.py") and len(parts) <= 4:
return "service"
for part in parts:
if part in LAYER_MAP:
return LAYER_MAP[part]
return "unknown"
def get_module_key(file_path: str) -> str:
"""Get module key for summary lookup."""
# Convert file path to module key
rel = file_path
if rel.startswith("src/agentkit/"):
rel = rel[len("src/agentkit/"):]
elif rel.startswith("configs/"):
rel = rel[len("configs/"):]
# Remove __init__.py and .py suffix
rel = rel.replace("/__init__.py", "").replace(".py", "")
return rel
def get_file_summary(file_path: str, docstring: str = "") -> str:
"""Get Chinese summary for a file."""
# If we have a docstring, use it as base
if docstring:
# Clean up docstring
doc = docstring.strip().split("\n")[0].strip()
if doc:
return doc
key = get_module_key(file_path)
# Try exact match first
if key in MODULE_SUMMARIES:
return MODULE_SUMMARIES[key]
# Try parent module
parts = key.split("/")
for i in range(len(parts) - 1, 0, -1):
parent_key = "/".join(parts[:i])
if parent_key in MODULE_SUMMARIES:
return MODULE_SUMMARIES[parent_key]
return f"模块 {key}"
def estimate_complexity(node: ast.AST) -> str:
"""Estimate complexity of an AST node."""
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
# Count branches, loops, nested functions
complexity = 1
for child in ast.walk(node):
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
complexity += 1
elif isinstance(child, (ast.And, ast.Or)):
complexity += 1
if complexity <= 3:
return "simple"
elif complexity <= 8:
return "moderate"
return "complex"
elif isinstance(node, ast.ClassDef):
methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
if len(methods) <= 3:
return "simple"
elif len(methods) <= 8:
return "moderate"
return "complex"
return "simple"
def extract_class_info(node: ast.ClassDef, file_path: str) -> dict:
"""Extract class information from AST node."""
base_classes = []
for base in node.bases:
if isinstance(base, ast.Name):
base_classes.append(base.id)
elif isinstance(base, ast.Attribute):
base_classes.append(ast.dump(base))
methods = []
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
params = [arg.arg for arg in item.args.args if arg.arg != "self"]
methods.append({
"name": item.name,
"params": params,
"is_async": isinstance(item, ast.AsyncFunctionDef),
})
# Extract class docstring
docstring = ast.get_docstring(node) or ""
return {
"name": node.name,
"base_classes": base_classes,
"methods": methods,
"complexity": estimate_complexity(node),
"docstring": docstring,
}
def extract_function_info(node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict:
"""Extract function information from AST node."""
params = [arg.arg for arg in node.args.args]
return_type = ""
if node.returns:
if isinstance(node.returns, ast.Name):
return_type = node.returns.id
elif isinstance(node.returns, ast.Constant):
return_type = str(node.returns.value)
else:
return_type = ast.dump(node.returns)
return {
"name": node.name,
"params": params,
"return_type": return_type,
"is_async": isinstance(node, ast.AsyncFunctionDef),
"complexity": estimate_complexity(node),
}
def extract_imports(tree: ast.AST, file_path: str) -> list[dict]:
"""Extract import information from AST."""
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module and (node.module.startswith("agentkit") or node.module.startswith("configs")):
for alias in node.names:
imports.append({
"from_module": node.module,
"import_name": alias.name,
})
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("agentkit") or alias.name.startswith("configs"):
imports.append({
"from_module": None,
"import_name": alias.name,
})
return imports
def module_to_file_path(module: str) -> str:
"""Convert Python module path to file path."""
parts = module.split(".")
# Handle agentkit modules
if module.startswith("agentkit"):
# Skip "agentkit" prefix, it's under src/
sub_parts = parts[1:] # skip "agentkit"
if not sub_parts:
return "src/agentkit/__init__.py"
# Try as package __init__.py
init_path = PROJECT_ROOT / "src" / "agentkit" / "/".join(sub_parts) / "__init__.py"
if init_path.exists():
return f"src/agentkit/{'/'.join(sub_parts)}/__init__.py"
# Try as module.py
mod_path = PROJECT_ROOT / "src" / "agentkit" / ("/".join(sub_parts) + ".py")
if mod_path.exists():
return f"src/agentkit/{'/'.join(sub_parts)}.py"
# Handle configs modules
if module.startswith("configs"):
sub_parts = parts[1:] # skip "configs"
if not sub_parts:
return "configs/__init__.py"
mod_path = PROJECT_ROOT / "configs" / ("/".join(sub_parts) + ".py")
if mod_path.exists():
return f"configs/{'/'.join(sub_parts)}.py"
return ""
def scan_file(file_path: Path) -> dict:
"""Scan a single Python file and extract all information."""
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source)
except (SyntaxError, UnicodeDecodeError):
return {"classes": [], "functions": [], "imports": [], "top_level_functions": [], "docstring": ""}
rel_path = str(file_path.relative_to(PROJECT_ROOT))
# Extract module docstring
docstring = ast.get_docstring(tree) or ""
classes = []
functions = []
top_level_functions = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.ClassDef):
classes.append(extract_class_info(node, rel_path))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
func_info = extract_function_info(node)
functions.append(func_info)
top_level_functions.append(func_info)
imports = extract_imports(tree, rel_path)
return {
"classes": classes,
"functions": top_level_functions,
"imports": imports,
"rel_path": rel_path,
"docstring": docstring,
}
def build_knowledge_graph():
"""Build the complete knowledge graph."""
# Collect all Python files
py_files = []
for scan_dir in SCAN_DIRS:
if scan_dir.exists():
for py_file in scan_dir.rglob("*.py"):
py_files.append(py_file)
print(f"Found {len(py_files)} Python files to scan")
# Scan all files
file_data = {}
for py_file in sorted(py_files):
data = scan_file(py_file)
rel_path = data["rel_path"]
file_data[rel_path] = data
# Build nodes and edges
nodes = []
edges = []
# Track all node IDs for edge building
file_node_ids = {}
class_node_ids = {}
func_node_ids = {}
# 1. Create file nodes
for rel_path, data in file_data.items():
node_id = f"file:{rel_path}"
layer = get_layer(rel_path)
summary = get_file_summary(rel_path, data.get("docstring", ""))
tags = []
parts = rel_path.replace("\\", "/").split("/")
for p in parts:
if p not in ("src", "agentkit", "__init__.py") and not p.endswith(".py"):
tags.append(p)
nodes.append({
"id": node_id,
"type": "file",
"name": rel_path.split("/")[-1],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": tags,
"complexity": "moderate" if data["classes"] or data["functions"] else "simple",
})
file_node_ids[rel_path] = node_id
# 2. Create class nodes
for rel_path, data in file_data.items():
for cls in data["classes"]:
class_id = f"class:{cls['name']}"
layer = get_layer(rel_path)
method_names = [m["name"] for m in cls["methods"]]
# Use docstring for summary if available
docstring = cls.get("docstring", "")
if docstring:
# Take first line of docstring
summary = docstring.strip().split("\n")[0].strip()
else:
summary = f"{cls['name']}"
if cls["base_classes"]:
summary += f",继承自{', '.join(cls['base_classes'])}"
if method_names:
summary += f",包含方法: {', '.join(method_names[:5])}"
if len(method_names) > 5:
summary += f"{len(method_names)}个方法"
nodes.append({
"id": class_id,
"type": "class",
"name": cls["name"],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": [cls["name"]],
"complexity": cls["complexity"],
})
class_node_ids[cls["name"]] = class_id
# Edge: file contains class
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": class_id,
"type": "contains",
"label": f"定义类 {cls['name']}",
})
# Edge: class extends base classes
for base in cls["base_classes"]:
if base in class_node_ids:
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": class_id,
"target": class_node_ids[base],
"type": "extends",
"label": f"继承 {base}",
})
# 3. Create method nodes
for method in cls["methods"]:
method_id = f"func:{cls['name']}.{method['name']}"
async_tag = "异步" if method["is_async"] else ""
summary = f"{cls['name']}.{method['name']}({', '.join(method['params'])}) {async_tag}方法"
nodes.append({
"id": method_id,
"type": "function",
"name": method["name"],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": [cls["name"], method["name"]],
"complexity": "simple",
})
func_node_ids[f"{cls['name']}.{method['name']}"] = method_id
# Edge: class contains method
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": class_id,
"target": method_id,
"type": "contains",
"label": f"方法 {method['name']}",
})
# 4. Create top-level function nodes
for rel_path, data in file_data.items():
for func in data["functions"]:
func_id = f"func:{func['name']}"
async_tag = "异步" if func["is_async"] else ""
summary = f"{func['name']}({', '.join(func['params'])}) {async_tag}函数"
if func["return_type"]:
summary += f"{func['return_type']}"
nodes.append({
"id": func_id,
"type": "function",
"name": func["name"],
"filePath": rel_path,
"layer": get_layer(rel_path),
"summary": summary,
"tags": [func["name"]],
"complexity": func["complexity"],
})
func_node_ids[func["name"]] = func_id
# Edge: file contains function
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": func_id,
"type": "contains",
"label": f"定义函数 {func['name']}",
})
# 5. Create import edges
for rel_path, data in file_data.items():
for imp in data["imports"]:
if imp["from_module"]:
target_path = module_to_file_path(imp["from_module"])
if target_path and target_path in file_node_ids:
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": file_node_ids[target_path],
"type": "imports",
"label": f"导入 {imp['import_name']}",
})
# 6. Build tours
tours = build_tours(file_data, file_node_ids, class_node_ids, func_node_ids)
# Get git commit hash
git_hash = "045fecd4cee49f04dc7b693c14d35ca38a0d92cb"
# Build final JSON
graph = {
"version": "1.0.0",
"project": {
"name": "Fischer AgentKit",
"languages": ["python"],
"frameworks": ["FastAPI", "Pydantic", "SQLAlchemy", "Typer", "Redis"],
"description": "AI驱动的Agent框架支持ReAct引擎、多LLM网关、Pipeline编排、自适应反思和消息总线",
"analyzedAt": datetime.now(timezone.utc).isoformat(),
"gitCommitHash": git_hash,
},
"nodes": nodes,
"edges": edges,
"tours": tours,
}
return graph
def build_tours(file_data, file_node_ids, class_node_ids, func_node_ids):
"""Build guided learning tours."""
tours = []
# Tour 1: Entry Points
tours.append({
"id": "tour:entry-points",
"name": "入口点导览",
"description": "从项目入口开始了解如何启动和使用AgentKit",
"steps": [
{"nodeId": "file:src/agentkit/__main__.py", "why": "Python模块入口python -m agentkit"},
{"nodeId": "file:src/agentkit/__init__.py", "why": "包入口导出核心公共API"},
{"nodeId": "file:src/agentkit/cli/main.py", "why": "CLI主入口Typer应用定义"},
{"nodeId": "file:src/agentkit/server/app.py", "why": "HTTP服务入口FastAPI应用创建"},
],
})
# Tour 2: Core Agent Lifecycle
tours.append({
"id": "tour:agent-lifecycle",
"name": "Agent生命周期导览",
"description": "深入理解Agent从创建到执行任务的完整生命周期",
"steps": [
{"nodeId": "class:BaseAgent", "why": "Agent基类定义标准生命周期和可插拔能力"},
{"nodeId": "func:BaseAgent.start", "why": "Agent启动流程连接Redis→注册→心跳→监听"},
{"nodeId": "func:BaseAgent.execute", "why": "任务执行框架方法on_task_start→handle_task→quality_gate→on_task_complete"},
{"nodeId": "func:BaseAgent.handle_task", "why": "抽象方法,子类实现业务逻辑"},
{"nodeId": "class:ConfigDrivenAgent", "why": "配置驱动Agent从YAML自动组装"},
{"nodeId": "func:ConfigDrivenAgent.handle_task", "why": "根据execution_mode路由到react/direct/custom模式"},
{"nodeId": "class:AgentConfig", "why": "Agent配置模型支持YAML/Dict构建"},
],
})
# Tour 3: ReAct Engine
tours.append({
"id": "tour:react-engine",
"name": "ReAct引擎导览",
"description": "理解ReAct推理-行动循环的核心实现",
"steps": [
{"nodeId": "class:ReActEngine", "why": "ReAct引擎核心Think→Act→Observe循环"},
{"nodeId": "func:ReActEngine.execute", "why": "执行ReAct循环支持超时和取消"},
{"nodeId": "func:ReActEngine.execute_stream", "why": "流式执行逐步yield事件"},
{"nodeId": "func:ReActEngine._execute_tool", "why": "工具调用执行,处理成功和失败"},
{"nodeId": "func:ReActEngine._parse_text_tool_calls", "why": "文本解析模式支持Action和代码块格式"},
{"nodeId": "class:ReActStep", "why": "单步记录数据结构"},
{"nodeId": "class:ReActResult", "why": "ReAct执行结果数据结构"},
{"nodeId": "class:ReActEvent", "why": "流式执行事件数据结构"},
],
})
# Tour 4: LLM Gateway
tours.append({
"id": "tour:llm-gateway",
"name": "LLM网关导览",
"description": "了解多Provider统一网关的设计和实现",
"steps": [
{"nodeId": "class:LLMGateway", "why": "LLM网关核心统一多Provider调用接口"},
{"nodeId": "file:src/agentkit/llm/protocol.py", "why": "LLM协议定义LLMProvider/LLMRequest/LLMResponse"},
{"nodeId": "file:src/agentkit/llm/config.py", "why": "模型别名和Provider配置"},
{"nodeId": "file:src/agentkit/llm/providers/openai.py", "why": "OpenAI Provider实现"},
{"nodeId": "file:src/agentkit/llm/providers/anthropic.py", "why": "Anthropic Provider实现"},
{"nodeId": "file:src/agentkit/llm/retry.py", "why": "LLM重试策略"},
],
})
# Tour 5: Memory System
tours.append({
"id": "tour:memory-system",
"name": "记忆系统导览",
"description": "理解多层记忆系统的架构和实现",
"steps": [
{"nodeId": "file:src/agentkit/memory/base.py", "why": "记忆基类接口定义"},
{"nodeId": "file:src/agentkit/memory/retriever.py", "why": "统一记忆检索器,整合工作/情景/语义记忆"},
{"nodeId": "file:src/agentkit/memory/working.py", "why": "工作记忆 - 基于Redis的短期记忆"},
{"nodeId": "file:src/agentkit/memory/episodic.py", "why": "情景记忆 - 基于向量的长期记忆"},
{"nodeId": "file:src/agentkit/memory/semantic.py", "why": "语义记忆 - RAG服务集成"},
{"nodeId": "file:src/agentkit/memory/embedder.py", "why": "文本向量化嵌入器"},
],
})
# Tour 6: Orchestration
tours.append({
"id": "tour:orchestration",
"name": "编排系统导览",
"description": "了解多Agent协作编排和Pipeline引擎",
"steps": [
{"nodeId": "class:Orchestrator", "why": "多Agent协作编排器Orchestrator-Worker模式"},
{"nodeId": "func:Orchestrator.execute", "why": "编排执行:分解→执行→汇总"},
{"nodeId": "func:Orchestrator.execute_adaptive", "why": "自适应编排:执行→评估→再分解循环"},
{"nodeId": "file:src/agentkit/orchestrator/pipeline_engine.py", "why": "Pipeline引擎执行DAG工作流"},
{"nodeId": "file:src/agentkit/orchestrator/pipeline_schema.py", "why": "Pipeline配置模型"},
{"nodeId": "file:src/agentkit/orchestrator/reflection.py", "why": "执行后反思模块"},
],
})
# Tour 7: Skills & Router
tours.append({
"id": "tour:skills-router",
"name": "技能与路由导览",
"description": "了解技能定义、注册和意图路由机制",
"steps": [
{"nodeId": "file:src/agentkit/skills/base.py", "why": "技能基类和配置定义"},
{"nodeId": "class:SkillRegistry", "why": "技能注册中心"},
{"nodeId": "file:src/agentkit/skills/loader.py", "why": "从YAML加载技能定义"},
{"nodeId": "class:IntentRouter", "why": "意图路由器,匹配用户输入到技能"},
{"nodeId": "file:src/agentkit/router/intent.py", "why": "意图路由实现"},
],
})
# Tour 8: Evolution
tours.append({
"id": "tour:evolution",
"name": "进化系统导览",
"description": "了解Agent自我进化的机制和实现",
"steps": [
{"nodeId": "file:src/agentkit/evolution/lifecycle.py", "why": "进化生命周期Mixin"},
{"nodeId": "file:src/agentkit/evolution/reflector.py", "why": "反思器 - 分析结果生成改进建议"},
{"nodeId": "file:src/agentkit/evolution/prompt_optimizer.py", "why": "Prompt自动优化"},
{"nodeId": "file:src/agentkit/evolution/genetic.py", "why": "遗传算法进化"},
{"nodeId": "file:src/agentkit/evolution/ab_tester.py", "why": "A/B测试对比"},
],
})
# Tour 9: Infrastructure
tours.append({
"id": "tour:infrastructure",
"name": "基础设施导览",
"description": "了解消息总线、会话管理、遥测等基础设施",
"steps": [
{"nodeId": "file:src/agentkit/bus/protocol.py", "why": "消息总线协议接口"},
{"nodeId": "file:src/agentkit/bus/redis_bus.py", "why": "Redis Pub/Sub消息总线"},
{"nodeId": "file:src/agentkit/bus/memory_bus.py", "why": "进程内消息总线"},
{"nodeId": "file:src/agentkit/session/manager.py", "why": "会话管理器"},
{"nodeId": "file:src/agentkit/telemetry/tracing.py", "why": "OpenTelemetry追踪集成"},
{"nodeId": "file:src/agentkit/telemetry/metrics.py", "why": "运行指标收集"},
],
})
return tours
def main():
"""Main entry point."""
print("Building knowledge graph for Fischer AgentKit...")
graph = build_knowledge_graph()
# Ensure output directory exists
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
# Write JSON
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(graph, f, ensure_ascii=False, indent=2)
print(f"Knowledge graph written to {OUTPUT_PATH}")
print(f" Nodes: {len(graph['nodes'])}")
print(f" Edges: {len(graph['edges'])}")
print(f" Tours: {len(graph['tours'])}")
# Print layer statistics
layer_counts = {}
for node in graph["nodes"]:
layer = node["layer"]
layer_counts[layer] = layer_counts.get(layer, 0) + 1
print("\nLayer distribution:")
for layer, count in sorted(layer_counts.items()):
print(f" {layer}: {count} nodes")
# Print type statistics
type_counts = {}
for node in graph["nodes"]:
t = node["type"]
type_counts[t] = type_counts.get(t, 0) + 1
print("\nNode type distribution:")
for t, count in sorted(type_counts.items()):
print(f" {t}: {count} nodes")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fischer AgentKit - Knowledge Graph Dashboard</title>
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; overflow: hidden; height: 100vh; }
.header { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #334155; }
.header h1 { font-size: 18px; font-weight: 600; background: linear-gradient(135deg, #60a5fa, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stats { display: flex; gap: 16px; font-size: 13px; color: #94a3b8; }
.stats span { padding: 4px 10px; background: #1e293b; border-radius: 6px; border: 1px solid #334155; }
.main { display: flex; height: calc(100vh - 52px); }
.sidebar { width: 340px; background: #1e293b; border-right: 1px solid #334155; display: flex; flex-direction: column; overflow: hidden; }
.search-box { padding: 12px; border-bottom: 1px solid #334155; }
.search-box input { width: 100%; padding: 8px 12px; background: #0f172a; border: 1px solid #475569; border-radius: 8px; color: #e2e8f0; font-size: 14px; outline: none; }
.search-box input:focus { border-color: #60a5fa; }
.tabs { display: flex; border-bottom: 1px solid #334155; }
.tab { flex: 1; padding: 8px; text-align: center; font-size: 13px; cursor: pointer; color: #94a3b8; border-bottom: 2px solid transparent; transition: all 0.2s; }
.tab.active { color: #60a5fa; border-bottom-color: #60a5fa; }
.tab:hover { color: #e2e8f0; }
.content { flex: 1; overflow-y: auto; padding: 8px; }
.content::-webkit-scrollbar { width: 6px; }
.content::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
.node-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; transition: background 0.15s; border: 1px solid transparent; }
.node-item:hover { background: #334155; border-color: #475569; }
.node-item.selected { background: #1e3a5f; border-color: #60a5fa; }
.node-item .name { font-size: 13px; font-weight: 500; color: #e2e8f0; }
.node-item .meta { font-size: 11px; color: #64748b; margin-top: 2px; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 6px; }
.badge.file { background: #1e3a5f; color: #60a5fa; }
.badge.class { background: #3b1f5e; color: #c084fc; }
.badge.function { background: #1a3d2e; color: #4ade80; }
.badge.api { background: #7c2d12; color: #fb923c; }
.badge.service { background: #1e3a5f; color: #60a5fa; }
.badge.data { background: #3b1f5e; color: #c084fc; }
.badge.utility { background: #1a3d2e; color: #4ade80; }
.graph-container { flex: 1; position: relative; }
#graph { width: 100%; height: 100%; }
.detail-panel { position: absolute; right: 16px; top: 16px; width: 320px; background: rgba(30,41,59,0.95); border: 1px solid #475569; border-radius: 12px; padding: 16px; display: none; backdrop-filter: blur(12px); max-height: calc(100vh - 100px); overflow-y: auto; }
.detail-panel.show { display: block; }
.detail-panel h3 { font-size: 15px; color: #60a5fa; margin-bottom: 8px; }
.detail-panel .summary { font-size: 13px; color: #cbd5e1; line-height: 1.5; margin-bottom: 12px; }
.detail-panel .field { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
.detail-panel .field strong { color: #e2e8f0; }
.detail-panel .edges-list { margin-top: 8px; }
.detail-panel .edge-item { font-size: 12px; padding: 4px 8px; background: #0f172a; border-radius: 4px; margin-bottom: 3px; cursor: pointer; }
.detail-panel .edge-item:hover { background: #334155; }
.tour-panel { padding: 8px; }
.tour-card { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; }
.tour-card:hover { border-color: #60a5fa; }
.tour-card h4 { font-size: 13px; color: #e2e8f0; margin-bottom: 4px; }
.tour-card p { font-size: 11px; color: #64748b; }
.tour-steps { margin-top: 8px; }
.tour-step { display: flex; align-items: center; gap: 8px; padding: 6px 8px; font-size: 12px; color: #94a3b8; border-radius: 4px; cursor: pointer; }
.tour-step:hover { background: #334155; color: #e2e8f0; }
.tour-step .num { width: 20px; height: 20px; background: #334155; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #60a5fa; flex-shrink: 0; }
.layer-filter { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 12px; border-bottom: 1px solid #334155; }
.layer-btn { padding: 3px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; border: 1px solid #475569; background: transparent; color: #94a3b8; transition: all 0.15s; }
.layer-btn.active { background: #334155; color: #e2e8f0; border-color: #60a5fa; }
.close-btn { position: absolute; top: 8px; right: 8px; background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 16px; }
.close-btn:hover { color: #e2e8f0; }
.loading { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; color: #60a5fa; }
</style>
</head>
<body>
<div class="header">
<h1>Fischer AgentKit Knowledge Graph</h1>
<div class="stats" id="stats"></div>
</div>
<div class="main">
<div class="sidebar">
<div class="search-box">
<input type="text" id="search" placeholder="Search nodes..." />
</div>
<div class="layer-filter" id="layerFilter"></div>
<div class="tabs">
<div class="tab active" data-tab="nodes">Nodes</div>
<div class="tab" data-tab="tours">Tours</div>
</div>
<div class="content" id="nodeList"></div>
<div class="content" id="tourList" style="display:none"></div>
</div>
<div class="graph-container">
<div id="graph"></div>
<div class="detail-panel" id="detailPanel">
<button class="close-btn" onclick="document.getElementById('detailPanel').classList.remove('show')">&times;</button>
<div id="detailContent"></div>
</div>
</div>
</div>
<script>
let graphData = null;
let network = null;
let nodesDataset = null;
let edgesDataset = null;
let activeLayers = new Set(['api', 'service', 'data', 'utility', 'unknown']);
let selectedNodeId = null;
const LAYER_COLORS = {
api: { bg: '#fb923c', border: '#ea580c', highlight: '#fdba74' },
service: { bg: '#60a5fa', border: '#2563eb', highlight: '#93c5fd' },
data: { bg: '#c084fc', border: '#9333ea', highlight: '#d8b4fe' },
utility: { bg: '#4ade80', border: '#16a34a', highlight: '#86efac' },
unknown: { bg: '#94a3b8', border: '#64748b', highlight: '#cbd5e1' }
};
const TYPE_SHAPES = { file: 'box', class: 'diamond', function: 'dot' };
async function loadGraph() {
try {
const resp = await fetch('knowledge-graph.json');
graphData = await resp.json();
initDashboard();
} catch(e) {
document.getElementById('graph').innerHTML = '<div class="loading">Failed to load knowledge-graph.json</div>';
}
}
function initDashboard() {
const { nodes, edges, tours, project } = graphData;
document.getElementById('stats').innerHTML = `
<span>${nodes.length} Nodes</span>
<span>${edges.length} Edges</span>
<span>${tours.length} Tours</span>
<span>${project.languages.join(', ')}</span>
`;
renderLayerFilter(nodes);
renderNodeList(nodes);
renderTourList(tours);
initGraph(nodes, edges);
initSearch(nodes);
initTabs();
}
function renderLayerFilter(nodes) {
const layers = [...new Set(nodes.map(n => n.layer).filter(Boolean))];
const container = document.getElementById('layerFilter');
container.innerHTML = layers.map(l =>
`<button class="layer-btn active" data-layer="${l}">${l}</button>`
).join('');
container.querySelectorAll('.layer-btn').forEach(btn => {
btn.onclick = () => {
const layer = btn.dataset.layer;
if (activeLayers.has(layer)) { activeLayers.delete(layer); btn.classList.remove('active'); }
else { activeLayers.add(layer); btn.classList.add('active'); }
filterGraph();
};
});
}
function renderNodeList(nodes) {
const container = document.getElementById('nodeList');
const filtered = nodes.filter(n => activeLayers.has(n.layer));
container.innerHTML = filtered.slice(0, 200).map(n => `
<div class="node-item" data-id="${n.id}">
<div class="name">${n.name} <span class="badge ${n.type}">${n.type}</span> <span class="badge ${n.layer}">${n.layer}</span></div>
<div class="meta">${n.filePath || ''}</div>
</div>
`).join('');
container.querySelectorAll('.node-item').forEach(el => {
el.onclick = () => focusNode(el.dataset.id);
});
}
function renderTourList(tours) {
const container = document.getElementById('tourList');
container.innerHTML = tours.map((t, i) => `
<div class="tour-card" data-tour="${i}">
<h4>${t.name}</h4>
<p>${t.description}</p>
<div class="tour-steps">
${t.steps.map((s, j) => `
<div class="tour-step" data-node="${s.nodeId}">
<span class="num">${j+1}</span>
<span>${s.why || s.nodeId}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
container.querySelectorAll('.tour-step').forEach(el => {
el.onclick = (e) => { e.stopPropagation(); focusNode(el.dataset.node); };
});
}
function initGraph(nodes, edges) {
const visNodes = nodes.filter(n => activeLayers.has(n.layer)).map(n => {
const colors = LAYER_COLORS[n.layer] || LAYER_COLORS.unknown;
return {
id: n.id,
label: n.name,
shape: TYPE_SHAPES[n.type] || 'dot',
color: colors,
font: { color: '#e2e8f0', size: n.type === 'file' ? 12 : 10 },
size: n.type === 'file' ? 20 : n.type === 'class' ? 15 : 8,
title: `${n.name}\n${n.summary || ''}\n[${n.layer}]`,
...n
};
});
const nodeIds = new Set(visNodes.map(n => n.id));
const visEdges = edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)).map((e, i) => ({
id: e.id || `edge-${i}`,
from: e.source,
to: e.target,
arrows: e.type === 'contains' ? '' : 'to',
color: { color: '#334155', highlight: '#60a5fa', hover: '#475569' },
width: 0.5,
dashes: e.type === 'imports',
title: e.type
}));
nodesDataset = new vis.DataSet(visNodes);
edgesDataset = new vis.DataSet(visEdges);
const container = document.getElementById('graph');
const data = { nodes: nodesDataset, edges: edgesDataset };
const options = {
physics: { barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 80, springConstant: 0.04 }, stabilization: { iterations: 100 } },
interaction: { hover: true, tooltipDelay: 200, navigationButtons: true, keyboard: true },
layout: { improvedLayout: true }
};
network = new vis.Network(container, data, options);
network.on('click', params => {
if (params.nodes.length > 0) focusNode(params.nodes[0]);
});
}
function filterGraph() {
const nodes = graphData.nodes.filter(n => activeLayers.has(n.layer));
renderNodeList(nodes);
if (nodesDataset) {
const nodeIds = new Set(nodes.map(n => n.id));
const edges = graphData.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
nodesDataset.clear();
nodesDataset.add(nodes.map(n => {
const colors = LAYER_COLORS[n.layer] || LAYER_COLORS.unknown;
return { id: n.id, label: n.name, shape: TYPE_SHAPES[n.type] || 'dot', color: colors, font: { color: '#e2e8f0', size: n.type === 'file' ? 12 : 10 }, size: n.type === 'file' ? 20 : n.type === 'class' ? 15 : 8, title: `${n.name}\n${n.summary || ''}\n[${n.layer}]`, ...n };
}));
edgesDataset.clear();
edgesDataset.add(edges.map((e, i) => ({ id: e.id || `edge-${i}`, from: e.source, to: e.target, arrows: e.type === 'contains' ? '' : 'to', color: { color: '#334155', highlight: '#60a5fa', hover: '#475569' }, width: 0.5, dashes: e.type === 'imports', title: e.type })));
}
}
function focusNode(nodeId) {
selectedNodeId = nodeId;
const node = graphData.nodes.find(n => n.id === nodeId);
if (!node) return;
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('selected'));
const el = document.querySelector(`.node-item[data-id="${nodeId}"]`);
if (el) { el.classList.add('selected'); el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
if (network) {
network.focus(nodeId, { scale: 1.5, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
network.selectNodes([nodeId]);
}
const inEdges = graphData.edges.filter(e => e.target === nodeId);
const outEdges = graphData.edges.filter(e => e.source === nodeId);
const panel = document.getElementById('detailPanel');
const content = document.getElementById('detailContent');
content.innerHTML = `
<h3>${node.name}</h3>
<div class="summary">${node.summary || 'No summary'}</div>
<div class="field"><strong>Type:</strong> ${node.type}</div>
<div class="field"><strong>Layer:</strong> ${node.layer}</div>
<div class="field"><strong>Path:</strong> ${node.filePath || '-'}</div>
<div class="field"><strong>Complexity:</strong> ${node.complexity || '-'}</div>
${node.tags ? `<div class="field"><strong>Tags:</strong> ${node.tags.join(', ')}</div>` : ''}
${inEdges.length > 0 ? `<div class="edges-list"><strong>Incoming (${inEdges.length}):</strong>${inEdges.slice(0,10).map(e => `<div class="edge-item" onclick="focusNode('${e.source}')">${e.type} ← ${e.source.split(':').pop()}</div>`).join('')}</div>` : ''}
${outEdges.length > 0 ? `<div class="edges-list"><strong>Outgoing (${outEdges.length}):</strong>${outEdges.slice(0,10).map(e => `<div class="edge-item" onclick="focusNode('${e.target}')">${e.type} → ${e.target.split(':').pop()}</div>`).join('')}</div>` : ''}
`;
panel.classList.add('show');
}
function initSearch(nodes) {
const input = document.getElementById('search');
input.oninput = () => {
const q = input.value.toLowerCase();
const filtered = nodes.filter(n =>
activeLayers.has(n.layer) &&
(n.name.toLowerCase().includes(q) || (n.summary || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q))
);
renderNodeList(filtered);
if (q.length > 1 && network) {
const matchIds = filtered.map(n => n.id);
if (matchIds.length > 0 && matchIds.length < 50) {
network.fit({ nodes: matchIds, animation: { duration: 500 } });
}
}
};
}
function initTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
document.getElementById('nodeList').style.display = target === 'nodes' ? 'block' : 'none';
document.getElementById('tourList').style.display = target === 'tours' ? 'block' : 'none';
};
});
}
loadGraph();
</script>
</body>
</html>

View File

@ -0,0 +1,250 @@
{
"configs/__init__.py": "830897da8bc1af33",
"configs/geo_handlers.py": "cbcf89b4d9da69c9",
"configs/geo_server.py": "41ddafbef18eea22",
"configs/geo_tools.py": "8a29de5d7511d2a0",
"src/agentkit/__init__.py": "43b17418bafb2c7c",
"src/agentkit/__main__.py": "503810fb0f210fab",
"src/agentkit/bus/__init__.py": "83b51b8b680ee75f",
"src/agentkit/bus/interface.py": "ee93b26fdcee1495",
"src/agentkit/bus/memory_bus.py": "28b4c3a18ef13181",
"src/agentkit/bus/message.py": "035adf5510427f85",
"src/agentkit/bus/protocol.py": "67e1ebb03a53da30",
"src/agentkit/bus/redis_bus.py": "7431c6cc2ed20e51",
"src/agentkit/chat/__init__.py": "e3b0c44298fc1c14",
"src/agentkit/chat/skill_routing.py": "bce85b158d5f2bd0",
"src/agentkit/cli/__init__.py": "a41c63d720a6e4db",
"src/agentkit/cli/chat.py": "fa0b4e7570c8bcb2",
"src/agentkit/cli/init.py": "b4f0de1670acdbb1",
"src/agentkit/cli/main.py": "25d92f4947b60dbf",
"src/agentkit/cli/onboarding.py": "0a6e8f869a02b067",
"src/agentkit/cli/pair.py": "1ceeffcc7a8d8ed3",
"src/agentkit/cli/skill.py": "af567fc67d603df6",
"src/agentkit/cli/task.py": "2a19fd0985717a20",
"src/agentkit/cli/templates.py": "b997aa3fc0dd8162",
"src/agentkit/cli/usage.py": "e121c1a5e76d21c1",
"src/agentkit/core/__init__.py": "f07bf925ec204974",
"src/agentkit/core/agent_pool.py": "d400580ed51fdd75",
"src/agentkit/core/base.py": "ab7261b559dd98c8",
"src/agentkit/core/compressor.py": "bfecb74c1a3de3e3",
"src/agentkit/core/config_driven.py": "68841e58ba2aa461",
"src/agentkit/core/dispatcher.py": "ec4c0dfc4a93b0be",
"src/agentkit/core/exceptions.py": "9145f66231230bd2",
"src/agentkit/core/goal_planner.py": "f2a4b33e33a3ab8a",
"src/agentkit/core/headroom_compressor.py": "cb496a6c5accb2ec",
"src/agentkit/core/logging.py": "96f61c08b97e4ffc",
"src/agentkit/core/orchestrator.py": "dd2de2f5a175e0de",
"src/agentkit/core/plan_checker.py": "8efab3240c01bca9",
"src/agentkit/core/plan_exec_engine.py": "93cc488b6a73cbd1",
"src/agentkit/core/plan_executor.py": "83e65f3399795244",
"src/agentkit/core/plan_schema.py": "a7ba8308e5ca8965",
"src/agentkit/core/protocol.py": "0c7d19ada22bff72",
"src/agentkit/core/react.py": "8612b9b1331c16b6",
"src/agentkit/core/reflexion.py": "a0812059d9c94825",
"src/agentkit/core/registry.py": "fa377a6bc19e87c7",
"src/agentkit/core/rewoo.py": "e673444bc4b91121",
"src/agentkit/core/shared_workspace.py": "9babb2eefff54246",
"src/agentkit/core/standalone.py": "a8a02755d5e4653d",
"src/agentkit/core/trace.py": "529563c5e0621c43",
"src/agentkit/evaluation/__init__.py": "d56ae7e0faa9a31d",
"src/agentkit/evaluation/ragas_evaluator.py": "8b6268ac71df3178",
"src/agentkit/evolution/__init__.py": "95142ac26f1ba26b",
"src/agentkit/evolution/ab_tester.py": "f8dc4bca82be03a9",
"src/agentkit/evolution/evolution_store.py": "9e9a1840f5a92377",
"src/agentkit/evolution/experience_schema.py": "81c2f52a74590e10",
"src/agentkit/evolution/experience_store.py": "f135174a2bbdfc2a",
"src/agentkit/evolution/fitness.py": "ba768d8387013b04",
"src/agentkit/evolution/genetic.py": "cdf3ff703b719be7",
"src/agentkit/evolution/lifecycle.py": "ae86dda1b5801b5f",
"src/agentkit/evolution/llm_reflector.py": "a9de6e81324c64f6",
"src/agentkit/evolution/models.py": "f5efef0d197be11f",
"src/agentkit/evolution/path_optimizer.py": "3fab382499e56ccd",
"src/agentkit/evolution/pitfall_detector.py": "e28d3ec9e8d59bf1",
"src/agentkit/evolution/prompt_optimizer.py": "41fb13fede6b3403",
"src/agentkit/evolution/reflector.py": "e72a78a4e7f2edf5",
"src/agentkit/evolution/strategy_tuner.py": "dc8cd09c786169ef",
"src/agentkit/llm/__init__.py": "657ef26b62f7bbba",
"src/agentkit/llm/config.py": "6a1984e98c59ec66",
"src/agentkit/llm/gateway.py": "b76262bb52cb4d13",
"src/agentkit/llm/protocol.py": "1bcfac4dfdff4d2c",
"src/agentkit/llm/providers/__init__.py": "46c33110f5f2520b",
"src/agentkit/llm/providers/anthropic.py": "28f5155e34b14f92",
"src/agentkit/llm/providers/doubao.py": "b33492eec5c57335",
"src/agentkit/llm/providers/gemini.py": "887f3a1322d0bae3",
"src/agentkit/llm/providers/openai.py": "e271cdb6914c3c2c",
"src/agentkit/llm/providers/tracker.py": "ed247284b574d0eb",
"src/agentkit/llm/providers/wenxin.py": "a0ac6a379635f8da",
"src/agentkit/llm/providers/yuanbao.py": "b9fc13b79e9942aa",
"src/agentkit/llm/retry.py": "478e1bb02bfcc598",
"src/agentkit/marketplace/__init__.py": "1f1580e072a7ca29",
"src/agentkit/marketplace/auction.py": "f09f9d6eff9a5b8f",
"src/agentkit/marketplace/wealth.py": "fd1a946e02f78d8b",
"src/agentkit/mcp/__init__.py": "a8a5c3c1add774af",
"src/agentkit/mcp/client.py": "785b1aba3497b49c",
"src/agentkit/mcp/manager.py": "736d67c3d8dd9d8d",
"src/agentkit/mcp/server.py": "3a9e94779d5eb53e",
"src/agentkit/mcp/transport.py": "8c6b1e564eb3e5e2",
"src/agentkit/memory/__init__.py": "71011b735f958a95",
"src/agentkit/memory/adapters/__init__.py": "469c38ab35d48484",
"src/agentkit/memory/adapters/base.py": "347dfd141a11d140",
"src/agentkit/memory/adapters/confluence.py": "f39068ec5354b67b",
"src/agentkit/memory/adapters/feishu.py": "2c0aa671fbbe3d3b",
"src/agentkit/memory/adapters/generic_http.py": "d45303c306d958f5",
"src/agentkit/memory/base.py": "df64874fe41402d7",
"src/agentkit/memory/chunking.py": "7c9947a60d8ebd7d",
"src/agentkit/memory/contextual_retrieval.py": "e3edd302f05cdfcd",
"src/agentkit/memory/document_loader.py": "30f5646712525d61",
"src/agentkit/memory/embedder.py": "3b483d4e80377e18",
"src/agentkit/memory/episodic.py": "db6f26e0dda31b8c",
"src/agentkit/memory/http_rag.py": "e4cd3f7bf11ba0ab",
"src/agentkit/memory/knowledge_base.py": "faa986892a910eff",
"src/agentkit/memory/local_rag.py": "4d474c286717a5a6",
"src/agentkit/memory/models.py": "e6861ba415a995a8",
"src/agentkit/memory/multi_source_retriever.py": "6eee1f48acf2f19f",
"src/agentkit/memory/profile.py": "153e008e625aa2f2",
"src/agentkit/memory/query_transformer.py": "ebedbfc043ee3ca9",
"src/agentkit/memory/rag_loop.py": "3ca5f89bf16fd16c",
"src/agentkit/memory/relevance_scorer.py": "bdb8930083078914",
"src/agentkit/memory/retriever.py": "28cc28168b69a5a3",
"src/agentkit/memory/semantic.py": "aa86076fd321399c",
"src/agentkit/memory/working.py": "8fc00c3c87d70845",
"src/agentkit/orchestrator/__init__.py": "4102c9499dd08119",
"src/agentkit/orchestrator/compensation.py": "460b78386f605f44",
"src/agentkit/orchestrator/dynamic_pipeline.py": "14e3f57a275160df",
"src/agentkit/orchestrator/handoff.py": "1901f8cd993ea02f",
"src/agentkit/orchestrator/pipeline_engine.py": "ab062dccfdc1f63b",
"src/agentkit/orchestrator/pipeline_loader.py": "2c09e8ede1ee792c",
"src/agentkit/orchestrator/pipeline_models.py": "de0175517a1fbb88",
"src/agentkit/orchestrator/pipeline_schema.py": "d0d64e7c20e63d53",
"src/agentkit/orchestrator/pipeline_state.py": "a462320b6c3554bc",
"src/agentkit/orchestrator/reflection.py": "2472b8d7161461b6",
"src/agentkit/orchestrator/retry.py": "abdc9c5fdd441e83",
"src/agentkit/orchestrator/workflow_schema.py": "f5b7efdb966d3564",
"src/agentkit/org/__init__.py": "ecc4ac01f48368bf",
"src/agentkit/org/context.py": "ee8e857268917c37",
"src/agentkit/org/discovery.py": "81dfb33d7599bb24",
"src/agentkit/prompts/__init__.py": "8afc78b85bd1f569",
"src/agentkit/prompts/section.py": "7698dadf96c29d62",
"src/agentkit/prompts/template.py": "de354279290b886b",
"src/agentkit/quality/__init__.py": "c12a5e356c25ef64",
"src/agentkit/quality/alignment.py": "1b480674d6598f8a",
"src/agentkit/quality/cascade_detector.py": "68dc7066e40ac8c9",
"src/agentkit/quality/gate.py": "211bf1d53ee7154d",
"src/agentkit/quality/output.py": "b26386d06d74d96d",
"src/agentkit/router/__init__.py": "76c37a202d535839",
"src/agentkit/router/intent.py": "99293a856fe71be6",
"src/agentkit/server/__init__.py": "df934a940763b2ae",
"src/agentkit/server/client.py": "badd8fd035e77613",
"src/agentkit/server/client_config.py": "c22cf22a3c9c52d7",
"src/agentkit/server/config.py": "a029878144c1fade",
"src/agentkit/server/middleware.py": "35981a4158defe97",
"src/agentkit/server/routes/agents.py": "f40c808fb19bb183",
"src/agentkit/server/routes/chat.py": "689d69dda752a22f",
"src/agentkit/server/routes/evolution.py": "f2b1d93d1588a9ed",
"src/agentkit/server/routes/health.py": "029fde5bf0951d0e",
"src/agentkit/server/routes/llm.py": "e3abf707341d9677",
"src/agentkit/server/routes/memory.py": "da00b9a092576ebe",
"src/agentkit/server/routes/metrics.py": "9cf9d61479278136",
"src/agentkit/server/routes/portal.py": "8c5ebdc1b3ede2bf",
"src/agentkit/server/routes/skill_management.py": "629bb1fe85f33007",
"src/agentkit/server/routes/skills.py": "3193d721029b5c6e",
"src/agentkit/server/routes/tasks.py": "f15c9f350f869770",
"src/agentkit/server/routes/ws.py": "784cb2b1af8abec2",
"src/agentkit/server/runner.py": "375e22b9f596adb9",
"src/agentkit/server/task_store.py": "b04afea982579a93",
"src/agentkit/session/__init__.py": "f7e2123235f799c2",
"src/agentkit/session/manager.py": "5cb0518f967b854b",
"src/agentkit/session/models.py": "8d96a974afc9acfb",
"src/agentkit/session/store.py": "41238fe9f9a4a522",
"src/agentkit/skills/__init__.py": "cd3bd9c844656636",
"src/agentkit/skills/base.py": "36e3d8062cbccd57",
"src/agentkit/skills/geo_pipeline.py": "42f969c61d0a3a7a",
"src/agentkit/skills/loader.py": "197ae05b735b6946",
"src/agentkit/skills/pipeline.py": "0367d52bd1a4d410",
"src/agentkit/skills/registry.py": "f63b5e174ec5d4d7",
"src/agentkit/skills/schema.py": "3cbe7fe2db688c4d",
"src/agentkit/skills/skill_md.py": "758de3b3601b2520",
"src/agentkit/telemetry/__init__.py": "66f777be163ce971",
"src/agentkit/telemetry/metrics.py": "72d548e3d6f1abef",
"src/agentkit/telemetry/setup.py": "b9f13873ef525378",
"src/agentkit/telemetry/tracer.py": "de8aebbe499ac264",
"src/agentkit/telemetry/tracing.py": "c0bca2277a02d383",
"src/agentkit/tools/__init__.py": "514d210f2d24be53",
"src/agentkit/tools/agent_tool.py": "0ba5f7b255225b0d",
"src/agentkit/tools/ask_human.py": "f9cb5255733e2e77",
"src/agentkit/tools/baidu_search.py": "81eaecce86d80780",
"src/agentkit/tools/base.py": "6a61acd0ca114026",
"src/agentkit/tools/composition.py": "1d2d10361382f459",
"src/agentkit/tools/computer_use.py": "be3462775cf3e004",
"src/agentkit/tools/computer_use_recorder.py": "98cf8693c0f136bf",
"src/agentkit/tools/computer_use_session.py": "102e1ac315fd09b8",
"src/agentkit/tools/function_tool.py": "702e5b3e8d6b465c",
"src/agentkit/tools/headroom_retrieve.py": "6da46b1a23fe8933",
"src/agentkit/tools/mcp_tool.py": "8a1da789ca963e2c",
"src/agentkit/tools/memory_tool.py": "197c51edcbbab705",
"src/agentkit/tools/output_parser.py": "f799cc7cafb6bb2e",
"src/agentkit/tools/pty_session.py": "6ceb31edf52a87fe",
"src/agentkit/tools/registry.py": "b2fe99106355b39d",
"src/agentkit/tools/schema_tools.py": "e490844348f3656b",
"src/agentkit/tools/shell.py": "6e2614979a2ade61",
"src/agentkit/tools/skill_install.py": "3cdf1b7c06343947",
"src/agentkit/tools/terminal_session.py": "432ceed53d63fcac",
"src/agentkit/tools/web_crawl.py": "89e2b4380810f60b",
"src/agentkit/tools/web_search.py": "3901c5ee7450521c",
"src/agentkit/utils/__init__.py": "273d2c7ba7ce101d",
"src/agentkit/utils/security.py": "939e46e447f57882",
"src/agentkit/utils/vector_math.py": "c3c7fa3f1e71463f",
"docs/brainstorms/2026-06-12-frontend-productization-requirements.md": "46e3ab3f45cd622e",
"docs/plans/2026-06-12-023-feat-frontend-productization-plan.md": "8f65a3b0dad42c4b",
"src/agentkit/server/frontend/package.json": "aebd560acfea05e8",
"src/agentkit/server/frontend/tsconfig.node.json": "d5806943b1bd0a2a",
"src/agentkit/server/frontend/vite.config.ts": "75f0385595bdcfcd",
"src/agentkit/server/app.py": "7ab76c4e95d04c05",
"src/agentkit/server/routes/__init__.py": "258f2f4c0d0cfaca",
"src/agentkit/server/routes/evolution_dashboard.py": "50d0e5cabdf6deaf",
"src/agentkit/server/routes/kb_management.py": "f2df2ab336e8966a",
"src/agentkit/server/routes/settings.py": "115e3e6fb9898883",
"src/agentkit/server/routes/terminal.py": "91ef1ba5efec6864",
"src/agentkit/server/routes/workflows.py": "6a3c19b6a4c6b157",
"src/agentkit/server/frontend/src/api/base.ts": "00e0b9a8a20134ae",
"src/agentkit/server/frontend/src/api/client.ts": "5ee2010d46fde145",
"src/agentkit/server/frontend/src/api/evolution.ts": "8e0be1d17af12ddf",
"src/agentkit/server/frontend/src/api/kb.ts": "d72a99362582fd7e",
"src/agentkit/server/frontend/src/api/settings.ts": "b9ed465850bc7ce1",
"src/agentkit/server/frontend/src/api/skills.ts": "59416c4806e257f0",
"src/agentkit/server/frontend/src/api/terminal.ts": "898862c911ca38c9",
"src/agentkit/server/frontend/src/api/workflow.ts": "60c5f34f8bf17739",
"src/agentkit/server/frontend/src/components/evolution/DashboardOverview.vue": "1734ddb592e27a91",
"src/agentkit/server/frontend/src/components/evolution/ExperiencePanel.vue": "c85a57ce584ad065",
"src/agentkit/server/frontend/src/components/evolution/MetricsChart.vue": "bb02f1ccc68e7891",
"src/agentkit/server/frontend/src/components/evolution/MetricsPanel.vue": "96a11588037f431d",
"src/agentkit/server/frontend/src/components/evolution/OptimizationPanel.vue": "cd09d8949e96728a",
"src/agentkit/server/frontend/src/components/evolution/PitfallRoutePanel.vue": "3a257fe8fadf557f",
"src/agentkit/server/frontend/src/components/evolution/UsagePanel.vue": "71e4e86d65238cc8",
"src/agentkit/server/frontend/src/components/kb/DocumentUpload.vue": "f72627b63f7dcd23",
"src/agentkit/server/frontend/src/components/kb/SearchTest.vue": "9189e0cdb6221bda",
"src/agentkit/server/frontend/src/components/kb/SourceConfig.vue": "debc96a327735395",
"src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue": "554f8c34a47678b8",
"src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue": "3ac8a2cbc279d158",
"src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue": "aca6f13eaa7f4548",
"src/agentkit/server/frontend/src/components/workflow/ParallelNode.vue": "dcf01080ee5171d4",
"src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue": "d84b2944a68c4da2",
"src/agentkit/server/frontend/src/components/workflow/SkillNode.vue": "209ebd4ea8b044dc",
"src/agentkit/server/frontend/src/main.ts": "b39507810967315c",
"src/agentkit/server/frontend/src/router/index.ts": "abb5156ca57d99b4",
"src/agentkit/server/frontend/src/stores/evolution.ts": "54f43be963383e56",
"src/agentkit/server/frontend/src/stores/knowledge.ts": "c639bbbee6906230",
"src/agentkit/server/frontend/src/stores/settings.ts": "a74460585842e471",
"src/agentkit/server/frontend/src/stores/terminal.ts": "aa652567a5eac361",
"src/agentkit/server/frontend/src/stores/workflow.ts": "94aadc5b90e7f98f",
"src/agentkit/server/frontend/src/utils/echarts.ts": "0f94ea52ff56ca85",
"src/agentkit/server/frontend/src/utils/workflowSerializer.ts": "2a38775e7c55f364",
"src/agentkit/server/frontend/src/views/EvolutionView.vue": "b6e92037d8ba864c",
"src/agentkit/server/frontend/src/views/TerminalView.vue": "444065d74e0d6272",
"src/agentkit/server/frontend/src/views/WorkflowView.vue": "0b43755c77b1babe",
"tests/unit/server/test_evolution_dashboard.py": "0b584b6c40aaec8b",
"tests/unit/server/test_kb_management.py": "16463430acc7a429",
"tests/unit/server/test_settings_routes.py": "f7920e0768fa4523",
"tests/unit/server/test_terminal_routes.py": "9433384c804e1705",
"tests/unit/server/test_workflow_routes.py": "99c72096d652ba95"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
{
"lastAnalyzedAt": "2026-06-12T17:39:05.023556+00:00",
"gitCommitHash": "09698d7a06c8d77b411e5a34ea27343ce9e8b42c",
"version": "1.0.0",
"analyzedFiles": 248
}

432
README.md
View File

@ -1,18 +1,20 @@
# Fischer AgentKit # Fischer AgentKit
统一 Agent 开发框架 -- 将 LLM、Tool、Prompt 组装为可执行的 Skill通过 ReAct 推理引擎自主完成任务。 统一 AI Agent 开发框架 -- 将 LLM、Tool、Prompt 组装为可执行的 Skill通过 ReAct 推理引擎自主完成任务支持记忆持久化、自进化、Pipeline 编排和桌面客户端
## 项目简介 ## 项目简介
AgentKit 解决的核心问题:**从写 150 行 Agent 代码降为 10-20 行 YAML 配置**。 AgentKit 解决的核心问题:**从写 150 行 Agent 代码降为 10-20 行 YAML 配置**。
传统方式下,每新增一个 Agent 需要编写子类、处理 LLM 调用、管理工具绑定、校验输出质量。AgentKit 将这些能力标准化为 8 个可组合模块,开发者只需编写 YAML 配置即可定义一个完整的 SkillPrompt + Tool + 质量门禁),框架自动完成 ReAct 推理循环、模型路由降级、产出质量检查和标准化输出。 传统方式下,每新增一个 Agent 需要编写子类、处理 LLM 调用、管理工具绑定、校验输出质量。AgentKit 将这些能力标准化为可组合模块,开发者只需编写 YAML 配置即可定义一个完整的 SkillPrompt + Tool + 质量门禁),框架自动完成 ReAct 推理循环、模型路由降级、产出质量检查和标准化输出。
核心定位: 核心定位:
- **配置驱动** -- YAML 定义 Skill无需写 Agent 子类 - **配置驱动** -- YAML 定义 Skill无需写 Agent 子类
- **生产就绪** -- 内置质量门禁、模型降级、用量统计 - **生产就绪** -- 内置质量门禁、模型降级、用量统计
- **三种使用** -- Python 库引用、CLI 聊天、Web GUI 界面 - **四种使用** -- Python 库引用、CLI 聊天、Web GUI、桌面客户端
- **记忆持久化** -- SOUL/USER/MEMORY/DAILY 四层记忆,写入即生效
- **自进化** -- 反思驱动 Soul 更新,经验积累与陷阱检测
- **工具丰富** -- 内置 Shell、搜索、爬虫、记忆等工具支持 MCP 扩展 - **工具丰富** -- 内置 Shell、搜索、爬虫、记忆等工具支持 MCP 扩展
- **Pipeline 编排** -- 多 Agent 协同、Saga 补偿、动态流水线 - **Pipeline 编排** -- 多 Agent 协同、Saga 补偿、动态流水线
@ -34,15 +36,53 @@ Skill = SkillConfig + 绑定 Tools。一个 Skill 代表一个可执行技能,
两级路由Level 1 关键词匹配(零成本,~0msLevel 2 LLM 分类(回退方案,~200 tokens。自动将用户输入路由到最佳匹配的 Skill。 两级路由Level 1 关键词匹配(零成本,~0msLevel 2 LLM 分类(回退方案,~200 tokens。自动将用户输入路由到最佳匹配的 Skill。
### 5. 产出质量管理 ### 5. 记忆系统
四层持久化记忆,写入即生效(无需重启):
| 层级 | 文件 | 说明 |
|------|------|------|
| 身份 | `SOUL.md` | Agent 身份、性格、做事准则、版本追踪 |
| 用户 | `USER.md` | 用户基本信息和偏好 |
| 笔记 | `MEMORY.md` | Agent 主动记录的重要信息 |
| 日志 | `DAILY/` | 按日期归档的交互摘要 |
- **Section-based CRUD**:每个记忆文件按 `## Section` 组织,支持原子读写
- **容量保护**`trim_to_budget` 按 section 边界裁剪,保护"版本"和"更新历史"
- **即时刷新**MemoryTool 写入后自动触发 `notify_change()`,所有 Agent 的 system_prompt 实时更新
- **RAG 检索**:向量嵌入 + 多源检索器,支持飞书/Confluence 适配器
### 6. 自进化系统
反思驱动的 Agent 自我改进:
- **Reflector** -- 任务完成后自动反思,生成质量评分和改进建议
- **Soul Evolution** -- 累积反思触发阈值后自动更新 SOUL.md版本追踪
- **经验存储** -- 成功/失败经验持久化,陷阱检测避免重复错误
- **Prompt 优化** -- 遗传算法 + A/B 测试自动优化 Prompt
- **路径优化** -- 分析工具调用路径,推荐更优执行策略
### 7. 三层意图路由
CostAwareRouter 三层路由,从零成本到高成本逐层升级:
| Layer | 方法 | 延迟 | Token 消耗 | 说明 |
|-------|------|------|-----------|------|
| 0 | 正则规则 | ~0ms | 0 | 问候/简单对话直接回复 |
| 1 | 启发式分类 | ~0ms | 0 | 关键词 + 模式匹配 |
| 2 | LLM 分类 | ~500ms | ~200 | 回退方案LLM 判断意图 |
路由结果携带 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT`),作为路由层与执行层的架构契约,杜绝硬编码。
### 8. 产出质量管理
四维质量检查必填字段、最低字数、JSON Schema 校验、自定义验证器。检查不通过时自动重试(可配置 max_retries重试时携带质量反馈信息。 四维质量检查必填字段、最低字数、JSON Schema 校验、自定义验证器。检查不通过时自动重试(可配置 max_retries重试时携带质量反馈信息。
### 6. 标准化输出 ### 9. 标准化输出
Schema 验证 + 字段类型归一化str -> int/float/bool+ 元数据附加version、produced_at、quality_score。所有 Skill 产出统一为 StandardOutput 格式。 Schema 验证 + 字段类型归一化str -> int/float/bool+ 元数据附加version、produced_at、quality_score。所有 Skill 产出统一为 StandardOutput 格式。
### 7. 内置工具集 ### 10. 内置工具集
开箱即用的工具插件,覆盖常见 Agent 需求: 开箱即用的工具插件,覆盖常见 Agent 需求:
@ -60,7 +100,7 @@ Schema 验证 + 字段类型归一化str -> int/float/bool+ 元数据附
工具组合:`SequentialChain`(顺序链)、`ParallelFanOut`(并行扇出)、`DynamicSelector`(动态选择)。 工具组合:`SequentialChain`(顺序链)、`ParallelFanOut`(并行扇出)、`DynamicSelector`(动态选择)。
### 8. Pipeline 编排 ### 11. Pipeline 编排
多 Agent 协同编排,支持复杂工作流: 多 Agent 协同编排,支持复杂工作流:
@ -73,73 +113,63 @@ Schema 验证 + 字段类型归一化str -> int/float/bool+ 元数据附
## 架构图 ## 架构图
``` ```
+-------------------+ +-------------------+ ┌──────────────────────────────────────────────────────────────┐
| Web GUI Chat | | CLI Chat | │ 桌面客户端 (Tauri 2.x) │
| (WebSocket) | | (agentkit chat) | │ splash → main窗口 → sidecar进程管理 → 系统托盘 │
+--------+----------+ +--------+----------+ └──────────────────────────┬───────────────────────────────────┘
| |
+----------+----------+ ┌──────────────────────────┼───────────────────────────────────┐
| │ 前端 (Vue 3 + Ant Design Vue) │
+----------v----------+ │ ChatView · EvolutionView · WorkflowView · TerminalView │
| Skill Routing | │ KnowledgeBase · SkillsView · SettingsView · ComputerUse │
| (keyword -> LLM) | └──────────────────────────┼───────────────────────────────────┘
+----------+----------+ │ WebSocket / SSE / HTTP
| ┌──────────────────────────┼───────────────────────────────────┐
matched_skill │ 服务端 (FastAPI + Uvicorn) │
| │ portal.py · chat.py · evolution.py · workflows.py · ... │
+-------------------v-------------------+ │ 17个路由模块 · Agent Pool · Memory Store │
| ConfigDrivenAgent | └──────────────────────────┼───────────────────────────────────┘
| (SkillConfig-driven) |
+-------------------+------------------+ ┌──────────────┼──────────────┐
| │ CostAwareRouter │
+--------------+--------------+ │ Layer 0: 正则规则 (0ms) │
| | │ Layer 1: 启发式分类 (0ms) │
v v │ Layer 2: LLM分类 (~500ms) │
+---------+--------+ +----------+---------+ │ → ExecutionMode 枚举契约 │
| ReActEngine | | Traditional Mode | └──────┬───────────────┬───────┘
| Think->Act->Observe| | llm_generate/ | │ │
+---------+--------+ | tool_call/custom | DIRECT_CHAT │ │ REACT / SKILL_REACT
| +---------------------+ ▼ ▼
v ┌─────────────┐ ┌──────────────────┐
+----------+----------+ │ Direct LLM │ │ ConfigDrivenAgent│
| LLM Gateway | │ (简单对话) │ │ (ReAct Engine) │
| resolve -> chat | └─────────────┘ └────────┬─────────┘
| fallback -> track |
+----------+----------+ ┌────────────────┼────────────────┐
| │ │ │
+------+------+ ▼ ▼ ▼
| | ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
v v │ LLM Gateway │ │ Tool Registry│ │ Memory System│
+-----+----+ +-----+-----+ │ resolve→chat │ │ shell/search │ │ SOUL/USER │
| DashScope | | OpenAI | ... │ fallback→track│ │ crawl/memory │ │ MEMORY/DAILY │
+-----+----+ +-----+-----+ └──────┬───────┘ └──────────────┘ └──────────────┘
|
+----------+----------+ ┌─────────┼─────────┐
| Tool Registry | ▼ ▼ ▼
| shell / search / | ┌─────────┐ ┌────────┐ ┌──────────┐
| crawl / memory / ... | │DashScope│ │ OpenAI │ │ DeepSeek │ ...
+----------+----------+ └─────────┘ └────────┘ └──────────┘
|
v
+----------+----------+
| Quality Gate |
| required_fields |
| min_word_count |
| schema validation |
| custom validator |
+----------+----------+
|
v
+----------+----------+
| OutputStandardizer |
| schema + normalize |
| + metadata |
+----------+----------+
|
v
StandardOutput
``` ```
### 模块分层
| 层级 | 模块 | 说明 |
|------|------|------|
| **API** | `server/` `cli/` | 服务端路由 + 命令行入口 |
| **Service** | `core/` `chat/` `skills/` | Agent 引擎、路由、技能系统 |
| **Data** | `memory/` `session/` `bus/` | 记忆持久化、会话管理、消息总线 |
| **Utility** | `llm/` `tools/` `evolution/` `quality/` `mcp/` | LLM 网关、工具、进化、质量、MCP |
## 快速开始 ## 快速开始
### 安装 ### 安装
@ -372,6 +402,81 @@ if __name__ == "__main__":
python server.py python server.py
``` ```
### Docker 部署
```bash
# 启动完整环境AgentKit + Redis + PostgreSQL + pgvector
docker-compose up -d
# 查看日志
docker-compose logs -f agentkit
# 健康检查
docker-compose exec agentkit agentkit doctor
# 停止
docker-compose down
```
`docker-compose.yaml` 包含三个服务:
| 服务 | 镜像 | 端口 | 说明 |
|------|------|------|------|
| `agentkit` | 自建 (python:3.11-slim) | 8001 | AgentKit 服务端 |
| `redis` | redis:7-alpine | 6379 | 消息总线 + 缓存 |
| `postgres` | pgvector/pgvector:pg15 | 5432 | 语义记忆向量存储 |
### 桌面客户端 (Tauri 2.x)
跨平台桌面应用Rust Shell + Vue 3 前端 + Python Sidecar。
**前置条件**Rust 工具链 + Node.js 18+ + Python 3.11+
```bash
# 1. 构建 Python sidecar
pip install pyinstaller
pyinstaller --onefile --name agentkit-server src/agentkit/__main__.py
# 2. 放置 sidecar带平台后缀
# macOS Apple Silicon:
cp dist/agentkit-server src-tauri/binaries/agentkit-server-aarch64-apple-darwin
# macOS Intel:
cp dist/agentkit-server src-tauri/binaries/agentkit-server-x86_64-apple-darwin
# Linux:
cp dist/agentkit-server src-tauri/binaries/agentkit-server-x86_64-unknown-linux-gnu
# Windows:
copy dist\agentkit-server.exe src-tauri\binaries\agentkit-server-x86_64-pc-windows-msvc.exe
# 3. 构建前端
cd src/agentkit/server/frontend
npm install
npm run build:frontend
# 4. 开发模式(热重载)
npm run tauri dev
# 5. 生产构建
npm run tauri build
# 产物:
# macOS: src-tauri/target/release/bundle/dmg/Fischer AgentKit.dmg
# macOS: src-tauri/target/release/bundle/macos/Fischer AgentKit.app
# Windows: src-tauri/target/release/bundle/msi/
# Linux: src-tauri/target/release/bundle/deb/
```
**架构**
```
Tauri Shell (Rust)
├── 窗口管理splash + main
├── 系统托盘(显示窗口 / 退出)
└── Sidecar 进程管理
└── agentkit-serverPyInstaller 打包的 Python 服务端)
└── Uvicorn + FastAPI (--port 0 动态分配)
```
Tauri 启动时以 `--port 0` 启动 sidecar解析 stdout 获取实际端口,前端通过该端口连接后端。
## 调用方式 ## 调用方式
### Import 模式示例 ### Import 模式示例
@ -830,6 +935,20 @@ ReActEngine 实现 Think -> Act -> Observe 循环:
停止条件LLM 不返回 tool_calls或达到 max_steps。 停止条件LLM 不返回 tool_calls或达到 max_steps。
危险工具确认流:非白名单命令触发 `needs_confirmation`,用户确认后以 `_skip_dangerous_check=True` 重新执行,避免无限循环。
### chat/skill_routing -- CostAwareRouter 三层路由
三层路由从零成本到高成本逐层升级:
| Layer | 组件 | 延迟 | Token |
|-------|------|------|-------|
| 0 | `RegexRules` | ~0ms | 0 |
| 1 | `HeuristicClassifier` | ~0ms | 0 |
| 2 | `LLMClassifier` | ~500ms | ~200 |
路由结果包含 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT`),作为路由层与执行层的架构契约。`complexity` 评分使用 `if is not None` 判断,避免 `0.0 or default` 误覆盖。
### llm/gateway -- LLM Gateway ### llm/gateway -- LLM Gateway
统一 LLM 调用入口,核心能力: 统一 LLM 调用入口,核心能力:
@ -851,16 +970,9 @@ Skill = SkillConfig + 绑定 Tools。SkillConfig 扩展自 AgentConfig新增
SkillRegistry 管理 Skill 的注册、发现、更新。 SkillRegistry 管理 Skill 的注册、发现、更新。
### router/intent -- 意图路由 ### router/intent -- 意图路由(已升级为 chat/skill_routing
两级路由策略: 原两级路由已升级为 CostAwareRouter 三层路由(详见 chat/skill_routing 模块详解)。
| Level | 方法 | 延迟 | Token 消耗 | 置信度 |
|-------|------|------|-----------|--------|
| 1 | 关键词匹配 | ~0ms | 0 | 1.0 |
| 2 | LLM 分类 | ~500ms | ~200 | 0.0-1.0 |
关键词匹配对 input_data 中所有字符串值包括嵌套进行大小写不敏感匹配。LLM 分类构建 prompt 列出所有 Skill 的名称、描述和示例,让 LLM 返回 JSON 格式的匹配结果。
### quality/gate -- 产出质量管理 ### quality/gate -- 产出质量管理
@ -909,6 +1021,62 @@ v2 增强:接受 SkillConfig 时自动创建 Skill 并启用 ReAct 模式Qu
运行时 Agent 实例池,管理 Agent 的创建、获取、删除。支持从已注册的 Skill 创建 Agent。 运行时 Agent 实例池,管理 Agent 的创建、获取、删除。支持从已注册的 Skill 创建 Agent。
### memory -- 记忆系统
四层持久化记忆,基于 Markdown section 的 CRUD 操作:
- **MemoryFile** -- 单个记忆文件SOUL/USER/MEMORY/DAILY支持 `read_section`/`write_section`/`add_section`/`remove_section`
- **MemoryStore** -- 管理所有记忆文件,`build_system_prompt()` 将记忆注入 system_prompt
- **即时刷新** -- `notify_change()` 回调机制MemoryTool 写入后自动刷新所有 Agent 的 system_prompt
- **容量保护** -- `trim_to_budget` 按 section 边界裁剪,`protected_sections` 确保版本/更新历史不被裁剪
- **原子写入** -- `_update_soul` 在内存中构建完整内容后一次性写入,避免先删后加导致数据丢失
- **RAG** -- 向量嵌入 + 多源检索器,支持飞书/Confluence 适配器
记忆注入格式:
```
<agent-identity>
## 身份
我是AK一个专业的 AI 助手。
</agent-identity>
<user-profile>
## 基本信息
- 姓名:张三
</user-profile>
<agent-notes>
## 重要事项
...
</agent-notes>
<recent-activity>
## 2026-06-14
...
</recent-activity>
[base_prompt 行为指令]
```
### evolution -- 自进化系统
反思驱动的 Agent 自我改进:
- **Reflector** -- 任务完成后自动反思,生成 quality_score 和 suggestions
- **evolve_soul** -- 累积反思达到阈值后触发 SOUL.md 更新,汇总所有反思建议(去重取 top 5
- **ExperienceStore** -- 成功/失败经验持久化
- **PitfallDetector** -- 陷阱检测,避免重复错误
- **PromptOptimizer** -- 遗传算法优化 Prompt
- **PathOptimizer** -- 分析工具调用路径,推荐更优策略
- **ABTester** -- A/B 测试验证优化效果
### bus -- 消息总线
进程内/跨进程消息传递:
- **MemoryBus** -- 进程内同步消息总线,集成 CascadeDetector 和 AlignmentGuard 进行消息质量管控
- **RedisBus** -- 基于 Redis Pub/Sub 的分布式消息总线,支持多实例部署
### server -- FastAPI Server ### server -- FastAPI Server
独立部署模式,提供 RESTful API 和 Web GUI 独立部署模式,提供 RESTful API 和 Web GUI
@ -927,16 +1095,28 @@ v2 增强:接受 SkillConfig 时自动创建 Skill 并启用 ReAct 模式Qu
| `/api/v1/llm/usage` | GET | 查询 LLM 用量 | | `/api/v1/llm/usage` | GET | 查询 LLM 用量 |
| `/api/v1/health` | GET | 健康检查 | | `/api/v1/health` | GET | 健康检查 |
### Web GUI 聊天界面 ### Web GUI
通过 `agentkit gui` 启动,特性 通过 `agentkit gui` 启动,8 个页面视图
- **实时对话** -- WebSocket 流式传输,逐 token 显示 | 视图 | 说明 |
- **Markdown 渲染** -- 自动检测并渲染标题、列表、代码块、表格等 |------|------|
- **工具确认卡片** -- 危险命令(如 `rm`)执行前弹出确认卡片,用户批准后才执行 | ChatView | 实时对话WebSocket 流式传输,代码高亮,工具调用卡片 |
- **Loading 动画** -- 等待 AI 响应时显示思考动画 | EvolutionView | 自进化仪表盘,任务/经验/指标/优化面板 |
- **Skill 路由** -- 输入 `@skill_name:` 前缀可指定使用特定 Skill | WorkflowView | 工作流编辑器Vue Flow 可视化编排 |
- **会话管理** -- 多会话并行,历史记录持久化 | TerminalView | 终端模拟器PTY 会话 |
| KnowledgeBaseView | 知识库管理,文档上传/搜索/源配置 |
| SkillsView | 技能市场,技能卡片/详情 |
| SettingsView | 系统设置 |
| ComputerUseView | 计算机使用,桌面操控 |
### 桌面客户端 (Tauri 2.x)
跨平台桌面应用,架构:
- **Rust Shell** -- 窗口管理splash + main、系统托盘、单实例锁
- **Sidecar 管理** -- 以 `--port 0` 启动 Python 后端,解析 stdout 获取动态端口
- **前端** -- Vue 3 SPA通过动态端口连接后端
### orchestrator -- Pipeline 编排 ### orchestrator -- Pipeline 编排
@ -1087,26 +1267,70 @@ async def generate_content(keyword: str, brand: str) -> dict:
## 开发指南 ## 开发指南
### 运行测试 ### 项目结构
```
fischer-agentkit/
├── src/agentkit/ # Python 后端
│ ├── bus/ # 消息总线MemoryBus + RedisBus
│ ├── chat/ # 聊天路由CostAwareRouter + ExecutionMode
│ ├── cli/ # CLI 命令Typer
│ ├── core/ # 核心引擎ReAct/Reflexion/ReWOO/ConfigDriven
│ ├── evaluation/ # 评估系统RAGAS
│ ├── evolution/ # 自进化(反思/优化/陷阱检测/A/B测试
│ ├── llm/ # LLM 网关(多供应商适配)
│ ├── marketplace/ # 多Agent市场拍卖/财富)
│ ├── mcp/ # MCP 协议
│ ├── memory/ # 记忆系统SOUL/USER/MEMORY/DAILY + RAG
│ ├── orchestrator/ # Pipeline 编排Saga/动态流水线)
│ ├── org/ # 组织发现
│ ├── prompts/ # Prompt 模板
│ ├── quality/ # 质量保障(对齐/级联检测/门控)
│ ├── router/ # 意图路由
│ ├── server/ # FastAPI 服务端 + Vue 3 前端
│ ├── session/ # 会话管理
│ ├── skills/ # 技能系统
│ ├── telemetry/ # 遥测追踪
│ ├── tools/ # 工具插件21个
│ └── utils/ # 工具函数
├── src-tauri/ # Tauri 桌面客户端Rust
│ ├── src/ # main.rs + lib.rs + sidecar.rs + tray.rs
│ └── binaries/ # Sidecar 二进制(平台相关)
├── configs/ # 配置文件(技能/LLM/GEO
├── tests/ # 测试unit + integration
├── docs/ # 文档brainstorms + plans
├── Dockerfile # Docker 镜像构建
├── docker-compose.yaml # 生产编排
└── pyproject.toml # Python 项目配置
```
### 常用开发命令
```bash ```bash
# 安装开发依赖 # 后端
pip install -e ".[dev]" pip install -e ".[dev]" # 安装开发依赖
agentkit gui --port 8002 # 启动 Web GUI
agentkit serve --port 8001 # 启动 API 服务器
pytest # 运行全部测试
pytest -m "not integration" # 仅单元测试
pytest --cov=agentkit # 覆盖率
ruff check src/ && ruff format src/ # 代码检查和格式化
# 运行全部测试 # 前端
pytest cd src/agentkit/server/frontend
npm install # 安装依赖
npm run dev # Vite 开发服务器
npm run build:frontend # 生产构建
npm run typecheck # TypeScript 类型检查
# 运行单元测试(跳过集成测试) # 桌面客户端
pytest -m "not integration" cd src/agentkit/server/frontend
npm run tauri dev # Tauri 开发模式
npm run tauri build # Tauri 生产构建
# 运行并查看覆盖率 # Docker
pytest --cov=agentkit --cov-report=term-missing docker-compose up -d # 启动完整环境
docker-compose logs -f agentkit # 查看日志
# 仅运行 Redis 相关测试
pytest -m redis
# 仅运行 PostgreSQL 相关测试
pytest -m postgres
``` ```
### 添加新 Skill ### 添加新 Skill

View File

@ -9,10 +9,11 @@ AgentKit 是一个**统一 Agent 开发框架**,核心能力:
| **ReAct 推理引擎** | Think → Act → Observe 循环LLM 自主选择工具、决定何时输出 | | **ReAct 推理引擎** | Think → Act → Observe 循环LLM 自主选择工具、决定何时输出 |
| **LLM Gateway** | 统一 LLM 调用入口,管理 API Key、模型路由、降级策略、用量统计 | | **LLM Gateway** | 统一 LLM 调用入口,管理 API Key、模型路由、降级策略、用量统计 |
| **Skill 系统** | YAML 配置定义技能Prompt + Tool + 质量门禁),无需写代码 | | **Skill 系统** | YAML 配置定义技能Prompt + Tool + 质量门禁),无需写代码 |
| **意图路由** | 关键词匹配(零成本)+ LLM 分类(兜底),自动路由到最佳 Skill | | **意图路由** | CostAwareRouter 三层路由:正则规则(0ms) → 启发式分类(0ms) → LLM分类(~500ms)ExecutionMode 枚举契约 |
| **产出质量管理** | 必填字段、最低字数、Schema 校验、自定义验证器,不通过自动重试 | | **产出质量管理** | 必填字段、最低字数、Schema 校验、自定义验证器,不通过自动重试 |
| **标准化输出** | Schema 验证 + 类型归一化 + 元数据附加,所有 Skill 产出格式统一 | | **标准化输出** | Schema 验证 + 类型归一化 + 元数据附加,所有 Skill 产出格式统一 |
| **记忆系统** | 语义记忆pgvector+ 情景记忆Redis+ 工作记忆 | | **记忆系统** | SOUL/USER/MEMORY/DAILY 四层持久化记忆,写入即生效 + RAG 检索 |
| **自进化** | 反思驱动 Soul 更新,经验积累与陷阱检测 |
| **MCP 协议** | 支持 Model Context Protocol可连接外部工具服务器 | | **MCP 协议** | 支持 Model Context Protocol可连接外部工具服务器 |
| **CLI 工具** | `agentkit` 命令行,支持 init/serve/task/skill/pair/doctor/usage | | **CLI 工具** | `agentkit` 命令行,支持 init/serve/task/skill/pair/doctor/usage |
| **独立部署** | FastAPI Server + Docker业务系统通过 HTTP API 调用 | | **独立部署** | FastAPI Server + Docker业务系统通过 HTTP API 调用 |

View File

@ -0,0 +1,765 @@
---
date: 2026-06-09
deepened: 2026-06-09
status: active
origin: docs/brainstorms/2026-06-09-agentkit-capability-matrix/requirements.md
---
## Summary
基于内核+插件架构,为 AgentKit 构建 7 项企业级能力自主闭环执行引擎内核、Skill 标准规范升级、知识库与 RAG 增强、智能终端交互、Computer Use 集成、可视化 Workflow、自进化经验积累。分 6 个阶段交付,先建内核再逐步接入插件能力,最终通过端到端企业场景验证。
## Problem Frame
企业想用 AI Agent 但不会落地。AgentKit 已具备 Skill/ReAct/Pipeline/RAG/Shell 等基础能力但缺少自主闭环执行、Computer Use、可视化 Workflow、任务经验积累等高级能力且各能力之间缺乏统一调度中枢。需要以自主闭环执行引擎为内核其他能力作为 Skill 插件接入,形成企业综合门户。
---
## Key Technical Decisions
**KTD-1. GoalPlanner 包装 Orchestrator._decompose_task(),而非独立实现**
现有 `Orchestrator._decompose_task()``src/agentkit/core/orchestrator.py`)已实现 LLM 驱动任务分解 + `_build_parallel_groups()` 并行组构建 + 单任务 fallback。GoalPlanner 作为前置增强层包装此方法:先通过结构化目标分解(规则/模板)生成初始方案,再让 LLM 细化调整;如果 GoalPlanner 返回了有效方案可直接跳过 LLM 调用。保持 fallback 机制不变确保向后兼容。PlanExecutor 同理扩展 `Orchestrator.execute()`,注入执行策略,保留默认 `_execute_plan` 作为 fallback。
**KTD-2. 经验库使用 PostgreSQL + pgvector 混合存储**
结构化字段task_type, outcome, duration存 PostgreSQL 表,语义向量存 pgvector全文检索用 tsvector。与现有 `EpisodicMemory` 共享基础设施无需引入新依赖ChromaDB/FAISS
**KTD-3. Computer Use 作为 Tool 插件集成**
新增 `ComputerUseTool`(继承 `Tool` 基类),内部调用 Anthropic Computer Use API。与 `ShellTool` 形成降级链ComputerUseTool 失败 → ShellTool/API → AskHumanTool。符合 Skill 插件架构,无需修改核心引擎。
**KTD-4. Vue Flow 作为 Workflow 可视化编辑器**
Vue Flow 是 Vue3 原生组件,轻量(~60KB gzipDAG 天然支持,自定义节点丰富。与现有 `PipelineEngine` 的 DAG 模型完美对应。Vue Flow JSON 可直接映射为 `PipelineStage` 配置。
**KTD-5. 智能终端增强现有 ShellTool**
在现有 `ShellTool` 基础上增加 `TerminalSession`(会话状态管理)和 `PTYSession`(交互式命令支持),而非替换。保持向后兼容,新功能通过配置开关启用。
**KTD-6. 前端独立 SPA通过 API 与后端交互**
Vue3 前端作为独立 SPA通过 FastAPI REST API + WebSocket 与后端通信。前端代码放在 `src/agentkit/server/frontend/` 目录,构建产物输出到 `src/agentkit/server/static/`。与现有 Server 架构无缝集成。
**KTD-7. KBAdapter 使用独立 KnowledgeBase 协议,不直接实现 Memory 接口**
Memory 接口的 `retrieve(key)`/`delete(key)` 是精确 key-value 语义,与知识库的语义检索模型不匹配。创建独立的 `KnowledgeBase` 协议(`ingest`/`query`/`delete_by_id`/`list_sources`/`health_check``SemanticMemory` 内部组合使用 `KnowledgeBase`,而非强制适配。这避免了 `retrieve(key)` 的人为映射和性能损失。
**KTD-8. PipelineStage 扩展 type/config 字段WorkflowDefinition 继承 Pipeline**
现有 `PipelineStage` 没有 `type` 字段,`action` 隐含类型语义。新增 `type: str = "skill"`(默认值不破坏现有数据)和 `config: dict = {}`(类型特定配置)。`WorkflowDefinition` 继承 `Pipeline`(而非 `PipelineStage`),新增 `workflow_id`/`triggers`/`variables_schema`/`output_schema` 字段。
---
## High-Level Technical Design
```mermaid
flowchart TB
subgraph Portal["企业 Agent 门户 (Vue3 SPA)"]
Chat["统一对话界面"]
WF["Workflow 编辑器"]
KB["知识库管理"]
Term["智能终端"]
CU["Computer Use"]
EVO["自进化仪表盘"]
end
subgraph Kernel["自主闭环执行引擎"]
GP["GoalPlanner<br/>目标→计划"]
PE["PlanExecutor<br/>并行执行"]
PC["PlanChecker<br/>检查+复盘"]
end
subgraph Plugins["Skill 插件"]
RAG["RAG Skill<br/>本地+外部"]
ST["TerminalSkill<br/>智能终端"]
CUT["ComputerUseSkill<br/>UI自动化"]
end
subgraph Foundation["现有基础设施"]
RE["ReActEngine"]
ORC["Orchestrator"]
PL["PipelineEngine"]
SR["SkillRegistry"]
TR["ToolRegistry"]
MR["MemoryRetriever"]
ES["ExperienceStore<br/>(新增)"]
end
Chat --> GP
GP --> PE
PE --> PC
PC --> ES
PE --> RAG
PE --> ST
PE --> CUT
RAG --> MR
ST --> TR
CUT --> TR
PE --> ORC
PE --> PL
GP --> SR
WF --> PL
EVO --> ES
```
---
## Requirements Traceability
| Origin R-ID | Plan Coverage |
|---|---|
| R1-R6 | U1 (GoalPlanner), U2 (PlanExecutor), U3 (PlanChecker) |
| R7-R10 | U4 (Skill 标准规范升级) |
| R11-R14 | U9 (本地文档摄取), U10 (外部知识库适配器), U11 (多源 RAG) |
| R15-R18 | U8 (智能终端交互) |
| R19-R22 | U12 (Computer Use 集成) |
| R24-R28 | U14 (Workflow 可视化编辑器) |
| R29-R33 | U5 (ExperienceStore), U6 (PitfallDetector), U7 (PathOptimizer) |
| R34 | U13a (对话界面与路由) |
| R35 | U13b (管理页面) |
| R36 | U13c (自进化仪表盘) |
---
## Implementation Units
### Phase 1: 核心内核与经验基础
### U1. GoalPlanner — 目标分析与计划生成
**Goal:** 用户给定自然语言目标后,自动生成结构化执行计划,包含任务拆解、依赖关系、并行度识别。
**Requirements:** R1, R2
**Dependencies:** 无
**Files:**
- `src/agentkit/core/goal_planner.py` (新建)
- `src/agentkit/core/plan_schema.py` (新建)
- `src/agentkit/core/orchestrator.py` (修改 — 增加 GoalPlanner 分支)
- `tests/unit/core/test_goal_planner.py` (新建)
**Approach:**
- 新增 `GoalPlanner` 类,包装 `Orchestrator._decompose_task()` 作为前置增强层(见 KTD-1
- `GoalPlanner.generate_plan(goal, context, available_skills)``ExecutionPlan`
- 执行流程GoalPlanner 先通过结构化目标分解(规则/模板)生成初始方案 → 如果有效则跳过 LLM 调用 → 否则将初始方案作为上下文注入 `_decompose_task()` 的 LLM prompt → LLM 细化调整
- `ExecutionPlan` 包含 `PlanStep` 列表,每个 step 有 name/description/dependencies/parallel_group/required_skills
- 依赖识别:复用 `_build_parallel_groups()` 的拓扑排序逻辑
- 人工确认:`ExecutionPlan` 序列化为可读格式,通过 `AskHumanTool` 请求确认/修改
- 修改 `Orchestrator._decompose_task()`:增加 `if self._goal_planner:` 分支
**Patterns to follow:** `Orchestrator._decompose_task()` 的 LLM 调用模式(`src/agentkit/core/orchestrator.py`
**Test scenarios:**
- 简单目标(单步任务)→ 生成单步计划
- 复杂目标(多步任务)→ 生成多步计划,正确识别依赖和并行关系
- 无可用 Skill 的目标 → 计划标注能力缺口,请求人工介入
- 用户修改计划 → 更新后重新验证依赖关系
- Covers AE1: 3 个竞品调研自动识别为并行步骤
**Verification:** `GoalPlanner` 能将"调研 3 个竞品 SEO 策略并生成对比报告"分解为包含并行步骤的结构化计划
### U2. PlanExecutor — 计划执行与并行调度
**Goal:** 按确认后的 ExecutionPlan 执行,自动并行调度无依赖步骤,支持执行中调整。
**Requirements:** R3, R5
**Dependencies:** U1
**Files:**
- `src/agentkit/core/plan_executor.py` (新建)
- `tests/unit/core/test_plan_executor.py` (新建)
**Approach:**
- 新增 `PlanExecutor` 类,扩展 `Orchestrator.execute()` 的执行策略(见 KTD-1
- 在 `Orchestrator.execute()` 中注入 PlanExecutor`if self._plan_executor: await self._plan_executor.execute(plan, task) else: await self._execute_plan(plan, task)`
- 按 `parallel_group` 分组执行:复用 `_execute_plan()``asyncio.gather` 并行模式
- 执行状态机PENDING → RUNNING → COMPLETED/FAILED → CHECKING → RETRYING
- 失败处理:检查步骤失败时,根据失败类型决定重试/调整计划/请求人工
- 与 `AgentPool` 集成:每个步骤通过 `AgentPool.create_agent_from_skill()` 创建 Agent 执行
- 兼容现有的 `subtask_results` 累积模式
**Patterns to follow:** `Orchestrator.execute()` 的并行组执行模式(`src/agentkit/core/orchestrator.py`
**Test scenarios:**
- 3 个并行步骤全部成功 → 结果正确汇总
- 并行步骤中 1 个失败 → 其他步骤继续,失败步骤进入检查
- 步骤失败后自动重试成功
- 步骤失败后调整计划(跳过/替换)继续执行
- 执行中请求人工介入后继续
**Verification:** PlanExecutor 能并行执行无依赖步骤,失败步骤正确处理
### U3. PlanChecker — 检查与复盘闭环
**Goal:** 每步执行后检查产出质量,全部完成后复盘总结并写入经验库。
**Requirements:** R4, R5, R6
**Dependencies:** U2, U5
**Files:**
- `src/agentkit/core/plan_checker.py` (新建)
- `tests/unit/core/test_plan_checker.py` (新建)
**Approach:**
- 新增 `PlanChecker` 类,集成 `QualityGate``LLMReflector`
- 检查环节:每步完成后,`QualityGate` 验证产出 + LLM 评估是否达标
- 复盘环节:全部完成后,`LLMReflector` 生成复盘报告(成功路径、失败原因、耗时分布)
- 经验写入:复盘结果写入 `ExperienceStore`U5
- 闭环:检查不通过 → 触发重试或计划调整(与 U2 的失败处理联动)
**Patterns to follow:** `EvolutionMixin.evolve_after_task()` 的反思-优化流程(`src/agentkit/evolution/lifecycle.py`
**Test scenarios:**
- 所有步骤通过检查 → 生成复盘报告,经验写入 ExperienceStore
- 某步骤检查不通过 → 触发重试
- 重试后仍不通过 → 请求人工介入
- 复盘报告包含成功路径和失败原因
- Covers AE3: 错误经验写入后,后续任务能检索到避坑预警
**Verification:** 完整的 分析→计划→执行→检查→复盘→总结 闭环可运行
### U4. Skill 标准规范升级
**Goal:** 定义 Skill 标准接口规范,支持动态注册、版本管理、能力查询,确保 7 项能力以 Skill 插件形式接入。
**Requirements:** R7, R8, R9, R10
**Dependencies:** 无
**Files:**
- `src/agentkit/skills/base.py` (修改)
- `src/agentkit/skills/registry.py` (修改)
- `src/agentkit/skills/loader.py` (修改)
- `src/agentkit/skills/schema.py` (新建)
- `tests/unit/skills/test_skill_registry_v2.py` (新建)
**Approach:**
- 扩展 `SkillConfig`:新增 `version`(语义版本)、`dependencies`(依赖的 Skill/Tool 声明)、`capabilities`(能力标签,用于查询)
- 新增 `SkillSpec` schema定义 Skill 的标准接口规范(元数据、输入输出 Schema、依赖声明、质量门禁配置
- 增强 `SkillRegistry`:新增 `query_by_capability()`(按能力标签查询)、`get_versions()`(版本管理)、`health_check()`(依赖检查)
- 增强 `SkillLoader`:支持从 Python 包自动发现 Skillentry_points 机制)
- RAG/Terminal/ComputerUse 等新能力均以 Skill 插件形式注册
**Patterns to follow:** 现有 `SkillConfig` 的 Pydantic 模型模式(`src/agentkit/skills/base.py`
**Test scenarios:**
- 注册带版本和依赖的 Skill → 成功注册,依赖检查通过
- 注册缺少依赖的 Skill → 依赖检查失败,返回错误信息
- 按能力标签查询 → 返回匹配的 Skill 列表
- 同名 Skill 注册新版本 → 版本历史保留,默认使用最新版
- 从 Python 包自动发现 Skill → 正确加载
**Verification:** 开发者可用 10-20 行 YAML 定义一个 Skill 并注册到平台SC2
### U5. ExperienceStore — 任务经验库
**Goal:** 存储任务执行经验(成功路径、失败原因、耗时分布),支持按任务类型检索和语义搜索。
**Requirements:** R29, R30, R33
**Dependencies:** U4
**Files:**
- `src/agentkit/evolution/experience_store.py` (新建)
- `src/agentkit/evolution/experience_schema.py` (新建)
- `tests/unit/evolution/test_experience_store.py` (新建)
**Approach:**
- 新增 `ExperienceStore` 类,独立于现有 `EvolutionStore`
- `TaskExperience` 数据模型task_type, goal, steps, outcome, duration, success_rate, failure_reasons, optimization_tips, embedding, created_at
- 存储PostgreSQL 表 + pgvector 向量索引 + tsvector 全文索引
- 检索:精确匹配 task_type + 语义相似度排序 + 时效性衰减
- 与 `EvolutionMixin` 集成:`evolve_after_task()` 完成后自动调用 `ExperienceStore.record_experience()`
- 指标追踪:`EvolutionMetrics` 追踪完成率/耗时/重试率趋势R33
**Patterns to follow:** `EpisodicMemory` 的 pgvector + PostgreSQL 存储模式(`src/agentkit/memory/episodic.py`
**Test scenarios:**
- 记录成功任务经验 → 正确存储,可按 task_type 检索
- 记录失败任务经验 → failure_reasons 正确存储
- 语义检索 → 返回与查询语义相似的经验
- 时效性衰减 → 近期经验权重高于旧经验
- 指标趋势查询 → 返回完成率/耗时/重试率变化
**Verification:** 任务完成后经验自动写入,新任务可检索到相关经验
---
### Phase 2: 智能终端与自进化增强
### U6. PitfallDetector — 避坑预警
**Goal:** 新任务启动时检索错误经验,匹配当前计划步骤,自动预警。
**Requirements:** R32
**Dependencies:** U5
**Files:**
- `src/agentkit/evolution/pitfall_detector.py` (新建)
- `tests/unit/evolution/test_pitfall_detector.py` (新建)
**Approach:**
- 新增 `PitfallDetector`
- `check_pitfalls(task_type, planned_steps)``list[PitfallWarning]`
- 匹配逻辑:检索同类任务的失败经验,将失败步骤与当前计划步骤进行语义匹配
- 预警级别HIGH历史高失败率步骤、MEDIUM有失败记录但频率低、LOW仅提示
- 集成到 `GoalPlanner`:计划生成后可选调用 `PitfallDetector`预警信息附加到计划中GoalPlanner 在 Phase 1 已可独立运行PitfallDetector 是 Phase 2 的可选增强)
**Patterns to follow:** `RuleBasedReflector` 的规则匹配模式(`src/agentkit/evolution/reflector.py`
**Test scenarios:**
- 计划包含历史高失败率步骤 → 返回 HIGH 级别预警
- 计划无历史失败记录 → 返回空列表
- 多个步骤有风险 → 按严重程度排序返回
- Covers AE3: "调用 X 系统 API 在高峰期超时率 60%" → 新任务调用时自动预警
**Verification:** 新任务计划包含历史失败步骤时,自动返回避坑预警
### U7. PathOptimizer — 执行路径优化
**Goal:** 发现更优执行路径时自动更新经验库中的推荐路径。
**Requirements:** R31
**Dependencies:** U5
**Files:**
- `src/agentkit/evolution/path_optimizer.py` (新建)
- `tests/unit/evolution/test_path_optimizer.py` (新建)
**Approach:**
- 新增 `PathOptimizer`
- `ExecutionPath` 数据模型steps, total_duration, success_rate, sample_count
- 对比逻辑:新路径与现有最优路径比较(综合耗时和成功率),决定是否更新
- 更新策略:新路径成功率更高或同成功率但耗时更短 → 更新推荐路径
- 集成到 `PlanChecker`:复盘时可选调用 `PathOptimizer.evaluate_and_update()`PlanChecker 在 Phase 1 已可独立运行PathOptimizer 是 Phase 2 的可选增强)
**Patterns to follow:** `ABTester` 的统计比较模式(`src/agentkit/evolution/ab_tester.py`
**Test scenarios:**
- 新路径耗时更短 → 更新推荐路径
- 新路径成功率更高 → 更新推荐路径
- 新路径无明显优势 → 保留现有推荐路径
- 样本量不足 → 不更新,记录待观察
**Verification:** 同类任务执行多次后,推荐路径自动优化
### U8. TerminalSession — 智能终端交互
**Goal:** 增强现有 ShellTool支持会话状态维护、交互式命令、输出理解。
**Requirements:** R15, R16, R17, R18
**Dependencies:** U4
**Files:**
- `src/agentkit/tools/terminal_session.py` (新建)
- `src/agentkit/tools/pty_session.py` (新建)
- `src/agentkit/tools/shell.py` (修改)
- `src/agentkit/tools/output_parser.py` (新建)
- `tests/unit/tools/test_terminal_session.py` (新建)
- `tests/unit/tools/test_pty_session.py` (新建)
**Approach:**
- 新增 `TerminalSession` 类:维护 cwd/env/history跨命令保持状态
- 新增 `PTYSession` 类:基于 `pexpect``asyncio` + `os.openpty()` 实现伪终端,支持交互式命令
- 增强 `ShellTool`:新增 `session_id` 参数,指定会话执行;无 session_id 时保持现有行为(向后兼容)
- 新增 `OutputParser`:结构化解析命令输出(错误类型、退出码含义、可操作建议)
- 安全控制:危险操作通过 `AskHumanTool` 请求确认,所有操作记录审计日志
**Patterns to follow:** 现有 `ShellTool` 的白名单 + 危险命令拦截模式(`src/agentkit/tools/shell.py`
**Test scenarios:**
- 跨命令保持 cwd → cd 后执行 pwd 返回正确目录
- 跨命令保持 env → export 后执行 echo 返回正确值
- 交互式命令自动应答 → 命令等待输入时自动提供
- 危险命令需确认 → rm 命令触发 AskHumanTool
- 输出解析 → 错误输出结构化为错误类型+建议
- 无 session_id 时保持现有行为
**Verification:** Agent 能在终端会话中跨命令保持状态,处理交互式命令
---
### Phase 3: 知识库与 RAG 增强
### U9. LocalDocumentIngestion — 本地文档摄取
**Goal:** 支持上传文档PDF/Word/Markdown 等),自动分块、向量化、索引。
**Requirements:** R11
**Dependencies:** U4
**Files:**
- `src/agentkit/memory/document_loader.py` (新建)
- `src/agentkit/memory/local_rag.py` (新建)
- `src/agentkit/memory/chunking.py` (新建)
- `tests/unit/memory/test_document_loader.py` (新建)
- `tests/unit/memory/test_local_rag.py` (新建)
**Approach:**
- 新增 `DocumentLoader`:支持 PDFPyMuPDF/pdfplumber、Wordpython-docx、Markdownmistune、HTMLBeautifulSoup、纯文本
- 新增 `LocalRAGService`:实现 `KnowledgeBase` 协议(见 KTD-7使用 pgvector 存储 + 检索(复用 `EpisodicMemory` 的 pgvector 基础设施)
- 分块策略:复用 `ContextualChunker``src/agentkit/memory/contextual_retrieval.py`),新增按文档结构的分块(按标题/段落)
- 嵌入:复用 `OpenAIEmbedder``src/agentkit/memory/embedder.py`
- 摄取 Pipeline上传 → 解析 → 分块 → 嵌入 → 写入 pgvector
**Patterns to follow:** `HttpRAGService` 的 HTTP 客户端模式(`src/agentkit/memory/http_rag.py``KnowledgeBase` 协议KTD-7
**Test scenarios:**
- 上传 PDF → 正确解析、分块、向量化、可检索
- 上传 Markdown → 按标题结构分块
- 上传 Word → 正确解析文本和表格
- 检索上传的文档 → 返回相关内容+来源追溯
- 大文件分块 → 块大小在配置范围内
**Verification:** 上传企业文档后可通过 RAG 检索到相关内容
### U10. ExternalKBAdapters — 外部知识库适配器
**Goal:** 对接飞书知识库、Confluence、企业 Wiki 等外部知识库,统一检索接口。
**Requirements:** R12
**Dependencies:** U4
**Files:**
- `src/agentkit/memory/knowledge_base.py` (新建 — KnowledgeBase 协议定义)
- `src/agentkit/memory/adapters/base.py` (新建)
- `src/agentkit/memory/adapters/feishu.py` (新建)
- `src/agentkit/memory/adapters/confluence.py` (新建)
- `src/agentkit/memory/adapters/generic_http.py` (新建)
- `tests/unit/memory/test_adapters.py` (新建)
**Approach:**
- 定义独立 `KnowledgeBase` 协议(见 KTD-7`ingest(docs)`/`query(text, top_k)`/`delete_by_id(id)`/`list_sources()`/`health_check()`
- 定义 `KBAdapter` 抽象基类实现 `KnowledgeBase` 协议:`search()`, `get_document()`, `list_sources()`, `health_check()`
- `FeishuKBAdapter`:对接飞书知识库 API
- `ConfluenceAdapter`:对接 Confluence REST API
- `GenericHTTPAdapter`:通用 HTTP 适配器,配置 API endpoint + auth 即可对接任意 HTTP 知识库
- 所有适配器实现 `KnowledgeBase` 协议(非 `Memory` 接口),可被 `MultiSourceRetriever` 统一调度
- `SemanticMemory` 内部组合使用 `KnowledgeBase`,而非强制适配 `Memory``retrieve(key)` 语义
**Patterns to follow:** `HttpRAGService` 的 HTTP 客户端模式(`src/agentkit/memory/http_rag.py`
**Test scenarios:**
- FeishuKBAdapter 检索 → 返回飞书知识库内容
- ConfluenceAdapter 检索 → 返回 Confluence 页面内容
- GenericHTTPAdapter 检索 → 返回配置的 HTTP API 内容
- 适配器健康检查 → 正确报告连接状态
- 认证失败 → 返回明确错误信息
**Verification:** 可通过统一接口检索飞书知识库和 Confluence 内容
### U11. MultiSourceRAG — 多源 RAG 与信息源指定
**Goal:** 用户可在任务级别指定信息源,支持多源混合检索,结果包含来源追溯。
**Requirements:** R13, R14
**Dependencies:** U9, U10
**Files:**
- `src/agentkit/memory/multi_source_retriever.py` (新建)
- `src/agentkit/memory/retriever.py` (修改)
- `tests/unit/memory/test_multi_source_rag.py` (新建)
**Approach:**
- 新增 `MultiSourceRetriever`:管理多个 `KnowledgeBase` 协议实现LocalRAGService、各 KBAdapter、HttpRAGService 适配器)
- 信息源指定:`search(query, sources=["feishu", "local:合规文档"])` → 仅从指定源检索
- 混合检索:并行查询多个源,按权重融合排序
- 来源追溯:每个检索结果附带 `source_id` + `document_title` + `chunk_location`
- 集成到 `MemoryRetriever`:新增 `sources` 参数,传递给 `MultiSourceRetriever`
- 所有 RAG 源统一实现 `KnowledgeBase` 协议KTD-7`MultiSourceRetriever` 不再区分 Memory 和 KBAdapter 接口
**Patterns to follow:** `MemoryRetriever` 的混合检索模式(`src/agentkit/memory/retriever.py`
**Test scenarios:**
- 指定单个信息源 → 仅从该源检索
- 指定多个信息源 → 并行检索,结果融合排序
- 不指定信息源 → 从所有可用源检索
- 来源追溯 → 每个结果包含来源信息
- Covers AE4: 指定"合规文档库"和"法务知识库" → 仅从这两个源检索
**Verification:** 用户可指定信息源检索,结果包含来源追溯
---
### Phase 4: Computer Use 集成
### U12. ComputerUseTool — Computer Use 集成
**Goal:** 集成 Anthropic Computer Use API支持截屏识别、UI 操作、降级策略、录制回放。
**Requirements:** R19, R20, R21, R22
**Dependencies:** U4
**Files:**
- `src/agentkit/tools/computer_use.py` (新建)
- `src/agentkit/tools/computer_use_session.py` (新建)
- `src/agentkit/tools/computer_use_recorder.py` (新建)
- `tests/unit/tools/test_computer_use.py` (新建)
**Approach:**
- 新增 `ComputerUseTool`(继承 `Tool`):封装 Anthropic Computer Use API 调用
- 新增 `ComputerUseSession`管理虚拟桌面会话Docker 沙箱),维护操作上下文
- 操作类型screenshot → click → type → scroll → drag → key → wait
- 降级链ComputerUseTool 失败 → 检查是否有 API/CLI 替代 → ShellTool → AskHumanTool
- 新增 `ComputerUseRecorder`:记录每次截屏和操作,支持回放和审核
- 注册为 Skill 插件:`computer_use` Skill可被自主执行引擎调用
**Patterns to follow:** `ShellTool` 的 Tool 基类实现 + 安全控制模式(`src/agentkit/tools/shell.py`
**Test scenarios:**
- 截屏并识别 UI 元素 → 返回可操作区域列表
- 点击指定坐标 → 操作成功
- 输入文本到输入框 → 操作成功
- 多步骤 UI 操作 → 每步根据结果决定下一步
- API 不可用时降级到 ShellTool → 正确降级
- Covers AE2: Computer Use 失败 → 降级到 OA 系统 API
- 操作录制回放 → 可回放操作序列
**Verification:** Computer Use 可在企业 Web 系统上完成基本操作登录、填表、提交SC4
---
### Phase 5: 前端门户
### U13a. Vue3 门户基础 — 对话界面与路由
**Goal:** 搭建 Vue3 SPA 骨架,实现统一对话界面作为 7 项能力的入口IntentRouter 自动路由到对应能力。
**Requirements:** R34
**Dependencies:** U1, U2, U3
**Files:**
- `src/agentkit/server/frontend/` (新建目录)
- `src/agentkit/server/frontend/src/App.vue` (新建)
- `src/agentkit/server/frontend/src/views/ChatView.vue` (新建)
- `src/agentkit/server/frontend/src/components/chat/` (新建目录)
- `src/agentkit/server/frontend/src/stores/` (新建目录)
- `src/agentkit/server/frontend/src/api/` (新建目录)
- `src/agentkit/server/frontend/src/router/index.ts` (新建)
- `src/agentkit/server/routes/portal.py` (新建)
- `tests/unit/server/test_portal_routes.py` (新建)
**Approach:**
- Vue3 + TypeScript + Pinia + Vue Router + Ant Design Vue
- 对话界面为主入口IntentRouter 自动路由到对应能力
- 侧边导航骨架:对话/工作流/知识库/技能/终端/Computer Use/自进化/设置
- API 层:封装 FastAPI REST API + WebSocket 调用
- 新增 Portal API 路由:对话消息、意图路由、能力状态查询
- 构建产物输出到 `src/agentkit/server/static/`,替换现有 `index.html`
**Patterns to follow:** 现有 FastAPI 路由结构(`src/agentkit/server/routes/`
**Test scenarios:**
- 对话界面发送消息 → 正确路由到对应能力
- 侧边导航切换 → 正确加载对应视图
- WebSocket 实时推送 → 执行进度正确显示
- 意图路由 → "帮我查知识库"路由到 RAG 能力
**Verification:** 用户通过对话界面发送消息,系统能正确识别意图并路由到对应能力
### U13b. 管理页面 — 知识库/技能/终端/设置
**Goal:** 实现知识库管理、技能浏览、终端交互、系统设置等管理页面。
**Requirements:** R35
**Dependencies:** U13a, U9, U10, U8
**Files:**
- `src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue` (新建)
- `src/agentkit/server/frontend/src/views/SkillsView.vue` (新建)
- `src/agentkit/server/frontend/src/views/TerminalView.vue` (新建)
- `src/agentkit/server/frontend/src/views/SettingsView.vue` (新建)
- `src/agentkit/server/frontend/src/components/kb/` (新建目录)
- `src/agentkit/server/frontend/src/components/skills/` (新建目录)
- `src/agentkit/server/frontend/src/components/terminal/` (新建目录)
- `src/agentkit/server/routes/kb_management.py` (新建)
- `src/agentkit/server/routes/skill_management.py` (新建)
**Approach:**
- 知识库管理页:文档上传、信息源配置、检索测试、来源追溯展示
- 技能浏览页:已注册 Skill 列表、能力标签筛选、版本管理、依赖检查
- 终端交互页WebSocket 终端会话、命令历史、输出高亮
- 设置页LLM 配置、Skill 配置、知识库连接配置
- 各页面通过 Pinia store 管理状态API 层统一封装
**Patterns to follow:** U13a 的 API 层和 Store 模式
**Test scenarios:**
- 知识库管理页面上传文档 → 文档正确摄取
- 技能浏览页按能力标签筛选 → 返回匹配 Skill
- 终端交互页发送命令 → 输出正确显示
- 设置页面配置 LLM → 配置生效
**Verification:** 管理员可通过管理页面完成知识库管理、技能浏览、终端交互、系统配置
### U13c. 自进化仪表盘 — 经验可视化与指标监控
**Goal:** 实现自进化经验的可视化展示和指标监控,让用户了解 Agent 的进化状态。
**Requirements:** R36
**Dependencies:** U13a, U5, U6, U7
**Files:**
- `src/agentkit/server/frontend/src/views/EvolutionView.vue` (新建)
- `src/agentkit/server/frontend/src/components/evolution/` (新建目录)
- `src/agentkit/server/routes/evolution_dashboard.py` (新建)
- `tests/unit/server/test_evolution_dashboard.py` (新建)
**Approach:**
- 经验时间线:展示任务经验积累历程,成功/失败分布
- 指标趋势图完成率、耗时、重试率变化曲线ECharts/AntV
- 避坑预警面板:当前任务的风险提示,历史失败步骤高亮
- 路径优化记录:推荐路径变更历史,新旧路径对比
- 实时更新WebSocket 推送新经验和指标变化
**Patterns to follow:** U13a 的 WebSocket 实时推送模式
**Test scenarios:**
- 经验时间线展示 → 正确显示成功/失败分布
- 指标趋势图 → 完成率/耗时曲线正确渲染
- 避坑预警面板 → 当前任务风险提示正确显示
- 实时更新 → 新经验写入后仪表盘自动刷新
**Verification:** 用户可通过仪表盘直观了解 Agent 的经验积累和进化状态
### U14. Workflow 可视化编辑器
**Goal:** 基于 Vue Flow 构建可视化拖拽编排界面,支持条件分支、并行执行、人工审批、动态调整。
**Requirements:** R24, R25, R26, R27, R28
**Dependencies:** U13a
**Files:**
- `src/agentkit/server/frontend/src/views/WorkflowEditorView.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/NodePalette.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/SkillNode.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue` (新建)
- `src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue` (新建)
- `src/agentkit/server/frontend/src/utils/workflowSerializer.ts` (新建)
- `src/agentkit/server/routes/workflows.py` (新建)
- `src/agentkit/orchestrator/workflow_schema.py` (新建)
- `tests/unit/server/test_workflow_routes.py` (新建)
**Approach:**
- Vue Flow 画布:拖拽节点构建 Workflow
- 自定义节点类型SkillNode引用已注册 Skill、ConditionNode条件分支、ApprovalNode人工审批、ParallelNode并行分组
- 序列化Vue Flow JSON → `WorkflowDefinition`(继承 `Pipeline`,见 KTD-8其中每个节点映射为扩展后的 `PipelineStage`(含 `type`/`config` 字段)
- 后端 APIWorkflow CRUD + 执行 + 状态查询 + WebSocket 进度推送
- `WorkflowDefinition` 继承 `Pipeline`,新增 `workflow_id`/`triggers`/`variables_schema`/`output_schema` 字段
- `PipelineStage` 扩展 `type: str = "skill"``config: dict = {}`,默认值不破坏现有数据
- 人工审批:执行到 ApprovalNode 时暂停,通知审批人,确认后继续
- 动态调整:执行中可通过 API 增删节点或切换分支
**Patterns to follow:** `PipelineEngine` 的 DAG 执行模式(`src/agentkit/orchestrator/pipeline_engine.py`
**Test scenarios:**
- 拖拽 Skill 节点到画布 → 节点正确渲染
- 连接节点建立依赖 → 边正确创建
- 添加条件分支节点 → 条件配置正确
- 添加人工审批节点 → 审批流程正确
- 序列化为 YAML → 与 PipelineEngine 格式兼容
- Covers AE5: Workflow 包含审批节点 → 执行到该节点暂停等待确认
**Verification:** 用户可通过拖拽构建 Workflow 并执行
---
### Phase 6: 端到端验证
### U15. 端到端企业场景验证
**Goal:** 用"目标驱动的复杂任务"场景端到端验证 7 项能力集成。
**Requirements:** R1-R36 (集成验证)
**Dependencies:** U1-U14 全部完成(含 U13a/U13b/U13c
**Files:**
- `tests/integration/test_goal_driven_scenario.py` (新建)
- `configs/skills/goal_driven_agent.yaml` (新建)
- `configs/pipelines/goal_driven_pipeline.yaml` (新建)
**Approach:**
- 验证场景:"分析竞品 SEO 策略并生成优化方案"
- 覆盖能力:自主闭环(目标→计划→执行→检查→复盘)+ RAG检索企业知识库+ Skill 调度(调用搜索/分析/生成 Skill
- 验证指标:端到端完成率、并行执行效率、经验积累效果
- 补充验证:知识库问答+系统操作场景、Workflow 编排场景
**Test scenarios:**
- 目标驱动场景端到端执行 → 生成完整优化方案
- 并行步骤自动调度 → 3 个竞品调研并行执行
- 经验积累 → 第二次执行同类任务耗时减少
- 知识库指定信息源 → 仅从指定源检索
- Workflow 人工审批 → 执行到审批节点暂停
**Verification:** 一个完整企业场景端到端走通,覆盖自主闭环+RAG+Skill 调度SC1
---
## Scope Boundaries
**Deferred for later:**
- Skill 市场/社区
- 多租户隔离
- 企业级认证/权限体系RBAC/LDAP/SSO
- 移动端适配
- Workflow 模板市场
- Computer Use 自研视觉识别(替代第三方 API
- 经验库跨组织共享
**Outside this product's identity:**
- LLM 训练/微调平台
- 数据标注平台
- 低代码应用开发平台
---
## Risks & Dependencies
| Risk | Impact | Mitigation |
|------|--------|-----------|
| Anthropic Computer Use API 可靠性有限 | Computer Use 能力不稳定 | 降级链API → ShellTool → AskHumanDocker 沙箱隔离 |
| Vue3 前端重构工作量大 | 延迟 Phase 5 交付 | 后端 API 先行,前端可分批交付(先对话界面,再 Workflow 编辑器) |
| 经验库初期数据为空 | 自进化效果不明显 | 预置种子经验(从测试和文档中提取),冷启动策略 |
| pgvector 性能瓶颈 | 大规模知识库检索慢 | 分区索引、查询优化、缓存层 |
| PipelineEngine 强依赖 Redis+SQLAlchemy | 无 Redis/PG 环境无法运行 | 提供 LocalRAGService 和内存 PipelineState 降级方案 |
---
## Outstanding Questions
**Deferred to implementation:**
- OQ1. 经验库的精确表结构和索引策略——实现时根据数据量调整
- OQ2. PTY 实现选型pexpect vs asyncio+openpty——U8 实现时根据跨平台需求决定
- OQ3. HttpRAGService 如何适配 KnowledgeBase 协议——U10 实现时决定是包装适配还是重构
---
## Sources & Research
- 现有架构分析:`src/agentkit/core/`, `src/agentkit/skills/`, `src/agentkit/orchestrator/`, `src/agentkit/memory/`, `src/agentkit/tools/`, `src/agentkit/evolution/`
- Anthropic Computer Use API 文档beta API支持 computer/text_editor/bash 三种工具类型
- Vue FlowVue3 原生流程图库6k+ starsDAG 支持,自定义节点
- Agent 自进化模式Reflexion反思-修正、Experience Replay经验回放、Genetic Evolution遗传进化
- 企业门户架构Dify/Coze/FastGPT 的对话驱动+能力面板模式
- 智能终端模式Claude Code 的执行-观察-决策循环 + PTY 会话模式

View File

@ -0,0 +1,212 @@
---
date: 2026-06-09
topic: agentkit-capability-matrix
---
## Summary
构建企业级 AI Agent 门户,采用内核+插件架构自主闭环执行引擎作为调度中枢RAG、智能终端、Computer Use、可视化 Workflow、自进化作为可插拔能力接入Skill 标准规范作为统一接口。内外统一平台,解决企业"想用 AI Agent 但不会落地"的核心痛点。
## Problem Frame
企业对 AI Agent 的需求明确但落地困难。当前市场上低代码平台Dify/Coze擅长可视化编排但自主性弱自主 Agent 框架AutoGPT/CrewAI擅长自主规划但集成能力差Computer Use 类产品Anthropic/Operator擅长 UI 自动化但场景窄编排框架LangChain/LlamaIndex擅长工具链但门槛高。企业需要的是一个统一入口能同时覆盖知识问答、系统操作、复杂任务编排、桌面自动化等全场景而不是在多个工具间切换。
AgentKit 已具备 Skill 系统、ReAct 引擎、Pipeline 编排、RAG 服务、ShellTool 等基础能力,但各能力之间缺乏统一调度中枢,缺少 Computer Use 和智能终端交互Workflow 不可视化且不支持动态编排,自主闭环执行能力尚未形成,自进化停留在 Prompt 优化层面而非任务经验积累。
## Key Decisions
**内核+插件架构,而非能力矩阵同步建设**
自主闭环执行引擎是所有能力的调度中枢,其他能力作为 Skill 插件接入。这避免了 6+1 项能力各自为政的集成问题——自主执行引擎天然就是集成点,每个能力接入后立即可被调用。
**计划驱动+人工确认,而非完全自主循环**
用户给定目标后Agent 自动生成计划,人工确认后执行。完全自主循环在企业场景下风险过高,计划驱动既保证可控性又保留灵活性。
**Workflow 与自主闭环执行作为两种独立编排模式**
Workflow 是人工设计的固定流程(可视化+动态编排),自主闭环是 Agent 动态生成的流程。两者独立使用,互不替代,满足不同场景需求。
**Computer Use 调用第三方 API**
调用第三方 Computer Use API如 Anthropic最快实现依赖外部服务但避免自研视觉识别的可靠性问题。
**Vue3 重构整体 UI 并集成 Workflow 编辑器**
现有 Web UI 是纯 HTML用 Vue3 重构整体 UI 并集成可视化 Workflow 编辑器,统一技术栈。
**自进化从 Prompt 优化升级为任务经验积累**
现有 evolution 模块聚焦 Prompt 优化Reflector/PromptOptimizer/ABTester新增任务经验积累能力记住错误避免重犯、总结正确路径、发现更优解时更新经验。两者并存服务不同目的。
**Skill 标准规范+注册调度,而非 Skill 市场**
定义 Skill 的标准接口和规范,任何人都按规范开发 Skill平台负责注册和调度。不做社区市场降低运营复杂度。
## Actors
- A1. **企业开发者** — 使用 AgentKit SDK/框架构建 Agent 应用的技术人员,通过 YAML 配置和 Python API 使用 7 项能力
- A2. **企业终端用户** — 通过 AgentKit 门户直接使用 Agent 完成工作的非技术人员,给定目标获取结果
- A3. **AgentKit 平台** — 自主闭环执行引擎,作为调度中枢协调 7 项能力
- A4. **企业系统** — ERP/CRM/OA 等企业后台系统Agent 通过 API 或 Computer Use 操作
## Requirements
### 自主闭环执行引擎(内核)
- R1. 用户给定自然语言目标后Agent 自动生成结构化执行计划,包含任务拆解、依赖关系、预估步骤和并行度识别
- R2. 执行计划需经人工确认后方可执行,用户可修改计划、调整步骤顺序、增删步骤
- R3. 执行过程中支持自动并行——当识别到多个步骤无依赖关系时,自动调度多个 Agent 并行执行
- R4. 执行过程遵循 分析→计划→执行→检查→复盘→总结 的闭环框架,每步的具体内容由 Agent 动态决定
- R5. 检查环节发现问题时Agent 可自动重试、调整计划或请求人工介入,而非直接失败
- R6. 复盘环节将执行经验写入经验库,供后续任务参考
### Skill 标准规范与注册调度
- R7. 定义 Skill 标准接口规范,包含元数据(名称/描述/版本/作者)、输入输出 Schema、依赖声明、质量门禁配置
- R8. Skill 注册中心支持动态注册、发现、版本管理和能力查询
- R9. 内置 Skill 加载器支持从 YAML、Python 函数、Markdown 文件加载 Skill
- R10. RAG、智能终端、Computer Use 等能力均以 Skill 插件形式注册,可被自主执行引擎和其他 Skill 调用
### 知识库与 RAG
- R11. 支持本地文档摄取PDF/Word/网页/Markdown 等),自动分块、向量化、索引
- R12. 支持对接外部知识库系统飞书知识库、Confluence、企业 Wiki 等),通过标准适配器统一检索
- R13. 用户可在任务级别指定信息源——选择使用哪些知识库或文档集合,支持多源混合检索
- R14. RAG 检索结果包含来源追溯,用户可验证信息出处
### 智能终端交互
- R15. Agent 能理解终端命令输出,根据输出内容决定下一步操作,而非仅执行预设命令
- R16. 支持交互式命令的自动应答——当命令等待用户输入时Agent 根据上下文自动提供输入
- R17. 维护终端会话状态,跨命令保持工作目录、环境变量、进程状态
- R18. 安全控制:危险操作需人工确认,支持操作审计日志
### Computer Use
- R19. 集成第三方 Computer Use API如 Anthropic支持截屏识别 UI 元素和模拟用户操作
- R20. 支持多步骤 UI 操作流程Agent 根据每步结果决定下一步操作
- R21. 当第三方 API 不可用或操作失败时,自动降级到 API/CLI 方式(如有可用),或请求人工介入
- R22. 操作过程可录制回放,支持人工审核和纠错
### 可视化 Workflow
- R24. 提供可视化拖拽编排界面,用户可通过拖拽节点构建 Workflow
- R25. 支持条件分支、循环、并行执行、子流程调用等动态编排能力
- R26. 支持人工审批节点——Workflow 执行到审批节点时暂停,等待人工确认后继续
- R27. Workflow 可引用已注册的 Skill 作为节点Skill 更新后 Workflow 自动使用最新版本
- R28. 支持运行时动态调整——执行中可根据条件动态增删节点或切换分支
### 自进化(任务经验积累)
- R29. 每次任务完成后Agent 自动总结执行经验:成功路径、失败原因、耗时分布
- R30. 经验库按任务类型组织,新任务启动时自动检索相关经验作为参考
- R31. 当发现更优执行路径时(如更少的步骤、更高的成功率),自动更新经验库中的推荐路径
- R32. 错误经验标记为避坑指南,后续任务遇到类似场景时自动预警
- R33. 经验积累效果可量化——展示任务完成率、平均耗时、重试率等指标的变化趋势
### 企业门户集成
- R34. 统一入口:终端用户通过一个对话界面即可使用全部 7 项能力,无需切换工具
- R35. 开发者入口:提供 SDK 和 API开发者可将 AgentKit 集成到自己的应用中
- R36. 支持接入企业系统获取操作权限和数据,通过标准适配器对接 ERP/CRM/OA 等
## Key Flows
- F1. 目标驱动的复杂任务
- **Trigger:** 用户输入自然语言目标(如"分析竞品并生成优化方案"
- **Actors:** A2, A3
- **Steps:**
1. Agent 分析目标,识别所需能力和信息源
2. 生成结构化执行计划,标注并行步骤
3. 用户确认或修改计划
4. Agent 按计划执行,并行步骤自动调度多 Agent
5. 检查环节验证每步产出,发现问题自动调整
6. 复盘总结,经验写入经验库
7. 输出最终结果
- **Covered by:** R1, R2, R3, R4, R5, R6
- F2. 知识库问答+系统操作
- **Trigger:** 用户提问涉及企业知识或需操作企业系统
- **Actors:** A2, A3, A4
- **Steps:**
1. Agent 识别问题需要知识检索还是系统操作
2. 检索指定知识库获取相关信息R11-R14
3. 如需操作企业系统,通过 Computer Use API 或 API/CLI 执行R19-R21
4. 组合信息生成回答或确认操作结果
- **Covered by:** R11, R12, R13, R14, R19, R22
- F3. 可视化 Workflow 编排
- **Trigger:** 用户需要设计可复用的固定流程
- **Actors:** A1, A2
- **Steps:**
1. 用户在可视化界面拖拽节点构建 Workflow
2. 配置条件分支、审批节点、并行执行等
3. 引用已注册 Skill 作为节点
4. 保存并发布 Workflow
5. 触发执行,运行时可动态调整
- **Covered by:** R24, R25, R26, R27, R28
## Acceptance Examples
- AE1. **目标驱动任务——并行执行**
- **Covers R3, R5.**
- **Given:** 用户目标"调研 3 个竞品的 SEO 策略并生成对比报告"
- **When:** Agent 生成计划后识别 3 个竞品调研无依赖关系
- **Then:** 自动调度 3 个 Agent 并行调研,汇总后生成对比报告
- AE2. **Computer Use 降级**
- **Covers R21.**
- **Given:** Agent 尝试通过第三方 Computer Use API 在企业 OA 系统提交审批
- **When:** API 不可用或操作失败
- **Then:** 自动降级到 OA 系统 API 提交审批,或暂停请求人工介入
- AE3. **经验积累与避坑**
- **Covers R30, R32.**
- **Given:** 经验库中记录"调用 X 系统 API 在高峰期超时率 60%"
- **When:** 新任务需要调用 X 系统 API
- **Then:** Agent 自动预警并建议错峰调用或使用重试策略
- AE4. **知识库指定信息源**
- **Covers R13.**
- **Given:** 用户提问"我们公司对数据导出有什么合规要求"
- **When:** 用户指定信息源为"合规文档库"和"法务知识库"
- **Then:** Agent 仅从指定知识库检索,不检索无关信息源
- AE5. **Workflow 人工审批**
- **Covers R26.**
- **Given:** Workflow 包含"发送客户报价"步骤
- **When:** 执行到该步骤
- **Then:** Workflow 暂停,通知审批人确认,确认后继续执行
## Success Criteria
- SC1. 一个完整企业场景(目标驱动的复杂任务)端到端走通,覆盖自主闭环+RAG+Skill 调度
- SC2. 开发者可用 10-20 行 YAML 配置定义一个 Skill 并注册到平台
- SC3. 终端用户通过一个对话界面完成知识问答、系统操作、复杂任务编排,无需切换工具
- SC4. Computer Use 在 3 个以上企业 Web 系统上完成基本操作(登录、填表、提交)
- SC5. 自进化使同类任务的平均完成时间随执行次数递减
## Scope Boundaries
**Deferred for later:**
- Skill 市场/社区——先做标准规范和注册调度,社区生态后续再建
- 多租户隔离——企业门户隐含需要,但 v1 先做单租户
- 企业级认证/权限体系——v1 先做基础 API Key 认证
- 移动端适配——先做 Web 端,移动端后续扩展
- Workflow 模板市场——先支持自建 Workflow模板市场后续再建
**Outside this product's identity:**
- LLM 训练/微调平台——AgentKit 使用 LLM不训练 LLM
- 数据标注平台——AgentKit 消费数据,不标注数据
- 低代码应用开发平台——AgentKit 是 Agent 平台,不是通用应用开发平台
## Dependencies / Assumptions
- D1. Computer Use 依赖第三方 API如 Anthropic的可用性和稳定性需要 API Key 和网络访问
- D2. 外部知识库对接依赖各系统的 API 开放程度,部分企业系统可能无 API 需通过 Computer Use 操作
- D3. 自进化的经验积累效果依赖任务执行量,初期经验库为空时效果有限
- D4. Vue3 重构整体 UI 需要前端开发能力当前项目后端为主Python/FastAPI前端资源可能不足
## Outstanding Questions
**Resolve Before Planning:**
- (All resolved — see Key Decisions below)
**Deferred to Planning:**
- OQ1. 经验库的存储和检索方案——向量数据库 vs 结构化存储 vs 混合
- OQ2. 自主闭环执行引擎与现有 ReAct 引擎的关系——增强还是替换
- OQ3. 智能终端交互与现有 ShellTool 的关系——增强还是替换

View File

@ -0,0 +1,183 @@
# Clawith 架构研究与 AgentKit 改进分析
## 研究背景
Clawith (clawith.ai) 是一个开源的多智能体协作平台,基于 OpenClaw 构建,旨在将 AI 代理从简单的聊天机器人提升为组织的"数字员工"。
GitHub: https://github.com/dataelement/Clawith
许可证: MIT
技术栈: React 19 + TypeScript / FastAPI + PostgreSQL + Redis
---
## Clawith 核心架构
### 1. Agent Plaza — 协作知识流
**设计理念**:代理共享的社交空间,代理在其中发布更新、分享发现、评论彼此的工作,实现知识流动而非被动查询。
**核心组件**
- `PlazaFeed`:类似社交 feed支持 post、comment、tag 过滤
- `KnowledgeBase`:组织级共享知识池
- 代理可订阅相关领域 feed实现"被动发现"
### 2. Persistent Identity — Soul & Memory
**设计理念**:每个代理有跨会话的持久身份,而非每次会话从头开始。
**核心组件**
- `soul.md`:代理的个性、价值观、工作风格(持久存储)
- `memory.md`:长期上下文和学习到的偏好
- 代理启动时加载 Soul用于 Prompt 注入
### 3. Organization Awareness — 组织感知
**设计理念**:代理理解完整组织结构,知道谁是同事,能自主建立工作关系和委派任务。
**核心组件**
- `OrganizationContext`:组织架构、代理能力矩阵
- 代理之间可以自发 `handoff`,而非只能通过中央编排器
- 像新员工加入团队一样自然融入
### 4. Supervision Tasks — 主动跟进
**设计理念**:代理可以代表组织主动跟进同事,确保待办事项完成。
**核心组件**
- `SupervisorAgent`:可配置自动跟进规则
- `deadline`、`reminder`、`escalation` 机制
### 5. Self-Evolution — 自我进化
**设计理念**:代理遇到无法处理的任务时,主动搜索 MCP 注册表发现并安装新工具。
**核心组件**
- 从 Smithery + ModelScope MCP 注册表搜索工具
- 代理可为自己或同事创建新 Skill
- `SkillCreator` 工具
### 6. Enterprise Governance — 企业治理
**设计理念**:组织级控制能力。
**核心组件**
- `UsageQuota`:每个用户/代理的消息限制、Token 配额、代理 TTL
- `ApprovalWorkflow`:危险操作需人工审批
- `AuditLog`:全链路操作可追溯
- RBAC + 多租户隔离
---
## AgentKit 当前架构
### 项目结构
```
src/agentkit/
├── core/ # BaseAgent、Orchestrator、Dispatcher、Registry
├── skills/ # SkillConfig、Skill、SkillRegistry、SkillLoader
├── memory/ # WorkingMemory、EpisodicMemory、SemanticMemory、RAG
├── evolution/ # EvolutionMixin、Reflector、PromptOptimizer、ABTester
├── llm/ # LLMGateway、ProviderOpenAI/Anthropic/Gemini/DeepSeek/国产)
├── orchestrator/ # PipelineEngine、HandoffManager、DynamicPipeline
├── mcp/ # MCPClient、MCPServer、MCPManager
├── tools/ # Tool基类、FunctionTool、AgentTool、MCPTool
├── server/ # FastAPI Server、routes、middleware
└── telemetry/ # OpenTelemetry tracing + metrics
```
### 已实现的核心能力
- **ReAct Engine**`react.py` 实现推理-行动循环
- **LLM Gateway**`llm/gateway.py` 统一调用、路由、计量
- **Intent Router**`router/intent.py` 三级路由(关键词+LLM
- **Quality Gate**`quality/gate.py` 产出质量管理
- **Skill System**`skills/base.py` SkillConfig + Skill + SkillRegistry
- **Evolution System**`evolution/lifecycle.py` 反思→优化→A/B测试→应用
- **Pipeline**`orchestrator/pipeline_engine.py` 多步骤编排+重试+补偿
---
## 差距分析
### 功能对比表
| 功能 | Clawith | AgentKit | 差距 |
|------|---------|----------|------|
| Agent Plaza | ✅ | ❌ | 需要新增 plaza/ 模块 |
| Soul/Memory 持久身份 | ✅ | ⚠️ 部分 | Soul 缺失memory 是会话级非代理级 |
| 组织感知 | ✅ | ❌ | Agent 不知道可以向谁求助 |
| Supervision Tasks | ✅ | ❌ | 任务即结束,无跟进机制 |
| 主动 Self-Evolution | ✅ | ⚠️ 被动 | evolve_after_task 是完成后触发 |
| Enterprise Governance | ✅ | ⚠️ 部分 | QualityGate 有Quota/审批/RBAC 无 |
| 多渠道 Gateway | ✅ | ⚠️ MCP | 当前是 Agent 平台Clawith 是 Channel |
| 意图路由 | ✅ | ✅ | 已有 Intent Router |
---
## 建议优先实现的功能
### P0 — 组织感知 + 主动进化
**1. OrganizationContext组织上下文**
- 新增 `src/agentkit/org/context.py`
- `OrganizationModel`:组织架构、代理能力注册表
- `Agent.on_task_start()` 自动注入组织上下文
- 支持代理主动 `handoff` 给合适的同事
**2. 主动工具发现**
- 当 ReAct 循环遇到未知任务时,触发 MCP 注册表搜索
- 新增 `ToolDiscovery`
- 集成 Smithery + ModelScope MCP 注册表
### P1 — Agent Plaza + Persistent Identity
**3. Agent Plaza**
- 新增 `src/agentkit/plaza/` 模块
- `PlazaFeed`、`KnowledgeShare`、`SubscriptionManager`
- 代理可发布状态、评论、发现
**4. Soul & LongTermMemory**
- 新增 `src/agentkit/identity/` 模块
- `SoulManager`:管理代理 soul.md
- `LongTermMemory`:跨任务的持续记忆
### P2 — Supervision + Enterprise Governance
**5. Supervision Tasks**
- 新增 `SupervisionTask` 类型
- `SupervisorAgent` 主动跟进机制
**6. Enterprise Governance 补齐**
- `UsageQuota`
- `ApprovalWorkflow`
- 完善 RBAC
---
## 深入分析任务
请在另一个会话中完成以下分析:
1. **OrganizationContext 详细设计**
- 如何表示组织架构和代理能力
- Agent 如何感知可用的同事
- handoff 协议如何扩展支持自发协作
2. **Agent Plaza 技术方案**
- Feed 数据模型设计
- 与现有 Memory 系统如何集成
- 订阅/推送机制实现
3. **Persistent Identity 实现路径**
- soul.md 的 Schema 设计
- LongTermMemory 与 WorkingMemory/EpisodicMemory 的关系
- 跨会话记忆如何注入 Prompt
4. **Self-Evolution 主动性增强**
- 何时触发工具发现
- 如何平衡探索成本和任务需求
- 发现新工具后如何学习使用
5. **与现有 Phase 6/7/8 规划的关系**
- Layered MemoryPhase 8
- Adaptive ChatPhase 8
- HeadroomPhase 7
- 如何避免重复建设
6. **实现优先级和依赖关系**
- 哪些功能可以并行开发
- 哪些必须依赖其他模块先完成
- 建议的迭代顺序

View File

@ -0,0 +1,191 @@
---
date: "2026-06-13"
topic: "agentkit-platform-experience-upgrade"
---
## Summary
Fischer AgentKit 平台体验全面升级——布局重构为左对话+右双栏、对话体验深化(首 Token 即渲染+消息格式增强+@-mention 四类引用)、响应速度核心优化(启发式路由+合并 LLM 调用、暗色主题与交互增强、Computer Use MVP——分三个冲击波迭代交付。
## Problem Frame
当前 AgentKit 处于"后端能力丰富、前端体验粗糙"的失衡状态。后端已实现 ReAct 推理、Skill 系统、Pipeline 编排、三层记忆、自进化、多 Agent 市场等完整能力,但 GUI 仍停留在功能可用层面:四象限等分布局让对话空间被压缩到 1/4 屏幕;消息仅支持纯文本渲染,代码块无高亮、工具调用无可视化;用户输入后需等待 5-10 秒才看到首个响应 Token无暗色主题、无过渡动画、无操作反馈Computer Use 前后端均为占位状态。
竞品Devin、Cursor、v0.dev已普遍采用 Agent-First 布局 + 统一设计系统 + 流式渲染 + 视觉操作能力。AgentKit 需要对齐这一标准,将"能用"升级为"好用"。
## Key Decisions
**冲击波迭代策略。** 三个迭代各聚焦一个体验质变:迭代 1 解决对话"快且宽敞"(布局+速度+格式),迭代 2 解决"丰富且精准"(双主题+@-mention+交互),迭代 3 解决"专业且全能"Computer Use+剩余优化)。每个迭代交付后用户都能感受到明确的体验提升。
**四线并行。** GUI 产品化、Chat 体验深化、响应速度优化、Computer Use 四条线并行推进,每个迭代从每条线各取一部分,交叉组合为端到端可交付的体验升级。
**Computer Use MVP 先截屏后容器。** 先实现截屏查看+简单点击操作的前后端闭环Docker 容器化隔离延后。MVP 让用户能通过对话让 Agent"看到"屏幕并执行简单操作,验证价值后再投入容器化成本。
**@-mention 四类全覆盖。** 支持 @文件、@技能、@工作流、@Agent 四类引用,在对话输入框中通过 autocomplete 下拉选择,选中后作为上下文注入 Agent 推理。
**Cmd+K 内联编辑延后。** AgentKit 当前没有代码编辑器Cmd+K 的场景定义不清,延后到代码预览集成完成后再评估。
## Actors
- A1. **开发者/运维工程师** — 通过对话面板与 Agent 交互,使用 @-mention 引用上下文,通过 Computer Use 让 Agent 操作远程环境
- A2. **Agent** — 接收用户消息,执行推理和工具调用,流式返回结果,响应 @-mention 注入的上下文
## Requirements
### 迭代 1对话体验质变
#### 布局重构
R1. 左对话 + 右双栏布局 — AgentLayout 从四象限等分布局重构为左半屏对话面板 + 右半屏上下双栏(上:代码/工作流/知识库,下:监控/技能/设置),左右分割默认 55:45右侧上下分割默认 60:40分割比例保存到 localStorage
R2. 面板折叠为 Tab 栏 — 每个面板可独立折叠为约 38px 的 Tab 栏,折叠状态保存到 localStorage折叠/展开有 200ms ease 过渡动画
R3. 侧边导航精简为图标模式 — 32px 宽图标导航栏,点击图标切换右侧面板 Tab 并展开,当前激活图标高亮,导航栏可通过 TopNav 按钮折叠/展开
R4. 小屏幕适配 — <1024px 显示提示1024-1280px 右下面板默认折叠1280px 完整展示
#### 响应速度核心
R5. 启发式分类器替代 LLM quick_classify — 用零成本的本地启发式规则(消息长度、关键词密度、工具提示检测)替代 `CostAwareRouter.quick_classify()` 的 LLM 调用,通过配置开关可回退到 LLM 模式
R6. 合并路由 LLM 调用 — 当启发式不确定需要 LLM 路由时,将复杂度评分和意图分类合并为单次 LLM 调用,而非两次串行调用
R7. 首 Token 即渲染 — 前端 WebSocket 接收到首个流式 Token 即开始渲染,不等完整块;后端 ReActEngine 流式输出与前端渲染对齐
#### 消息格式增强
R8. 代码块语法高亮 — 消息中的代码块自动识别语言并语法高亮,支持复制按钮
R9. 工具调用可视化 — 工具调用显示为可折叠的步骤卡片,展示工具名称、参数摘要、执行状态、结果预览,替代当前的纯文本标签
R10. 图片和文件预览 — 消息中的图片内联显示缩略图,文件显示为可下载卡片(文件名+大小+类型图标)
### 迭代 2专业感 + 精准度
#### 设计系统
R11. 暗色主题 — 在浅色 Token 基础上新增暗色主题 Token 变体TopNav 增加主题切换按钮,偏好保存到 localStorage暗色配色为深色背景+荧光强调色+终端原生感
R12. 组件样式统一 — 所有组件统一引用 Design Token消除硬编码值Ant Design 全局覆盖通过 Token 驱动
#### @-mention 上下文引用
R13. @-mention Autocomplete — 对话输入框中输入 `@` 触发下拉选择器,支持 @文件(知识库文档)、@技能(已注册技能)、@工作流(已有工作流)、@Agent已配置 Agent支持模糊搜索
R14. @-mention 上下文注入 — 选中的引用项作为结构化上下文注入 Agent 推理,后端解析引用类型和 ID将对应内容文档片段、技能描述、工作流定义、Agent 配置)加入对话上下文
R15. @-mention 引用标签 — 输入框中已选引用显示为可删除的标签(类似 ContextPill发送后消息中显示为可点击的引用链接
#### 交互增强
R16. 过渡动画 — 面板折叠/展开 200ms ease、Tab 切换 150ms 淡入淡出、列表项交错渐入 stagger 50ms、路由切换 200ms 淡入淡出
R17. 操作反馈 — 按钮点击波纹/缩放反馈、加载状态骨架屏替代 `a-spin`、成功/失败 Toast 提示、WebSocket 断连顶部横幅
R18. 空状态设计 — 对话/工作流/监控/知识库/技能空状态各有品牌化插图和引导文案
### 迭代 3能力扩展
#### Computer Use MVP
R19. 截屏查看 — Agent 可对目标环境执行截屏操作,截图实时显示在 Computer Use 面板中,支持缩放和滚动
R20. 简单点击操作 — Agent 可在截屏上执行点击操作(指定坐标),操作结果通过新的截屏反馈
R21. Computer Use 面板 — 右上面板新增 Computer Use Tab展示截屏画面和操作历史支持手动截屏触发
#### 响应速度剩余优化
R22. 并行工具执行 — ReActEngine 中多个独立 tool_calls 使用 `asyncio.gather()` 并行执行,结果按 tool_call_id 顺序追加,通过配置开关可回退串行模式
R23. 异步会话写入 — SessionManager 的 `append_message()` 改为非阻塞,使用写前缓冲区防止消息丢失,优雅关闭时 flush
#### 拖拽交互增强
R24. 分割线拖拽增强 — 拖拽时高亮分割线,显示当前比例百分比
R25. 面板折叠缩略预览 — 面板折叠时显示缩略内容预览
## Key Flows
- F1. 对话为主的工作流
- **Trigger:** 用户打开 AgentKit
- **Steps:** 左侧对话面板占满左半屏 → 用户输入消息 → 启发式分类器即时判断复杂度 → ReActEngine 流式推理 → 首 Token 即渲染 → 工具调用显示为步骤卡片 → 代码块语法高亮 → 右侧按需展示辅助信息
- **Covered by:** R1, R5, R7, R8, R9
- F2. @-mention 上下文引用
- **Trigger:** 用户在对话输入框输入 `@`
- **Steps:** 下拉选择器弹出 → 用户输入关键词模糊搜索 → 选择引用项 → 引用显示为标签 → 发送消息 → 后端解析引用 → 对应内容注入 Agent 上下文 → Agent 基于引用内容推理
- **Covered by:** R13, R14, R15
- F3. Computer Use 操作
- **Trigger:** 用户让 Agent 执行视觉操作(如"帮我点击屏幕上的保存按钮"
- **Steps:** Agent 调用截屏工具 → 截图显示在 Computer Use 面板 → Agent 分析截图定位目标 → 执行点击操作 → 新截屏反馈操作结果 → 用户可在面板中查看操作历史
- **Covered by:** R19, R20, R21
- F4. 主题切换
- **Trigger:** 用户点击 TopNav 主题切换按钮
- **Steps:** 点击月亮/太阳图标 → 所有 Token 变量切换 → 界面平滑过渡 → 偏好保存到 localStorage
- **Covered by:** R11
## Scope Boundaries
**在范围内:**
- 布局重构(左对话 + 右双栏)
- Design Token 体系 + 双主题
- 组件样式统一
- 首 Token 即渲染
- 消息格式增强(代码高亮、工具调用卡片、图片/文件预览)
- @-mention 四类引用文件、技能、工作流、Agent
- 响应速度优化(启发式路由、合并调用、并行工具、异步写入)
- Computer Use MVP截屏+点击,不含 Docker 容器化)
- 过渡动画、操作反馈、空状态、拖拽增强
- 侧边导航精简
- 小屏幕适配
**延迟到后续迭代:**
- Cmd+K 内联编辑
- Computer Use Docker 容器化隔离
- 代码 Diff 查看器实现右上「代码」Tab 仍为占位)
- 代码 Diff 的 Accept/Reject 实际回滚功能
- 响应式移动端适配
- httpx 连接池配置优化
- A/B 测试框架和性能基准 CI
**不在本产品身份内:**
- 多用户协作/实时协同编辑
- 插件市场
- 代码编辑器(只读预览,不提供编辑能力)
## Success Criteria
1. **布局合理** — 对话面板占左半屏,右侧辅助信息可按需折叠
2. **首 Token <1s** — 简单对话问候、Q&A首 Token 延迟低于 1 秒
3. **消息丰富** — 代码块有语法高亮,工具调用有可视化卡片,图片/文件有预览
4. **@-mention 可用** — 四类引用均可 autocomplete 选择并注入上下文
5. **双主题可用** — 浅色/暗色一键切换,所有组件在两种主题下正常显示
6. **Computer Use MVP** — Agent 能截屏查看并执行点击操作
7. **交互流畅** — 所有过渡动画 ≤200ms操作有即时反馈
8. **渐进交付** — 分 3 个迭代完成,每个迭代可独立部署
## Dependencies / Assumptions
- 现有 SplitPane 和 QuadrantPanel 组件支持水平和垂直分割,可直接复用
- Ant Design Vue 4.x 的 ConfigProvider 支持 CSS 变量主题注入
- ChatView 的 ChatSidebar 默认折叠,不影响左侧对话面板的空间利用
- Vue 3 的 `<Transition>``<TransitionGroup>` 可满足过渡动画需求
- `CostAwareRouter` 已有 `_tokenize_content()``_match_layer0()` 规则基础,启发式分类器可复用
- `IntentRouter._classify_with_llm()` 的 prompt 结构可扩展为同时返回复杂度评分
- Computer Use MVP 不依赖 Docker可直接在宿主环境执行截屏和点击
- 现有 `tools/computer_use_session.py` 的 TODO 占位需要替换为实际实现
- 响应速度优化已有详细实施计划docs/plans/2026-06-12-021-feat-chat-response-speed-optimization-plan.mdU1-U6 可直接沿用
## Outstanding Questions
**Resolved:**
- Computer Use MVP 的截屏实现方式:使用 OpenCLI
**Deferred to Planning:**
- @-mention 后端解析的具体协议:引用项如何在 WebSocket 消息中编码,与现有 `chat` 消息类型如何兼容
- 工具调用步骤卡片的折叠/展开交互细节
- 骨架屏的具体形状和占位内容

View File

@ -0,0 +1,190 @@
---
date: "2026-06-13"
topic: "gui-productization"
status: active
---
## Summary
对 Fischer AgentKit GUI 进行产品级提升,三线并行推进:布局重构为「左对话 + 右双栏」、建立双主题设计系统、增强交互体验。分 3 个迭代交付,每个迭代可独立部署。
## Problem Frame
当前 GUI 处于"功能可用但体验粗糙"状态:四象限等分布局让对话空间被压缩到 1/4 屏幕,终端面板大多时间空闲浪费空间;颜色/间距/圆角全部硬编码散落在 30+ 组件中视觉不统一无过渡动画、操作无反馈、空状态单调、加载态简陋。竞品Devin、Cursor、v0.dev已普遍采用 Agent-First 布局 + 统一设计系统AgentKit 需要对齐这一标准。
## Key Decisions
**左对话 + 右双栏布局。** 对话是用户 80% 时间停留的区域,应占据左半屏全部空间。右侧上下分割为代码/工作流(右上)和监控(右下),提供辅助信息。
**全面设计系统重构 + 双主题。** 建立 CSS 变量体系(颜色/间距/圆角/字体/阴影),所有组件统一引用。同时支持浅色极简和暗色科技两种主题,用户可一键切换。
**三线并行,分迭代交付。** 布局/视觉/交互三线并行推进,分 3 个迭代交付:迭代 1 布局骨架 + 设计 Token 基础,迭代 2 双主题 + 组件样式统一,迭代 3 交互增强。
## Actors
- A1. **开发者/运维工程师** — 通过对话面板与 Agent 交互,右侧面板提供工作流编辑、监控数据等辅助信息
## Requirements
### 迭代 1布局骨架 + 设计 Token 基础
#### R1. 左对话 + 右双栏布局
将 AgentLayout 从四象限等分布局重构为「左对话 + 右双栏」布局。
- 左半屏对话面板ChatView占满左侧全部高度
- 右半屏上半:代码/工作流面板Tab 切换代码(占位)/工作流/知识库
- 右半屏下半监控面板Tab 切换监控/技能/设置
- 左右分割线可拖拽调整宽度,默认比例 55:45
- 右侧上下分割线可拖拽调整高度,默认比例 60:40
- 分割比例保存到 localStorage刷新后恢复
- 最小比例 20%,最大比例 80%
#### R2. 面板折叠为 Tab 栏
每个面板可独立折叠,折叠后仅显示 Tab 栏(约 38px 高度)。
- 右上面板折叠后仅显示 Tab 栏(代码/工作流/知识库 图标+文字)
- 右下面板折叠后仅显示 Tab 栏(监控/技能/设置 图标+文字)
- 折叠状态保存到 localStorage
- 折叠/展开有平滑过渡动画200ms ease
- 两个面板可同时折叠,对话面板获得最大空间
#### R3. 侧边导航精简为图标模式
侧边导航栏精简为 32px 宽的图标导航,点击图标切换右侧面板 Tab 内容。
- 导航项:对话、工作流、知识库、技能、监控、设置
- 点击导航图标:对应面板切换到指定 Tab 并展开
- 当前激活的导航图标高亮显示
- 导航栏可通过 TopNav 按钮折叠/展开
#### R4. Design Token 体系基础
建立 CSS 变量体系,定义所有设计令牌。
- 创建 `src/styles/tokens.css`,定义浅色主题 Token颜色、间距、圆角、字体、阴影
- Ant Design Vue 主题通过 ConfigProvider 注入 Token 值
- 所有组件引用 Token 变量,不再硬编码颜色/间距值
- 主色统一为一种(消除 `#1677ff`/`#1890ff` 混用)
#### R5. 小屏幕适配
- 屏幕宽度 < 1024px 时显示提示"请使用更大的屏幕"
- 屏幕宽度 1024-1280px 时右侧下面板默认折叠
- 屏幕宽度 ≥ 1280px 时完整展示所有面板
### 迭代 2双主题 + 组件样式统一
#### R6. 暗色主题
在浅色主题基础上新增暗色主题 Token 变体。
- `src/styles/tokens-dark.css` 定义暗色主题 Token
- TopNav 增加主题切换按钮(太阳/月亮图标)
- 主题偏好保存到 localStorage
- 暗色主题配色:深色背景(#1a1a2e、荧光强调色、终端原生感
- 代码/终端区域在浅色主题下也使用深色背景One Dark Pro 配色)
#### R7. 组件样式统一
所有组件统一引用 Design Token消除硬编码值。
- 对话面板:消息气泡、工具调用标签、流式输出样式
- 工作流面板:节点卡片、属性面板、画布样式
- 监控面板:指标卡片、图表容器、时间线样式
- 通用:按钮、输入框、标签、徽章、模态框统一圆角和间距
- Ant Design 全局覆盖通过 Token 驱动,而非逐组件 `!important`
### 迭代 3交互增强
#### R8. 过渡动画
为所有交互添加过渡动画。
- 象限折叠/展开平滑过渡200ms ease
- Tab 切换淡入淡出150ms
- 列表项加载交错渐入stagger 50ms
- 路由切换淡入淡出200ms
#### R9. 操作反馈
为用户操作提供即时反馈。
- 按钮点击:波纹效果或缩放反馈
- 加载状态:骨架屏替代 `<a-spin>`
- 成功/失败Toast 提示(替代 `alert`
- WebSocket 断连:顶部横幅提示
#### R10. 空状态设计
为所有空状态提供品牌化插图和引导文案。
- 对话空状态:引导用户输入第一条消息
- 工作流空状态:引导创建第一个工作流
- 监控空状态:说明数据来源和更新频率
- 知识库空状态:引导上传文档或配置信息源
- 技能空状态:说明如何注册技能
#### R11. 拖拽交互增强
优化拖拽操作的视觉反馈。
- 分割线拖拽:拖拽时高亮分割线,显示当前比例百分比
- 工作流节点拖拽:放置预览指示,对齐网格
- 面板折叠:折叠时显示缩略预览
## Key Flows
- F1. 对话为主的工作流
- **Trigger:** 用户打开 AgentKit
- **Steps:** 左侧对话面板占满左半屏 → 右侧显示工作流/监控 Tab 栏 → 用户在对话面板输入 → Agent 回复 → 右侧按需展示辅助信息
- **Covered by:** R1
- F2. 主题切换
- **Trigger:** 用户点击 TopNav 主题切换按钮
- **Steps:** 点击月亮图标 → 所有 Token 变量切换为暗色值 → 界面平滑过渡到暗色主题 → 偏好保存到 localStorage
- **Covered by:** R6
- F3. 面板折叠获取最大对话空间
- **Trigger:** 用户需要专注对话
- **Steps:** 点击右上面板折叠按钮 → 面板折叠为 Tab 栏200ms 动画)→ 点击右下面板折叠按钮 → 同样折叠 → 对话面板获得最大空间
- **Covered by:** R2, R8
## Scope Boundaries
**在范围内:**
- 布局重构(左对话 + 右双栏)
- Design Token 体系 + 双主题
- 组件样式统一
- 过渡动画、操作反馈、空状态、拖拽增强
- 侧边导航精简
- 小屏幕适配
**延迟到后续迭代:**
- 代码 Diff 查看器实现右上「代码」Tab 仍为占位)
- Cmd+K 内联编辑(类似 Cursor
- @-mention 上下文引用
- 代码 Diff 的 Accept/Reject 实际回滚功能
- 响应式移动端适配
**不在本产品身份内:**
- 多用户协作/实时协同编辑
- 插件市场
- 代码编辑器(只读预览,不提供编辑能力)
## Success Criteria
1. **布局合理** — 对话面板占左半屏,右侧辅助信息可按需折叠
2. **视觉一致** — 零硬编码颜色值,所有样式通过 Design Token 引用
3. **双主题可用** — 浅色/暗色一键切换,所有组件在两种主题下正常显示
4. **交互流畅** — 所有过渡动画 ≤200ms操作有即时反馈
5. **渐进交付** — 分 3 个迭代完成,每个迭代可独立部署
## Dependencies / Assumptions
- 现有 SplitPane 组件支持水平和垂直分割,可直接复用
- 现有 QuadrantPanel 组件支持 Tab 切换和折叠,可直接复用
- Ant Design Vue 4.x 的 ConfigProvider 支持 CSS 变量主题注入
- ChatView 的 ChatSidebar 默认折叠collapsed = true不影响左侧对话面板的空间利用
- Vue 3 的 `<Transition>``<TransitionGroup>` 组件可满足过渡动画需求

View File

@ -0,0 +1,220 @@
# Fischer AgentKit GUI 重构需求文档
**日期:** 2026-06-13
**状态:** Active
**范围:** Deep — Feature
---
## 1. 问题陈述
当前 Fischer AgentKit GUI 处于"功能可用但视觉粗糙"状态,存在以下核心问题:
1. **布局范式落后** — SideNav 多页面布局无法同时展示 Agent 的对话、代码修改、终端执行和预览结果,用户需要频繁切换页面才能理解 Agent 在做什么
2. **设计系统缺失** — 无 Design Token颜色`#1677ff`/`#1890ff` 混用)、间距、圆角、字体大小全部硬编码散落在 30+ 组件中
3. **视觉质量低** — 无过渡动画、空状态单调、加载态粗糙、信息层级平铺
4. **响应式完全缺失** — 所有面板宽度固定,小屏幕下内容被严重挤压
竞品Devin、Cursor、Trae、v0.dev已普遍采用 Agent-First 全屏布局 + 统一设计系统Fischer AgentKit 需要对齐这一标准。
## 2. 目标用户
**主要用户:** 使用 Fischer AgentKit 执行自动化任务的开发者和运维工程师。他们提交任务后需要实时观察 Agent 执行过程,并在必要时干预。
**核心场景:** 用户提交一个多步任务(如"部署新版本到测试环境"Agent 自主执行,用户在四象限界面中实时观察对话推理、代码修改、终端执行和结果预览,随时可以点击干预。
## 3. 设计方向
### 3.1 布局范式Agent-First 全屏
采用类似 Devin 的四象限布局Agent 执行过程全屏实时展示:
```
┌──────────────────────────────────────────────────────┐
│ 顶部导航栏 (48px) — Logo / 任务选择 / 状态 / 设置 │
├──────────────────────┬───────────────────────────────┤
│ │ │
│ 对话面板 │ 代码/预览面板 │
│ (Chat + Agent │ (代码 Diff / 文件树 / │
│ 推理过程) │ 工作流画布 / 知识库) │
│ │ │
│ │ │
├──────────────────────┼───────────────────────────────┤
│ │ │
│ 终端面板 │ 状态/监控面板 │
│ (实时终端输出 │ (执行进度 / 节点状态 / │
│ + 命令确认) │ 指标图表 / 日志) │
│ │ │
└──────────────────────┴───────────────────────────────┘
```
**四象限可独立缩放和折叠**,象限之间的分隔线可拖拽调整比例。
### 3.2 视觉风格:浅色极简
- **主背景:** 白色 / 极浅灰(`#fafafa`
- **强调色:** 紫黑渐变(类似 v0.dev 的 Vercel 品牌色)
- **代码/终端区域:** 深色背景One Dark Pro 配色),与浅色主界面形成对比
- **设计语言:** 大量留白、细线条、柔和阴影、圆角 8px
- **字体:** 系统字体栈UI+ 等宽字体栈(代码/终端)
### 3.3 交互模式
- **任务提交:** 顶部输入栏或对话面板底部输入
- **实时观察:** 四象限同步更新WebSocket 推送
- **干预机制:** 每个象限内的操作可暂停/审批/接管
- **模式切换:** 对话模式Chat-First↔ 代理模式Agent-First一键切换
## 4. 功能需求
### R1: Design Token 体系
建立统一的设计令牌系统,替代当前硬编码散落的样式值。
**完成标准:**
- 创建 `src/styles/tokens.css`,定义所有 Design Token颜色、间距、圆角、字体、阴影
- Ant Design Vue 主题通过 ConfigProvider 注入 Token 值
- 所有组件引用 Token 变量,不再硬编码颜色/间距值
- 主色统一为一种(消除 `#1677ff`/`#1890ff` 混用)
### R2: Agent-First 全屏布局
将 SideNav 多页面布局重构为四象限全屏布局。
**完成标准:**
- 顶部导航栏48pxLogo、任务选择器、Agent 状态指示、模式切换、设置入口
- 四象限可拖拽调整比例,每个象限可折叠
- 象限内容根据当前任务动态切换(对话/代码/终端/状态)
- 保留 Vue Router但页面切换变为象限内容切换而非整页跳转
- 侧边导航改为顶部极简导航 + 下拉菜单
### R3: 对话面板重构
将 ChatView 重构为对话面板,作为四象限的左上象限。
**完成标准:**
- 支持 Markdown 渲染(替代当前 `v-html`
- 流式输出 + 打字机效果
- 工具调用指示器(类似 Claude Code 的 `[Read]`/`[Edit]`/`[Bash]` 彩色标签)
- 上下文胶囊Context Pills显示当前关联的文件、技能
- 对话历史侧栏可折叠
### R4: 代码/预览面板
新增代码 Diff 查看和文件预览能力,作为右上象限。
**完成标准:**
- 代码 Diff 查看器:逐行 diff 高亮,支持 Accept/Reject
- 文件树浏览器:展示 Agent 修改的文件列表
- 工作流画布:当前 FlowCanvas 集成为此象限的一个 Tab
- 知识库管理:当前 KnowledgeBaseView 集成为此象限的一个 Tab
- Tab 切换:代码 / 工作流 / 知识库
### R5: 终端面板重构
将 TerminalView 重构为终端面板,作为左下象限。
**完成标准:**
- 暗色背景终端One Dark Pro 配色)
- 命令确认弹窗使用 Ant Design Modal替代当前原生 HTML
- 命令历史可折叠侧栏
- ANSI 颜色完整支持
### R6: 状态/监控面板
将 EvolutionView 精简后集成为右下象限。
**完成标准:**
- 进化面板从 6 个精简为 2-3 个核心面板:概览+指标、经验+坑点、用量
- 执行进度条:顶部显示当前任务完成度估算
- 节点状态动画:运行中节点脉冲动画
- 技能卡片网格:当前 SkillsView 集成为此象限的一个 Tab
- 设置页:当前 SettingsView 集成为此象限的一个 Tab分组化
- Tab 切换:监控 / 技能 / 设置
### R7: 工作流单页化
将工作流列表和编辑合并为单页 Tab 切换。
**完成标准:**
- 列表和编辑在同一象限内通过 Tab 切换,无需整页跳转
- 编辑模式NodePalette + FlowCanvas + PropertyPanel 保留
- 执行历史面板可折叠
### R8: 设置分组化
将设置页从平铺长表单拆分为 Tab 分组。
**完成标准:**
- 4 个分组 TabLLM 配置、技能管理、知识库设置、系统设置
- 每组最大宽度 600px居中布局
- 保存按钮每组独立
### R9: 过渡动画与微交互
为所有交互添加过渡动画。
**完成标准:**
- 象限折叠/展开平滑过渡200ms ease
- Tab 切换淡入淡出150ms
- 列表项加载交错渐入stagger 50ms
- 空状态:品牌化插图 + 引导文案
- 加载态:骨架屏替代 `<a-spin>`
### R10: 响应式断点
支持 1280px+ 桌面端断点。
**完成标准:**
- ≥1440px四象限完整展示
- 1280-1440px右下象限自动折叠为 Tab 栏
- <1280px显示提示"请使用更大屏幕"
- 象限比例记忆localStorage 保存用户调整的比例
## 5. 范围边界
### 在范围内
- Design Token 体系建立
- 四象限全屏布局重构
- 所有现有功能的迁移(对话、工作流、知识库、技能、终端、进化、设置)
- 浅色极简视觉风格实现
- 过渡动画和微交互
- 1280px+ 响应式断点
### 延迟到后续迭代
- 暗色模式(需 Design Token 暗色变体)
- 移动端适配
- Computer Use 功能实现(保留路由,显示"即将推出"
- Cmd+K 内联编辑(类似 Cursor
- @-mention 上下文引用
- 代码 Diff 的 Accept/Reject 实际回滚功能
### 不在本产品身份内
- 多用户协作/实时协同编辑
- 插件市场
- 代码编辑器(只读预览,不提供编辑能力)
## 6. 成功标准
1. **视觉一致性** — 零硬编码颜色值,所有样式通过 Design Token 引用
2. **信息密度** — 四象限同时展示 Agent 全部活动,无需切换页面
3. **交互流畅度** — 所有过渡动画 ≤200ms无卡顿
4. **响应式** — 1280px+ 屏幕正常使用,象限比例可调
5. **渐进式交付** — 分 2-3 个迭代完成,每个迭代可独立部署
## 7. 依赖与假设
- **假设** Vue Flow 和 ECharts 可在象限内正常渲染(需验证尺寸变化时的 resize
- **假设** Ant Design Vue 4.x 的 ConfigProvider 主题注入满足 Design Token 需求
- **依赖** 后端 WebSocket 推送已就绪(上一轮已实现)
- **依赖** 后端 API 路径不变,前端仅重构 UI 层
## 8. 竞品参考
| 参考工具 | 借鉴要素 |
|----------|----------|
| Devin | 四象限布局、Action Timeline、Intervention Button |
| v0.dev | 浅色极简风格、紫黑渐变强调色、Preview-First |
| Cursor | Cmd+K 交互理念后续迭代、Chat Panel 设计 |
| Claude Code | Tool Use Indicators、Permission Prompts |
| Trae Solo | Chat-First 全屏布局、Agent Mode Toggle |

View File

@ -0,0 +1,550 @@
---
title: "feat: AgentKit Phase 8 — 对话式 Agent 与自适应编排"
status: completed
created: 2026-06-07
plan_type: feat
depth: deep
origin: Phase 1-7 完成后架构能力评估 — 对话模式(20%)、多Agent协作(70%)、自适应规划(10%)三大差距
branch: feat/agentkit-phase8-chat-adaptive
---
# AgentKit Phase 8 — 对话式 Agent 与自适应编排
## Summary
Phase 8 将 AgentKit 从"任务执行框架"演进为"对话式 Agent 平台"补齐三大核心差距Chat 模式(多轮对话 + Human-in-the-Loop、自适应编排反思-重规划闭环)、高级多 Agent 协作Agent 间直接通信 + 动态角色协商。分三个阶段交付先建立对话基础设施U1-U3再实现自适应编排U4-U5最后补齐多 Agent 协作U6-U8
## Problem Frame
Phase 1-7 构建了完整的 Agent 执行框架ReAct 引擎、三层记忆、Pipeline 编排、MCP 协议、上下文压缩、自进化系统。但当前架构是 **"计划-执行"** 模式,存在三大差距:
1. **对话能力缺失20%**:无会话管理、无多轮对话、无 Human-in-the-Loop。`conversation_id` 字段存在但从未使用。每次任务提交是独立的ReAct 引擎不保留对话历史。
2. **自适应规划缺失10%**Pipeline 定义是静态的,执行失败后无"分析原因→修改计划→重新执行"闭环。Orchestrator 分解一次执行一次,无迭代优化。
3. **多 Agent 高级协作不足70%**Worker 之间只能通过 SharedWorkspace 间接传递数据,无直接通信。角色固定,无运行时协商。无 Supervisor 监控。
## Requirements
- R1: 支持多轮对话:用户可在同一会话中持续与 Agent 交互,对话历史自动持久化
- R2: 支持 Human-in-the-LoopAgent 可主动向用户提问并等待回复,用户可在执行中追问/打断
- R3: 支持流式 LLM 输出ReAct 引擎的 LLM 调用支持 token-by-token streaming
- R4: 支持自适应编排Pipeline 执行失败后可触发反思→重规划→重新执行闭环
- R5: 支持 Agent 间直接通信Worker Agent 之间可发送消息,无需通过 SharedWorkspace 间接传递
- R6: 支持动态角色协商Agent 可在运行时协商分工,而非固定角色分配
- R7: 所有新功能向后兼容,现有 Task 模式不受影响
- R8: Chat 模式和 Task 模式可共存,同一 Agent 实例可同时服务两种模式
---
## Key Technical Decisions
### KTD-1: 会话模型设计 — Session + Message 双层模型
**决策**:采用 `Session`(会话)+ `Message`消息双层模型Session 持有对话元数据和 Agent 绑定Message 持有单条消息内容和角色。
**理由**
- Session 管理生命周期(创建/活跃/暂停/关闭),支持超时自动归档
- Message 按 role 分类user/assistant/tool/system与 ReAct 引擎的 messages 列表自然映射
- Session 可关联多个 AgentOrchestrator 模式下Message 可标注来源 Agent
**替代方案**
- 单层 Conversation 模型:简单但无法表达多 Agent 参与、会话状态管理
- 纯 Redis 存储:高性能但无持久化保证,重启丢失
### KTD-2: Human-in-the-Loop 实现 — AskHumanTool + WebSocket 双向通信
**决策**:注册 `AskHumanTool` 作为 ReAct 可调用工具Agent 调用时通过 WebSocket 向客户端推送问题并等待回复。
**理由**
- 工具化实现与现有 ReAct 循环无缝集成,无需修改引擎核心
- WebSocket 已有基础设施,只需扩展为双向通信
- 非对话模式下 AskHumanTool 不注册,零侵入
**替代方案**
- 特殊消息类型:需要修改 ReAct 引擎核心循环,侵入性高
- HTTP 轮询:延迟高,用户体验差
### KTD-3: 流式 LLM 输出 — LLMGateway.chat_stream() + ReActEvent 扩展
**决策**:在 LLMGateway 新增 `chat_stream()` 方法返回 `AsyncIterator[str]`ReAct 引擎在 `execute_stream()` 中消费并包装为 `token` 类型 ReActEvent。
**理由**
- 流式输出是 Chat 模式的基本要求,用户需要实时看到 Agent 的"思考过程"
- 在 LLM Gateway 层实现流式,所有 Provider 统一接口
- ReActEvent 已有 streaming 机制,只需新增 `token` 事件类型
### KTD-4: 自适应编排 — Reflect-then-Replan 模式
**决策**:在 PipelineEngine 执行失败时,触发 LLM 反思分析失败原因,生成修正后的 Pipeline 重新执行,最多重试 N 次。
**理由**
- 与现有 PipelineEngine 的重试机制互补:重试处理瞬态错误,反思处理结构性错误
- LLM 反思可利用执行上下文(哪步失败、错误信息、已完成步骤的输出)做出更智能的调整
- 可配置开关,默认关闭,不影响现有 Pipeline 行为
### KTD-5: Agent 间通信 — MessageBus 抽象层
**决策**:新增 `MessageBus` 抽象层,基于 Redis Streams 实现,支持 Agent 间点对点和广播通信。
**理由**
- Redis Streams 比 Pub/Sub 可靠(消息持久化 + 消费者组),比 Queue 灵活(多消费者)
- 抽象层解耦底层实现,未来可替换为 Kafka/NATS
- 与现有 SharedWorkspace 互补Workspace 用于共享状态MessageBus 用于事件通知
---
## High-Level Technical Design
```mermaid
flowchart TB
subgraph Chat["Chat 模式(新增)"]
Client[客户端] <-->|WebSocket 双向| WS[WS Handler]
WS -->|发送消息| SM[SessionManager]
SM -->|加载会话| SP[Session + Messages]
SM -->|调用 Agent| Agent[ReAct Engine]
Agent -->|AskHumanTool| WS
Agent -->|token streaming| WS
end
subgraph Task["Task 模式(现有)"]
API[REST API] -->|提交任务| Dispatcher[TaskDispatcher]
Dispatcher -->|分发| Agent
end
subgraph Adaptive["自适应编排(新增)"]
PE[PipelineEngine] -->|执行失败| Reflector[LLM Reflector]
Reflector -->|分析原因| Replanner[Replanner]
Replanner -->|生成新计划| PE
end
subgraph MultiAgent["多 Agent 协作(增强)"]
Orch[Orchestrator] -->|分配任务| W1[Worker 1]
Orch -->|分配任务| W2[Worker 2]
W1 <-->|MessageBus| W2
Orch -->|动态协商| Negotiator[Role Negotiator]
end
Agent --> PE
Agent --> Orch
style Chat fill:#c8e6c9,color:#1a5e20
style Adaptive fill:#bbdefb,color:#0d47a1
style MultiAgent fill:#fff3e0,color:#e65100
```
---
## Scope Boundaries
### In Scope
- Session/Message 模型与持久化
- Chat APIREST + WebSocket 双向通信)
- AskHumanTool 实现
- LLMGateway.chat_stream() 流式输出
- ReAct 引擎 token streaming
- PipelineEngine 反思-重规划闭环
- MessageBus 抽象层与 Redis Streams 实现
- Agent 间点对点通信
### Deferred to Follow-Up Work
- 多租户隔离与用户认证OAuth/JWT
- Agent 沙箱与工具权限控制
- 声明式 Pipeline YAML 定义
- Pipeline 运行时监控/可视化
- 跨 Agent 记忆共享
- 记忆主动遗忘/压缩策略
- 性能基准测试与混沌测试
- 动态角色协商Phase 8 仅实现基础通信,协商逻辑留待 Phase 9
---
## Implementation Units
### U1. Session/Message 模型与持久化
**Goal:** 建立对话基础设施 — Session 生命周期管理 + Message 持久化存储
**Requirements:** R1, R7
**Dependencies:** 无
**Files:**
- `src/agentkit/session/__init__.py` (新建)
- `src/agentkit/session/models.py` (新建) — Session + Message 数据模型
- `src/agentkit/session/manager.py` (新建) — SessionManager 会话管理器
- `src/agentkit/session/store.py` (新建) — 会话存储InMemory + Redis 双后端)
- `tests/unit/test_session_models.py` (新建)
- `tests/unit/test_session_manager.py` (新建)
- `tests/unit/test_session_store.py` (新建)
**Approach:**
- `Session` 模型:`session_id`, `agent_name`, `status` (active/paused/closed), `metadata`, `created_at`, `updated_at`, `ttl`
- `Message` 模型:`message_id`, `session_id`, `role` (user/assistant/tool/system), `content`, `tool_call_id` (可选), `agent_name` (可选,多 Agent 场景), `created_at`
- `SessionManager`:创建/获取/暂停/关闭会话,追加消息,加载对话历史,超时归档
- `SessionStore`InMemory测试用+ Redis生产用双后端接口统一
- Session 与 Agent 绑定:创建时指定 `agent_name`,后续消息自动路由到该 Agent
- 对话历史加载:`get_messages(session_id)` 返回完整消息列表,可直接传入 ReAct 引擎
**Patterns to follow:** `TaskStore` 的 InMemory + Redis 双后端模式(`src/agentkit/server/task_store.py`
**Test scenarios:**
- 创建 Session 返回有效 session_id状态为 active
- 追加 Message 后通过 get_messages 获取完整历史
- Session 状态转换active → paused → active → closed
- 关闭的 Session 不接受新 Message
- InMemory 和 Redis Store 行为一致
- Session TTL 过期后自动归档为 closed
- 获取不存在的 Session 返回 None
- 大量消息的会话加载性能1000+ messages
**Verification:** Session 模型可创建、持久化、加载Message 可追加和查询,状态转换正确
---
### U2. Chat API 与 WebSocket 双向通信
**Goal:** 暴露 Chat 模式的 REST + WebSocket API支持多轮对话和实时交互
**Requirements:** R1, R2, R7, R8
**Dependencies:** U1
**Files:**
- `src/agentkit/server/routes/chat.py` (新建) — Chat REST + WebSocket 路由
- `src/agentkit/server/app.py` (修改) — 注册 Chat 路由,注入 SessionManager
- `src/agentkit/server/config.py` (修改) — 新增 session 配置
- `tests/unit/test_chat_routes.py` (新建)
- `tests/integration/test_chat_e2e.py` (新建)
**Approach:**
- REST 端点:
- `POST /api/v1/chat/sessions` — 创建会话
- `GET /api/v1/chat/sessions/{id}` — 获取会话信息
- `POST /api/v1/chat/sessions/{id}/messages` — 发送消息(同步模式,等待 Agent 完整回复)
- `GET /api/v1/chat/sessions/{id}/messages` — 获取对话历史
- `DELETE /api/v1/chat/sessions/{id}` — 关闭会话
- WebSocket 端点:`/ws/chat/{session_id}` — 双向通信
- 客户端 → 服务端:`{"type": "message", "content": "..."}`, `{"type": "cancel"}`
- 服务端 → 客户端:`{"type": "token", "content": "..."}`, `{"type": "tool_call", ...}`, `{"type": "ask_human", "question": "..."}`, `{"type": "final_answer", ...}`
- Chat 路由与现有 Task 路由共存,同一 Agent 实例可同时服务两种模式
- WebSocket handler 接收用户消息后,加载 Session 历史 + 新消息,调用 Agent.execute_stream()
**Patterns to follow:** 现有 `routes/tasks.py` 的 stream 端点 + `routes/ws.py` 的 WebSocket handler
**Test scenarios:**
- POST /chat/sessions 创建会话返回 session_id
- POST /chat/sessions/{id}/messages 发送消息获得 Agent 回复
- GET /chat/sessions/{id}/messages 返回完整对话历史
- WebSocket 连接后发送消息收到 token 流式事件
- WebSocket 发送 cancel 消息取消 Agent 执行
- 关闭的 Session 发送消息返回 400 错误
- 不存在的 Session 返回 404
- 同一 Agent 同时服务 Chat 和 Task 模式
**Verification:** Chat API 端到端可用WebSocket 双向通信正常,多轮对话历史持久化
---
### U3. AskHumanTool + 流式 LLM 输出
**Goal:** 实现 Human-in-the-Loop 和 token-by-token 流式输出
**Requirements:** R2, R3, R7
**Dependencies:** U1, U2
**Files:**
- `src/agentkit/tools/ask_human.py` (新建) — AskHumanTool
- `src/agentkit/tools/__init__.py` (修改) — 导出 AskHumanTool
- `src/agentkit/llm/gateway.py` (修改) — 新增 chat_stream() 方法
- `src/agentkit/llm/protocol.py` (修改) — 新增 LLMStreamResponse
- `src/agentkit/core/react.py` (修改) — execute_stream() 支持 token 事件
- `src/agentkit/core/config_driven.py` (修改) — Chat 模式下注册 AskHumanTool
- `tests/unit/test_ask_human_tool.py` (新建)
- `tests/unit/test_llm_streaming.py` (新建)
- `tests/unit/test_react_token_streaming.py` (新建)
**Approach:**
- `AskHumanTool`
- 参数:`question: str`, `options: list[str] | None`
- 执行时通过 `context` 获取 WebSocket 连接,向客户端推送问题
- 使用 `asyncio.Future` 等待用户回复,设置超时(默认 60s
- 超时返回默认回复或取消当前 ReAct 循环
- 仅在 Chat 模式下注册(通过 ConfigDrivenAgent 的 mode 参数判断)
- `LLMGateway.chat_stream()`
- 新增方法,签名与 `chat()` 一致,返回 `AsyncIterator[LLMStreamChunk]`
- `LLMStreamChunk``content: str`, `finish_reason: str | None`, `usage: dict | None`
- 各 Provider 实现 `_stream_chat()` 方法,调用底层 SDK 的 stream API
- 优先实现 OpenAI 和 Anthropic 的流式(覆盖 80% 用例)
- ReAct token streaming
- `execute_stream()` 中调用 `llm_gateway.chat_stream()` 替代 `chat()`
- 新增 `ReActEvent` 类型:`type="token", content=str`
- token 事件直接透传到 WebSocket 客户端
**Patterns to follow:** `HeadroomRetrieveTool` 的条件注册模式;现有 Provider 的 `chat()` 实现模式
**Test scenarios:**
- AskHumanTool 执行后向客户端推送问题
- AskHumanTool 收到用户回复后返回结果
- AskHumanTool 超时后返回默认回复
- 非 Chat 模式下 AskHumanTool 不注册
- LLMGateway.chat_stream() 返回 AsyncIterator
- 流式输出的内容拼接后与完整输出一致
- ReAct execute_stream() 产出 token 类型事件
- 流式输出中断(取消)时正确清理资源
**Verification:** Agent 可在执行中向用户提问并等待回复LLM 输出以 token 粒度流式推送
---
### U4. PipelineEngine 反思-重规划闭环
**Goal:** Pipeline 执行失败后自动反思分析原因,生成修正计划重新执行
**Requirements:** R4, R7
**Dependencies:** 无(独立于 U1-U3
**Files:**
- `src/agentkit/orchestrator/reflection.py` (新建) — PipelineReflector + PipelineReplanner
- `src/agentkit/orchestrator/pipeline_engine.py` (修改) — 集成反思-重规划
- `src/agentkit/orchestrator/pipeline_schema.py` (修改) — 新增 AdaptiveConfig
- `tests/unit/test_pipeline_reflection.py` (新建)
- `tests/integration/test_adaptive_pipeline.py` (新建)
**Approach:**
- `PipelineReflector`
- 输入:失败的 Pipeline + 执行上下文(哪步失败、错误信息、已完成步骤输出)
- 调用 LLM 分析失败原因输出结构化反思报告failure_type, root_cause, suggested_fix
- failure_type 分类:`input_error`(输入问题)、`resource_error`(资源不可用)、`logic_error`(步骤逻辑错误)、`timeout`(超时)
- `PipelineReplanner`
- 输入:原始 Pipeline + 反思报告
- 调用 LLM 生成修正后的 Pipeline调整步骤顺序、替换失败步骤、增加前置检查
- 保留已完成步骤的结果,仅重新执行失败及后续步骤
- `AdaptiveConfig`
- `enabled: bool = False` — 默认关闭
- `max_reflections: int = 3` — 最大反思次数
- `reflection_model: str = "default"` — 反思使用的 LLM 模型
- `skip_stages: list[str] = []` — 不参与反思的步骤
- PipelineEngine 集成:
- 执行失败时检查 AdaptiveConfig.enabled
- 启用则触发反思→重规划→重新执行循环
- 每次反思记录到 PipelineResult.metadata
**Patterns to follow:** `StepRetryPolicy` 的配置模式;`SagaOrchestrator` 的补偿追踪模式
**Test scenarios:**
- Pipeline 执行失败且 adaptive 未启用时,行为与现有一致
- Pipeline 执行失败且 adaptive 启用时,触发反思
- 反思报告包含 failure_type 和 root_cause
- 重规划生成的新 Pipeline 保留已完成步骤结果
- 达到 max_reflections 后停止重试
- 反思-重规划成功后 Pipeline 最终完成
- 连续反思仍失败时返回最终失败结果
- 集成测试:模拟资源错误→反思→调整→成功
**Verification:** Pipeline 执行失败后可自动反思并重规划,最终完成或达到最大重试次数
---
### U5. Orchestrator 自适应任务分解
**Goal:** Orchestrator 支持迭代式任务分解 — 执行→评估→再分解
**Requirements:** R4, R7
**Dependencies:** U4
**Files:**
- `src/agentkit/core/orchestrator.py` (修改) — 新增 execute_adaptive() 方法
- `tests/unit/test_orchestrator_adaptive.py` (新建)
- `tests/integration/test_orchestrator_adaptive.py` (新建)
**Approach:**
- `execute_adaptive()` 方法:
- 第一轮:与现有 execute() 一致,分解任务并执行
- 评估LLM 评估子任务结果质量0-1 评分 + 改进建议)
- 如果评估不通过且未达 max_iterations
- 基于评估反馈重新分解未达标的子任务
- 保留已完成的子任务结果
- 执行新分解的子任务
- 如果评估通过或达到 max_iterations汇总结果返回
- 新增 `OrchestratorConfig`
- `adaptive: bool = False`
- `max_iterations: int = 3`
- `quality_threshold: float = 0.7`
**Patterns to follow:** `DynamicPipeline.execute_loop()` 的迭代模式
**Test scenarios:**
- adaptive=False 时行为与现有 execute() 一致
- 第一轮评估通过时直接返回结果
- 第一轮评估不通过时触发再分解
- 再分解保留已完成子任务结果
- 达到 max_iterations 时返回当前最佳结果
- 评估反馈正确传递给 LLM 用于再分解
- 集成测试:复杂任务经两轮分解后完成
**Verification:** Orchestrator 可根据执行结果迭代优化任务分解
---
### U6. MessageBus 抽象层与 Redis Streams 实现
**Goal:** 新增 Agent 间通信基础设施 — MessageBus 抽象 + Redis Streams 实现
**Requirements:** R5, R7
**Dependencies:** 无(独立于 U1-U5
**Files:**
- `src/agentkit/bus/__init__.py` (新建)
- `src/agentkit/bus/protocol.py` (新建) — MessageBus Protocol 定义
- `src/agentkit/bus/message.py` (新建) — AgentMessage 消息模型
- `src/agentkit/bus/redis_bus.py` (新建) — Redis Streams 实现
- `src/agentkit/bus/memory_bus.py` (新建) — InMemory 实现(测试用)
- `tests/unit/test_bus_protocol.py` (新建)
- `tests/unit/test_redis_bus.py` (新建)
- `tests/unit/test_memory_bus.py` (新建)
**Approach:**
- `AgentMessage` 模型:
- `message_id: str` — UUID
- `sender: str` — 发送者 Agent 名称
- `recipient: str | None` — 接收者None 为广播)
- `topic: str` — 消息主题(如 "task.result", "agent.status"
- `payload: dict[str, Any]` — 消息内容
- `timestamp: datetime` — 发送时间
- `correlation_id: str | None` — 关联 ID请求-响应模式)
- `MessageBus` Protocol
- `publish(message: AgentMessage) -> None` — 发布消息
- `subscribe(agent_name: str, handler: Callable) -> None` — 订阅消息
- `unsubscribe(agent_name: str) -> None` — 取消订阅
- `request(message: AgentMessage, timeout: float) -> AgentMessage` — 请求-响应模式
- `broadcast(message: AgentMessage) -> None` — 广播消息
- `RedisMessageBus`
- 使用 Redis StreamsXADD/XREADGROUP实现
- 每个 Agent 一个 Consumer Group支持多消费者
- 消息确认机制XACK防止消息丢失
- 死信队列:超过重试次数的消息转入死信
- `InMemoryMessageBus`
- asyncio.Queue 实现,测试用
- 行为与 Redis 实现一致
**Patterns to follow:** `SharedWorkspace` 的 Redis + InMemory 双模式;`TaskDispatcher` 的 Redis Queue 模式
**Test scenarios:**
- 点对点消息Agent A 发送消息给 Agent BB 收到
- 广播消息Agent A 广播,所有订阅者收到
- 请求-响应Agent A 发送请求Agent B 回复A 收到响应
- 请求超时Agent A 发送请求,无响应,超时后抛出异常
- 取消订阅后不再收到消息
- InMemory 和 Redis 实现行为一致
- 消息确认:消费者处理完消息后 XACK
- 死信队列:消息重试 3 次后转入死信
**Verification:** Agent 间可通过 MessageBus 点对点和广播通信,请求-响应模式正常工作
---
### U7. Orchestrator 集成 MessageBus
**Goal:** Orchestrator 通过 MessageBus 协调 Worker支持 Worker 间直接通信
**Requirements:** R5, R7
**Dependencies:** U6
**Files:**
- `src/agentkit/core/orchestrator.py` (修改) — 注入 MessageBusWorker 间通信
- `src/agentkit/core/agent_pool.py` (修改) — Agent 注册到 MessageBus
- `src/agentkit/server/app.py` (修改) — 初始化 MessageBus
- `tests/unit/test_orchestrator_bus.py` (新建)
- `tests/integration/test_multi_agent_communication.py` (新建)
**Approach:**
- Orchestrator 注入 MessageBus
- 创建 Orchestrator 时传入可选的 MessageBus
- Worker 执行子任务时,通过 MessageBus 发布进度和中间结果
- Worker 之间可直接通信(如 Agent A 请求 Agent B 的中间结果)
- AgentPool 集成:
- Agent 创建时自动注册到 MessageBus订阅自己的消息
- Agent 销毁时取消订阅
- Agent 的 `handle_message()` 方法处理收到的消息
- 消息路由:
- 点对点:`recipient="agent_name"`
- 广播:`topic="task.progress"`
- Orchestrator 订阅所有 Worker 的进度和结果
**Patterns to follow:** `MCPManager` 的生命周期管理模式
**Test scenarios:**
- Worker 通过 MessageBus 发布进度Orchestrator 收到
- Worker A 直接请求 Worker B 的中间结果
- Agent 创建时自动注册到 MessageBus
- Agent 销毁时取消订阅
- 无 MessageBus 时 Orchestrator 行为不变(向后兼容)
- 集成测试:两 Worker 协作完成需要中间结果交换的任务
**Verification:** Orchestrator 可通过 MessageBus 协调 WorkerWorker 间可直接通信
---
### U8. Chat 模式集成测试与文档更新
**Goal:** 端到端验证 Chat 模式 + 自适应编排 + 多 Agent 通信的集成
**Requirements:** R1-R8
**Dependencies:** U1-U7
**Files:**
- `tests/integration/test_chat_adaptive_e2e.py` (新建) — Chat + 自适应编排 E2E
- `tests/integration/test_chat_multi_agent_e2e.py` (新建) — Chat + 多 Agent 协作 E2E
- `configs/llm_config.yaml` (修改) — 新增 session/bus/adaptive 配置段
**Approach:**
- Chat + 自适应 E2E
- 创建会话 → 发送复杂任务 → Agent 执行 Pipeline → 失败触发反思 → 重规划 → 成功 → 多轮对话继续
- Chat + 多 Agent E2E
- 创建会话 → 发送多 Agent 任务 → Orchestrator 分解 → Worker 通过 MessageBus 协作 → 结果汇总 → 用户追问
- 配置更新:
- `session` 段:`backend: redis`, `ttl: 3600`
- `bus` 段:`backend: redis_streams`
- `adaptive` 段:`enabled: true`, `max_reflections: 3`
**Test scenarios:**
- Chat + 自适应:多轮对话中 Pipeline 失败后自动反思重规划
- Chat + 多 Agent用户通过 Chat 模式触发多 Agent 协作
- AskHumanToolAgent 在执行中向用户提问,用户回复后继续
- 流式输出Chat 模式下 token-by-token 推送
- 配置加载:新配置段正确解析
**Verification:** 所有新功能端到端可用,配置正确加载
---
## Risks & Mitigations
| 风险 | 影响 | 缓解 |
|------|------|------|
| WebSocket 双向通信复杂度高 | 中 | 先实现 REST 同步模式WebSocket 作为增强 |
| LLM 流式输出 Provider 适配工作量大 | 中 | 优先适配 OpenAI/Anthropic其余 Provider 降级为非流式 |
| 反思-重规划 LLM 调用增加成本 | 低 | 默认关闭,可配置;反思使用低成本模型 |
| Redis Streams 运维复杂度 | 低 | InMemory 实现可用于开发/测试,生产用 Redis |
| Session 大量消息加载性能 | 中 | 分页加载 + 摘要压缩(长期消息自动摘要) |
## Open Questions
- Q1: Session 消息的分页策略?默认按时间倒序分页,还是按 ReAct 循环分页?
- Q2: AskHumanTool 超时后的默认行为?返回默认回复还是抛出异常让 ReAct 处理?
- Q3: 反思-重规划是否需要人工确认?自动执行还是需要用户审批后才能重规划?
## System-Wide Impact
- **API 层**:新增 5 个 REST 端点 + 1 个 WebSocket 端点
- **ReAct 引擎**execute_stream() 扩展 token 事件类型,非破坏性变更
- **LLM Gateway**:新增 chat_stream() 方法,所有 Provider 需实现(可降级为非流式)
- **Orchestrator**:新增 execute_adaptive(),现有 execute() 不变
- **PipelineEngine**:反思-重规划为可选增强,默认关闭
- **新模块**`session/`, `bus/` — 两个新子包
- **配置**:新增 session/bus/adaptive 配置段

View File

@ -0,0 +1,308 @@
---
title: "feat: AgentKit 分层记忆系统 — SOUL/USER/MEMORY/DAILY 注入"
status: completed
created: 2026-06-08
plan_type: feat
depth: standard
---
## Summary
为 AgentKit 实现分层记忆注入系统,参考 Hermes/OpenClaw 架构,在每次会话启动时将 SOUL.mdAgent 人格、USER.md用户档案、MEMORY.mdAgent 工作笔记、DAILY.md最近日志注入 system prompt并提供 MemoryTool 让 Agent 在对话中读写记忆。采用 TDD 方式开发。
## Problem Frame
当前 `agentkit chat` 的 system prompt 是硬编码的一句话Agent 无法:
1. 拥有持久人格(名字、性格、说话方式)
2. 记住用户信息(称呼、习惯、职业)
3. 跨会话保留工作笔记
4. 回顾近期决策和对话摘要
虽然已有 `memory/` 模块Working/Episodic/Semantic但这些是面向 Pipeline 编排的底层记忆,不是面向 Chat 场景的"人格+档案+笔记"注入。
## Requirements
- **R1**: SOUL.md — Agent 人格定义,每次会话必须加载
- **R2**: USER.md — 用户档案,每次会话必须加载
- **R3**: MEMORY.md — Agent 工作笔记每次会话必须加载Agent 可通过工具修改
- **R4**: DAILY.md — 最近 2 天日志每次会话自动加载Agent 可通过工具追加
- **R5**: MemoryTool — Agent 可在对话中 add/replace/remove 记忆条目
- **R6**: 文件存储在 `~/.agentkit/` 目录下,首次运行自动创建默认 SOUL.md
- **R7**: 容量上限 — SOUL ~2000 字符、USER ~1400 字符、MEMORY ~2200 字符、DAILY ~1000 字符/天
- **R8**: 注入方式 — 会话开始时一次性注入 system prompt会话内不变利于 KV 缓存)
- **R9**: 与现有 `agentkit chat` 命令集成
## Key Technical Decisions
### KTD1: 记忆文件格式 — Markdown sections
每个 .md 文件使用 `## Section` 格式组织内容,方便 Agent 通过 MemoryTool 的 `replace` 操作精确替换某个 section而非重写整个文件。
```
## 身份
我是小王,一个专业的 AI 助手。
## 性格
友好、耐心、注重细节
## 说话方式
简洁专业,偶尔使用比喻
```
### KTD2: 注入格式 — 分段注入 system prompt
将记忆内容作为 system prompt 的结构化段落注入,每段用明确的标记分隔:
```
<agent-identity>
[SOUL.md content]
</agent-identity>
<user-profile>
[USER.md content]
</user-profile>
<agent-notes>
[MEMORY.md content]
</agent-notes>
<recent-activity>
[DAILY.md content]
</recent-activity>
[原始 system prompt 或默认指令]
```
### KTD3: MemoryTool 操作模型 — section 级别 CRUD
- `memory_add(section, content)` — 追加内容到指定 section不存在则创建
- `memory_replace(file, section, old_text, new_text)` — 精确替换 section 内的文本
- `memory_remove(file, section)` — 删除整个 section
- `memory_read(file)` — 读取整个文件内容
file 参数: `soul` | `user` | `memory` | `daily`
### KTD4: 日志自动生成 — 会话结束时摘要
会话结束时(用户 /quit 或 Ctrl+C自动调用 LLM 生成当天日志摘要追加到 DAILY.md。保留最近 2 天日志,更早的自动归档。
### KTD5: 存储路径 — `~/.agentkit/memories/`
```
~/.agentkit/
├── SOUL.md # Agent 人格
├── memories/
│ ├── USER.md # 用户档案
│ ├── MEMORY.md # 工作笔记
│ └── daily/
│ ├── 2026-06-07.md
│ └── 2026-06-08.md
└── agentkit.yaml # 配置文件(已存在)
```
## Implementation Units
### U1. MemoryFile — 记忆文件读写与容量管理
**Goal:** 实现记忆文件的读写、容量限制、section 级别操作
**Dependencies:** None
**Files:**
- `src/agentkit/memory/profile.py` — MemoryFile 类
- `tests/unit/test_memory_profile.py` — 测试
**Approach:**
- `MemoryFile` 类:封装单个 .md 文件的读写、section 解析、容量检查
- `read()` / `write()` — 读写整个文件
- `read_section(name)` / `add_section(name, content)` / `replace_section(name, old, new)` / `remove_section(name)` — section 级别操作
- `trim_to_budget(char_budget)` — 超出容量时从末尾裁剪(保留前面的 section
- 文件不存在时返回空字符串,不抛异常
**Execution note:** TDD — 先写测试再实现
**Test scenarios:**
- 读取不存在的文件返回空字符串
- 写入并读回完整内容
- 解析 `## Section` 格式read_section 返回指定 section 内容
- add_section 追加新 section
- replace_section 精确替换 section 内文本
- remove_section 删除指定 section
- 超出 char_budget 时 trim_to_budget 裁剪
- 空文件 read_section 返回空
**Verification:** 所有测试通过
---
### U2. MemoryStore — 多文件记忆管理器
**Goal:** 管理 SOUL/USER/MEMORY/DAILY 四类记忆文件,提供统一的加载和注入接口
**Dependencies:** U1
**Files:**
- `src/agentkit/memory/profile.py` — MemoryStore 类(同文件)
- `tests/unit/test_memory_profile.py` — 追加测试
**Approach:**
- `MemoryStore(base_dir)` — 接受 `~/.agentkit` 路径
- `load_all()` — 加载所有记忆文件,返回 `MemorySnapshot` dataclass
- `build_system_prompt(snapshot, base_prompt)` — 将记忆注入 system prompt
- `get_file(file_key)` — 返回指定 MemoryFilefile_key: soul/user/memory/daily
- `load_daily_logs(days=2)` — 加载最近 N 天日志
- `archive_old_dailies(days=2)` — 归档超过 N 天的日志
- `ensure_defaults()` — 首次运行创建默认 SOUL.md
- `MemorySnapshot` dataclass: soul, user, memory, daily, total_chars
**Execution note:** TDD
**Test scenarios:**
- MemoryStore 初始化,目录不存在时自动创建
- load_all() 返回 MemorySnapshot
- build_system_prompt() 正确注入所有段落
- build_system_prompt() 无记忆文件时只返回 base_prompt
- ensure_defaults() 创建默认 SOUL.md
- load_daily_logs() 加载最近 2 天日志
- archive_old_dailies() 归档旧日志
- 容量超限时 build_system_prompt 仍能工作trim
**Verification:** 所有测试通过
---
### U3. MemoryTool — Agent 可调用的记忆操作工具
**Goal:** 实现 Agent 在对话中读写记忆的工具
**Dependencies:** U1, U2
**Files:**
- `src/agentkit/tools/memory_tool.py` — MemoryTool 类
- `tests/unit/test_memory_tool.py` — 测试
**Approach:**
- 继承 `Tool` 基类
- `memory_add(file, section, content)` — 追加内容
- `memory_replace(file, section, old_text, new_text)` — 替换内容
- `memory_remove(file, section)` — 删除 section
- `memory_read(file)` — 读取文件
- file 参数限定为 `soul` | `user` | `memory` | `daily`
- 操作后自动 trim 到容量上限
- 返回操作结果和当前文件内容摘要
**Execution note:** TDD
**Test scenarios:**
- memory_add 创建新 section
- memory_add 追加到已有 section
- memory_replace 精确替换文本
- memory_replace old_text 不存在时返回错误
- memory_remove 删除 section
- memory_read 返回文件内容
- 无效 file 参数返回错误
- 操作后内容不超容量上限
**Verification:** 所有测试通过
---
### U4. Chat 集成 — 记忆注入 + MemoryTool + 日志生成
**Goal:** 将记忆系统集成到 `agentkit chat` 命令
**Dependencies:** U2, U3
**Files:**
- `src/agentkit/cli/chat.py` — 修改
- `tests/unit/test_chat_memory_integration.py` — 测试
**Approach:**
- `_chat_async()` 启动时:
1. 初始化 `MemoryStore(base_dir=~/.agentkit)`
2. 调用 `ensure_defaults()` 创建默认文件
3. `load_all()` 加载记忆
4. `build_system_prompt(snapshot, base_prompt)` 构建完整 system prompt
5. 将 `MemoryTool(memory_store)` 加入 tools 列表
- 会话结束时:
1. 调用 LLM 生成当天对话摘要
2. 追加到 DAILY.md
3. 归档旧日志
- `/clear` 命令不清除记忆文件,只清除会话历史
**Execution note:** TDD
**Test scenarios:**
- chat 启动时自动加载记忆注入 system prompt
- 无记忆文件时使用默认 SOUL.md
- MemoryTool 在对话中可用
- 会话结束时生成日志摘要
- /clear 不影响记忆文件
- 记忆跨 /clear 会话持久化
**Verification:** 所有测试通过,手动 `agentkit chat` 验证
---
### U5. Onboarding 集成 — 首次引导创建 SOUL.md
**Goal:** 在 onboarding 向导中增加 Agent 人格配置步骤
**Dependencies:** U2
**Files:**
- `src/agentkit/cli/onboarding.py` — 修改
- `tests/unit/test_onboarding.py` — 追加测试
**Approach:**
- onboarding 最后一步增加可选的 Agent 人格配置:
- Agent 名字(默认 "AgentKit"
- 性格描述(默认 "专业、友好、注重细节"
- 说话方式(默认 "简洁清晰"
- 写入默认 SOUL.md
- 可跳过(使用默认值)
**Execution note:** TDD
**Test scenarios:**
- onboarding 生成默认 SOUL.md
- 自定义名字写入 SOUL.md
- 跳过时使用默认值
**Verification:** 所有测试通过
## Scope Boundaries
### In Scope
- SOUL/USER/MEMORY/DAILY 四层记忆文件
- MemoryFile section 级别 CRUD
- MemoryStore 统一加载和注入
- MemoryTool Agent 可调用工具
- Chat 命令集成
- Onboarding 集成
- 日志自动生成
### Out of Scope
- 向量检索记忆(已有 EpisodicMemory
- Redis/PostgreSQL 后端(已有 WorkingMemory/SemanticMemory
- 多 Agent 共享记忆(后续)
- 记忆版本控制(后续)
- 记忆加密(后续)
### Deferred to Follow-Up Work
- 记忆导入/导出命令
- 记忆搜索(在大量笔记中搜索)
- 与 EpisodicMemory 的整合(将 DAILY 日志同步到 Episodic
## Open Questions
None — 所有设计决策已在 KTD 中明确。
## Risks & Mitigations
| 风险 | 缓解措施 |
|------|---------|
| system prompt 过长占用 token | 容量上限 + trim_to_budget |
| Agent 恶意修改 SOUL.md | SOUL.md 可设为只读(后续),当前信任 Agent |
| 日志文件无限增长 | archive_old_dailies 自动归档,保留最近 2 天 |
| 文件并发写入冲突 | 单进程 chat 场景无并发问题 |

View File

@ -0,0 +1,597 @@
---
title: "feat: AgentKit Multi-Agent Marketplace 架构演进(修订版)"
status: active
date: 2026-06-09
depth: deep
origin: docs/brainstorms/2026-06-09-clawith-research-prompt.md
revision: v2
revision_reason: "基于 2026-06-10 代码增量分析(+36863 行161 文件),修正原方案中与现有代码重叠/冲突/不适用的部分"
---
# AgentKit Multi-Agent Marketplace 架构演进方案(修订版)
## 修订说明
原方案v1基于 Phase 1-8 代码编写,但代码库在 2026-06-10 有大量新增161 files, +36863 lines包含多个与原方案重叠的实现。本次修订基于最新代码逐项评估修正不适用的部分。
---
## 原方案问题评估
### 问题 1U6 Plan-and-Execute 引擎 — 与现有代码大量重叠
**现有实现**
- `core/goal_planner.py`594 行GoalPlanner 已实现目标→结构化执行计划的分解,含规则/模板+LLM 双模式
- `core/plan_executor.py`518 行PlanExecutor 已实现按计划逐步执行
- `core/plan_checker.py`739 行PlanChecker 已实现计划检查和复盘
- `core/plan_schema.py`148 行ExecutionPlan/PlanStep/PlanStepStatus/SkillGap 数据模型
- `orchestrator/reflection.py`370 行PipelineReflector + PipelineReplanner 已实现反思-重规划
**原方案 U6 的问题**:计划"新增 `core/plan_exec.py`",但 `core/plan_executor.py` 已存在且功能完整。GoalPlanner + PlanExecutor + PipelineReplanner 三者组合已覆盖 Plan-and-Execute 的核心流程。
**修正**U6 不再新建引擎,而是将现有 GoalPlanner/PlanExecutor/PipelineReplanner 封装为 `plan_exec` execution_mode 的执行引擎适配器。
### 问题 2U7 Reflexion 引擎 — 与现有代码部分重叠
**现有实现**
- `orchestrator/reflection.py`PipelineReflector 已实现 LLM 反思分析
- `evolution/reflector.py` + `evolution/llm_reflector.py`LLMReflector 已实现反思
- `evolution/lifecycle.py`EvolutionMixin 已实现反思→优化→A/B测试闭环
**原方案 U7 的问题**Reflexion 的核心逻辑(反思+重试)在 EvolutionMixin 和 PipelineReflector 中已有实现,但缺少"执行中自我评估+重试"的循环。
**修正**U7 简化为在 ReActEngine 基础上增加 Evaluate→Reflect→Retry 循环节点,复用 LLMReflector。
### 问题 3U1 Concierge — 与现有 Chat 系统重叠
**现有实现**
- `chat/skill_routing.py`168 行SkillRoutingResult + parse_skill_prefix() + route_to_skill()
- `cli/chat.py`422 行CLI Chat 界面,含 @skill: 前缀路由
- `server/routes/chat.py`REST + WebSocket Chat API
- `session/store.py`Session/Message 管理
**原方案 U1 的问题**Concierge 的"统一入口+对话上下文+路由"功能在现有 Chat 系统中已部分实现。skill_routing.py 已实现 @skill: 前缀路由chat.py 已实现对话上下文管理。
**修正**U1 不再新建 Concierge 模块,而是在现有 Chat 系统上扩展 CostAwareRouter 能力。Concierge 的对话管理复用 SessionManager路由扩展复用 skill_routing.py。
### 问题 4U2 CostAwareRouter — 与现有路由重叠
**现有实现**
- `chat/skill_routing.py`:已实现 Skill 路由(@skill: 前缀 + 关键词匹配)
- `router/intent.py`IntentRouter 三级路由(关键词+LLM
- `skills/registry.py`259 行SkillRegistry 已实现 Skill 查找和匹配
**原方案 U2 的问题**Layer 0 的 Skill 匹配和 Layer 1 的 LLM 分类在现有路由中已有实现。
**修正**U2 简化为在 skill_routing.py 基础上增加 complexity 评估和拍卖触发逻辑,不新建独立模块。
### 问题 5U10 Soul — 与现有 Memory 系统重叠
**现有实现**
- `tools/memory_tool.py`117 行MemoryTool 已实现 SOUL/USER/MEMORY/DAILY 四层记忆操作
- `memory/profile.py`294 行MemoryProfile 已实现记忆配置和注入
**原方案 U10 的问题**Soul 的 CRUD 已通过 MemoryTool 实现,不需要新建 SoulManager。
**修正**U10 简化为扩展 MemoryTool 的 SOUL section 支持动态演变(版本号+反思触发更新),不新建 identity/ 模块。
### 问题 6拍卖机制的适用性存疑
**原方案 KTD1**:用拍卖机制替代中央编排器。
**问题**
1. 拍卖机制需要每个 Agent 都能"竞标"——但当前 ConfigDrivenAgent 没有 `bid()` 方法,需要给所有 Agent 增加竞标能力
2. Economy of Minds 论文的环境是"弱 Agent 群体",而 AgentKit 的 Agent 是"强 Agent + 工具",场景不同
3. 拍卖机制的"财富积累"概念在单用户场景下意义不大——谁给 Agent 发工资?
4. 拍卖增加了系统复杂度但实际收益不确定——大多数场景下基于能力的路由OrganizationContext.find_best_agent比拍卖更直接有效
**修正**:拍卖机制降级为可选实验特性。默认使用"能力匹配路由"(基于 OrganizationContext拍卖作为高级模式可启用。
### 问题 7对齐护栏的边界不清
**原方案 KTD3**AlignmentGuard 包含全局约束注入、输出审计、级联失败检测。
**问题**
1. "全局约束"由谁定义?用户?开发者?运维?——需要约束配置机制
2. "输出审计"用 LLM 检查输出——这本身又是一次 LLM 调用,增加成本和延迟
3. "级联失败检测"的阈值10 次交互、3 层循环)是经验值,需要可配置
4. 对齐护栏与现有 QualityGate 的关系不清——是替代还是补充?
**修正**AlignmentGuard 明确为 QualityGate 的扩展,约束来源为 YAML 配置级联检测阈值可配置LLM 审计默认关闭(仅高风险场景启用)。
---
## 修订后的需求
| ID | 需求 | 变更说明 |
|----|------|---------|
| R1 | 用户通过现有 Chat 系统对话,路由层自动选择 Agent | 从"新建 Concierge"改为"扩展现有 Chat" |
| R2 | 简单任务走单 Agent 直连,零额外开销 | 不变 |
| R3 | 中等任务走能力匹配路由,可选拍卖模式 | 从"必须拍卖"改为"默认能力匹配,拍卖可选" |
| R4 | 复杂任务支持多 Agent 协作,需成本论证 | 不变 |
| R5 | 多 Agent 协作时注入全局约束 | 不变,约束来源明确为 YAML 配置 |
| R6 | 检测级联失败,自动中断 | 不变,阈值可配置 |
| R7 | 不同 Agent 可配置不同 LLM 模型 | 不变,已有 llm.model 配置支持 |
| R8 | 支持 ReAct/ReWOO/Plan-and-Execute/Reflexion/Direct 五种执行架构 | Plan-and-Execute 改为适配器模式 |
| R9 | Agent 具备持久身份Soul跨会话保持个性 | 从"新建 identity 模块"改为"扩展 MemoryTool" |
| R10 | Agent 具备组织感知 | 不变 |
| R11 | Agent 可主动发现新工具 | 降级为 Out of Scope依赖 Marketplace 先就绪) |
| R12 | 执行透明度可调 | 不变 |
---
## 修订后的 Key Technical Decisions
### KTD1修订: 扩展现有 Chat 系统,不新建 Concierge
**决策**:在现有 `chat/skill_routing.py` + `server/routes/chat.py` 基础上扩展 CostAwareRouter 能力,不新建 Concierge 模块。
**理由**
- `chat/skill_routing.py` 已实现 @skill: 前缀路由和 Skill 匹配
- `server/routes/chat.py` 已实现 REST + WebSocket Chat API
- `session/store.py` 已实现对话上下文管理
- 新建 Concierge 会与现有 Chat 系统功能重复,增加维护成本
### KTD2不变: 分层路由 — 80% 场景单 Agent 直连
### KTD3修订: AlignmentGuard 作为 QualityGate 扩展
**决策**AlignmentGuard 不作为独立模块,而是扩展现有 QualityGate增加约束注入和级联检测能力。
**理由**
- QualityGate 已在 ConfigDrivenAgent.execute() 中集成
- 独立模块需要额外的集成点,增加复杂度
- 约束来源明确为 YAML 配置alignment.constraints 字段)
### KTD4修订: Plan-and-Execute 使用适配器模式
**决策**:不新建 Plan-and-Execute 引擎,而是创建适配器将现有 GoalPlanner + PlanExecutor + PipelineReplanner 封装为 `plan_exec` execution_mode。
**理由**
- GoalPlanner594 行)已实现目标分解
- PlanExecutor518 行)已实现计划执行
- PipelineReplanner370 行)已实现反思-重规划
- 重新实现是重复建设
### KTD5不变: 分层模型配置
### KTD6修订: Soul 扩展基于现有 MemoryTool
**决策**:不新建 identity/ 模块,而是扩展现有 MemoryTool 的 SOUL section 支持动态演变。
**理由**
- MemoryTool 已实现 SOUL section CRUD
- MemoryProfile 已实现记忆注入
- 新建 identity/ 模块与现有 Memory 系统重复
### KTD7新增: 拍卖机制降级为可选实验特性
**决策**:默认使用"能力匹配路由"(基于 OrganizationContext拍卖作为可选高级模式。
**理由**
- Economy of Minds 论文场景(弱 Agent 群体)与 AgentKit 场景(强 Agent + 工具)不同
- 拍卖的"财富积累"在单用户场景下意义不大
- 基于能力的路由更直接、更可预测
- 拍卖增加系统复杂度,收益不确定
---
## 修订后的 Implementation Units
### U1. CostAwareRouter — 扩展现有 Chat 路由
**Goal**:在现有 `chat/skill_routing.py` 基础上增加 complexity 评估和分层路由能力。
**Dependencies**:无
**Files**
- `src/agentkit/chat/skill_routing.py` (modify — 增加 complexity 评估和拍卖触发)
- `src/agentkit/chat/__init__.py` (modify — 导出新增类)
- `tests/unit/test_cost_aware_router.py` (create)
**Approach**
- 在 `skill_routing.py` 中新增 `CostAwareRouter`
- Layer 0复用现有 `parse_skill_prefix()``route_to_skill()`,新增聊天模式正则匹配
- Layer 1新增 `quick_classify()` 方法LLM 评估 complexity 0-1
- Layer 2complexity > 0.7 触发能力匹配路由(默认)或拍卖(可选)
- 透明度控制:在 SkillRoutingResult 中新增 `transparency_level``execution_trace` 字段
**Patterns to follow**`chat/skill_routing.py` SkillRoutingResult + `router/intent.py` IntentRouter
**Test scenarios**
- 问候语 "你好" 命中 Layer 0 规则,零 token 开销
- "搜索XX" 命中现有 Skill 路由,零 token 开销
- "分析下这个数据" 走 Layer 1 LLM 分类
- "做市场调研+竞品分析" complexity > 0.7,走能力匹配路由
- 透明度从 SILENT 切换到 TRACE
**Verification**:三层路由正确分流,与现有 Chat 系统兼容
---
### U2. ReWOO 执行引擎
**Goal**:实现 ReWOO 执行引擎,一次性规划所有工具调用后批量执行。
**Dependencies**:无
**Files**
- `src/agentkit/core/rewoo.py` (create)
- `tests/unit/test_rewoo_engine.py` (create)
**Approach**
- Phase 1 PlanningLLM 生成完整工具调用计划JSON 格式 steps 列表)
- Phase 2 Execution按计划顺序执行工具调用可并行执行无依赖步骤
- Phase 3 SynthesisLLM 综合所有工具结果生成最终输出
- 参考 ReActEngine 的接口设计execute/execute_stream保持 API 一致性
- 复用 LLMGateway、Tool、CancellationToken 等现有组件
**Patterns to follow**`core/react.py` ReActEngine 接口模式
**Test scenarios**
- 单步骤计划:规划 1 个工具调用,执行,综合
- 多步骤计划:规划 3 个工具调用,顺序执行,综合
- 工具调用失败时的错误处理
- 与 ReActEngine 接口兼容(可替换使用)
**Verification**ReWOO 引擎能完成规划→执行→综合的完整流程
---
### U3. Plan-and-Execute 适配器
**Goal**:将现有 GoalPlanner + PlanExecutor + PipelineReplanner 封装为 `plan_exec` execution_mode 的执行引擎适配器。
**Dependencies**:无
**Files**
- `src/agentkit/core/plan_exec_engine.py` (create)
- `tests/unit/test_plan_exec_engine.py` (create)
**Approach**
- PlanExecEngine 作为适配器,内部组合 GoalPlanner + PlanExecutor + PipelineReplanner
- 实现 ReActEngine 兼容的 execute()/execute_stream() 接口
- Planner 阶段:调用 GoalPlanner.generate_plan() 分解任务
- Executor 阶段:调用 PlanExecutor.execute_plan() 逐步执行
- Replanner 阶段:执行偏离时调用 PipelineReplanner.replan() 重规划
- 每个子步骤可选择不同执行策略react/direct/rewoo
**Patterns to follow**`core/react.py` ReActEngine 接口 + `core/goal_planner.py` + `core/plan_executor.py`
**Test scenarios**
- 3 步骤任务:规划 → 逐步执行 → 汇总
- 执行偏离时触发重规划PipelineReplanner
- 子步骤使用不同执行策略
- 与 ReActEngine 接口兼容
**Verification**PlanExecEngine 能完成规划→执行→重规划的完整流程,复用现有组件
---
### U4. Reflexion 执行引擎
**Goal**:在 ReActEngine 基础上增加 Evaluate→Reflect→Retry 循环。
**Dependencies**:无
**Files**
- `src/agentkit/core/reflexion.py` (create)
- `tests/unit/test_reflexion_engine.py` (create)
**Approach**
- 继承/组合 ReActEngine在 ReAct 循环结束后增加评估步骤
- EvaluateLLM 评估当前结果质量0-1 分),复用 LLMReflector 的评估逻辑
- Reflect评估分低于阈值时LLM 反思失败原因,复用 evolution/reflector.py
- Retry基于反思结果重新执行 ReAct 循环
- 最多重试 max_reflections 次(默认 3 次)
- 分层模型act 用中模型evaluate/reflect 用大模型
**Patterns to follow**`core/react.py` ReActEngine + `evolution/llm_reflector.py` LLMReflector
**Test scenarios**
- 首次执行即达标,不触发重试
- 评估分低于阈值触发反思+重试
- 重试后达标,返回最终结果
- 超过 max_reflections 次重试后返回最后结果
- 分层模型验证
**Verification**Reflexion 引擎能完成执行→评估→反思→重试的完整循环
---
### U5. SkillConfig 扩展 + 专业 Agent 定义
**Goal**:扩展 SkillConfig 支持新执行模式,定义五种专业 Agent 的 YAML 配置。
**Dependencies**U2, U3, U4
**Files**
- `src/agentkit/skills/base.py` (modify — VALID_EXECUTION_MODES 扩展)
- `src/agentkit/core/config_driven.py` (modify — handle_task 路由扩展)
- `configs/skills/react_agent.yaml` (create)
- `configs/skills/rewoo_agent.yaml` (create)
- `configs/skills/plan_exec_agent.yaml` (create)
- `configs/skills/reflexion_agent.yaml` (create)
- `configs/skills/direct_agent.yaml` (create)
- `tests/unit/test_execution_modes.py` (create)
**Approach**
- SkillConfig.VALID_EXECUTION_MODES 新增 "rewoo", "plan_exec", "reflexion"
- ConfigDrivenAgent.handle_task() 新增 _handle_rewoo/_handle_plan_exec/_handle_reflexion 路由
- 每种专业 Agent 的 YAML 配置指定不同的 llm.model
- 复用现有 SkillLoader 和 SkillRegistry 的加载逻辑
**Patterns to follow**`skills/base.py` SkillConfig + `skills/loader.py` SkillLoader
**Test scenarios**
- SkillConfig 验证 "rewoo"/"plan_exec"/"reflexion" 为合法 execution_mode
- ConfigDrivenAgent 根据 execution_mode 路由到正确引擎
- 五种专业 Agent YAML 配置加载成功
- 不同 Agent 配置不同 llm.model
**Verification**:五种执行模式均可通过配置启用,路由正确
---
### U6. OrganizationContext 组织感知
**Goal**实现组织上下文Agent 知道可以向谁求助,支持基于能力的 Agent 发现。
**Dependencies**U5
**Files**
- `src/agentkit/org/__init__.py` (create)
- `src/agentkit/org/context.py` (create)
- `src/agentkit/org/discovery.py` (create)
- `tests/unit/test_org_context.py` (create)
**Approach**
- AgentProfilename, agent_type, capabilities, skills, current_load, max_concurrency, availability, specializations
- OrganizationContextagents dict, capability_matrix能力→Agent 映射), find_best_agent() 方法
- AgentDiscovery基于能力的 Agent 发现,考虑负载均衡
- 与现有 AgentPool 集成:从 AgentPool 自动构建 OrganizationContext
- 与现有 SkillRegistry 集成:从 SkillConfig.capabilities 构建能力矩阵
- 注入到 BaseAgent.on_task_start()Agent 启动时自动获得组织上下文
**Patterns to follow**`core/agent_pool.py` AgentPool + `skills/schema.py` CapabilityTag
**Test scenarios**
- 根据 required_capabilities 找到匹配的 Agent
- 负载均衡:选择当前负载最低的 Agent
- 无匹配 Agent 时返回 None
- OrganizationContext 从 AgentPool + SkillRegistry 自动构建
**Verification**Agent 能通过 OrganizationContext 发现合适的协作 Agent
---
### U7. AlignmentGuard — QualityGate 扩展
**Goal**:扩展现有 QualityGate增加全局约束注入和级联失败检测能力。
**Dependencies**U6
**Files**
- `src/agentkit/quality/alignment.py` (create)
- `src/agentkit/quality/cascade_detector.py` (create)
- `src/agentkit/skills/base.py` (modify — 新增 AlignmentConfig)
- `tests/unit/test_alignment_guard.py` (create)
**Approach**
- AlignmentConfigconstraints全局约束列表、cascade_threshold级联检测阈值、audit_enabledLLM 审计开关,默认关闭)
- ConstraintInjector在任务分发前注入全局约束到每个子任务的 input_data
- CascadeDetector检测 Agent 间交互次数超限和循环深度超限,触发中断
- LLM 审计默认关闭,仅高风险场景(标记 alignment.audit_enabled: true启用
- 与现有 QualityGate 集成:在 QualityGate.validate() 之后执行对齐检查
**Patterns to follow**`quality/gate.py` QualityGate + `skills/base.py` QualityGateConfig
**Test scenarios**
- 全局约束被注入到子任务
- 级联检测Agent 间交互超过阈值触发中断
- LLM 审计关闭时无额外 LLM 调用
- LLM 审计开启时检查输出是否违反约束
**Verification**:对齐护栏能检测约束违反和级联失败,与 QualityGate 兼容
---
### U8. Soul 动态演变 — 扩展 MemoryTool
**Goal**:扩展现有 MemoryTool 的 SOUL section 支持动态演变(版本号+反思触发更新)。
**Dependencies**U5
**Files**
- `src/agentkit/tools/memory_tool.py` (modify — SOUL section 增加版本号和更新逻辑)
- `src/agentkit/evolution/lifecycle.py` (modify — 反思结果触发 Soul 更新)
- `tests/unit/test_soul_evolution.py` (create)
**Approach**
- SOUL section 新增 `version``updated_at` 字段
- MemoryTool 新增 `update_soul()` 方法:基于反思结果更新 Soul
- EvolutionMixin 新增 `evolve_soul()` 钩子:反思完成后检查是否需要更新 Soul
- Soul 更新条件:反思发现新的行为模式/偏好/能力变化
- Soul 注入:复用现有 MemoryProfile 的 SOUL section 注入逻辑
**Patterns to follow**`tools/memory_tool.py` MemoryTool + `evolution/lifecycle.py` EvolutionMixin
**Test scenarios**
- Soul 版本号初始为 1更新后递增
- 反思结果触发 Soul 更新(新增 strength/value
- 无反思结果时不触发更新
- Soul 信息正确注入到 System Prompt复用现有逻辑
**Verification**Agent 具备跨会话的持久身份Soul 可动态演变
---
### U9. 拍卖机制(可选实验特性)
**Goal**:实现拍卖机制作为可选的高级路由模式,默认不启用。
**Dependencies**U6
**Files**
- `src/agentkit/marketplace/__init__.py` (create)
- `src/agentkit/marketplace/auction.py` (create)
- `src/agentkit/marketplace/wealth.py` (create)
- `tests/unit/test_auction.py` (create)
**Approach**
- Bid 数据结构agent_name, architecture, estimated_steps, estimated_cost, confidence, payment_offer
- 拍卖裁决score = (confidence / estimated_cost) * wealth_factor
- 财富追踪:成功完成任务增加财富,长期表现差被标记破产
- 默认关闭,需在配置中显式启用 `marketplace.auction_enabled: true`
- 启用后Layer 2 路由使用拍卖而非能力匹配
**Patterns to follow**`core/agent_pool.py` AgentPool
**Test scenarios**
- 拍卖关闭时使用能力匹配路由
- 拍卖启用后,多 Agent 竞标选择最优
- 财富因子影响竞标结果
- Agent 破产检查
**Verification**:拍卖机制作为可选特性正确工作,不影响默认路由
---
### U10. 集成测试 + Server 集成
**Goal**:将所有新模块集成到现有 Server 中,实现端到端的 Chat → Router → Agent → AlignmentGuard 完整流程。
**Dependencies**U1-U9
**Files**
- `src/agentkit/server/app.py` (modify — 注入 OrganizationContext、AlignmentGuard)
- `src/agentkit/server/config.py` (modify — 新增 marketplace/alignment 配置段)
- `src/agentkit/chat/skill_routing.py` (modify — 集成 CostAwareRouter)
- `tests/integration/test_marketplace_e2e.py` (create)
**Approach**
- create_app() 中新增 OrganizationContext、AlignmentGuard 的初始化
- CostAwareRouter 集成到现有 Chat 路由流程
- ServerConfig 新增 marketplace 和 alignment 配置段
- 端到端测试:用户消息 → Chat → Router → Agent → AlignmentGuard → 回复
**Patterns to follow**`server/app.py` create_app() 组装模式
**Test scenarios**
- 简单聊天经路由到 DirectAgent返回正常
- 复杂任务经能力匹配路由选择 Agent执行完成返回
- 对齐护栏检测到级联风险,触发中断
- 透明度 TRACE 模式返回执行追踪信息
- 拍卖模式启用后,复杂任务走拍卖路由
**Verification**:端到端流程完整可用,与现有 Chat 系统兼容
---
## 修订后的 Phased Delivery
### Phase A — 执行引擎U2, U3, U4, U5
三种新引擎 + SkillConfig 扩展,可独立运行,不依赖 Marketplace
### Phase B — 路由与组织U1, U6, U7
CostAwareRouter + OrganizationContext + AlignmentGuard
### Phase C — 身份与集成U8, U9, U10
Soul 演变 + 拍卖(可选)+ Server 集成
---
## 修订后的 Risks & Dependencies
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| PlanExecEngine 适配器与现有组件接口不兼容 | Plan-and-Execute 模式无法工作 | 适配器内部处理接口差异,对外暴露 ReActEngine 兼容接口 |
| Reflexion 引擎 token 成本高 | 自我评估+重试增加 2-3x token | 分层模型 + max_reflections 限制 + 默认关闭 |
| CostAwareRouter Layer 1 分类不准 | 中等任务被错误路由 | 分类结果带置信度,低置信度时回退到默认 Agent |
| AlignmentGuard 级联检测误报 | 正常多步交互被中断 | 阈值可配置,初期宽松 |
| 拍卖机制增加系统复杂度 | 维护成本高 | 默认关闭,作为可选实验特性 |
| 与 P2 Hardening 计划冲突 | 两个计划同时修改 server/app.py | P2 先行Marketplace 后续,避免同时修改同一文件 |
---
## 已明确事项
### 1. 拍卖机制 — 作为核心特性
**决策**:拍卖机制是核心差异化能力,应在 Phase B 与能力匹配路由同时实现。
**实现要点**
- 需要解决"奖励信号来源"问题:任务成功 → 正奖励,任务失败 → 负奖励,由 Concierge/Router 在任务完成后发放
- Agent 需要新增 `bid()` 方法(在 BaseAgent 中定义默认实现ConfigDrivenAgent 覆盖)
- 拍卖与能力匹配路由并行:能力匹配作为底保,拍卖作为优选
### 2. AlignmentGuard 约束检查 — 分层混合
**决策**:系统级用规则检查,组织级用 LLM 检查,用户级用 Prompt 注入。
| 层级 | 检查方式 | 定义者 | 示例 |
|------|---------|--------|------|
| 系统级 | 规则检查(关键词+正则) | 开发者/运维 | "不生成恶意代码"、"不泄露 API Key" |
| 组织级 | LLM 语义检查 | 管理员 | "不引用竞品数据"、"合规审查需人工" |
| 用户级 | Prompt 注入(不检查) | 用户 | "用中文回复"、"不超过 500 字" |
**实现要点**
- 系统级约束硬编码在 `quality/alignment.py` 中,配置可扩展
- 组织级约束在 `agentkit.yaml``alignment.constraints` 中配置
- LLM 审计仅组织级约束触发,系统级约束用规则检查零额外 token
### 3. Soul 更新频率 — 条件触发
**决策**:同类反思出现 ≥ 3 次才触发 Soul 更新,更新后版本号递增,可回滚。
**实现要点**
- EvolutionMixin 维护 `pending_soul_updates: dict[str, list[Reflection]]` 缓冲区
- 同类反思(相同 category累积 ≥ 3 次时触发 `update_soul()`
- Soul 更新记录完整变更历史before/after/trigger/evidence支持回滚
- Soul 版本号递增,每次更新 +1
### 4. 专业 Agent 工具集 — YAML 配置 + 默认推荐
**决策**:工具通过 YAML 配置绑定,提供默认推荐配置,用户可自定义。
**默认推荐**
| Agent | 默认工具 | 原因 |
|-------|---------|------|
| ReactAgent | web_search, baidu_search, shell, memory | ReAct 需要丰富工具集 |
| RewooAgent | web_search, baidu_search, web_crawl | 批量数据采集类工具 |
| PlanExecAgent | 所有工具(子步骤按需选择) | 子步骤可能需要任何工具 |
| ReflexionAgent | 与 ReactAgent 相同 | Reflexion = ReAct + 评估 |
| DirectAgent | 无工具 | 单次 LLM 调用 |
### 5. 与 P2 Hardening 计划 — 部分并行
**决策**Phase A执行引擎与 P2 并行开发Phase B/C 等 P2 完成后再开始。
**理由**
- Phase A 只新增引擎文件rewoo.py/plan_exec_engine.py/reflexion.py不修改 server 文件,无冲突
- Phase B/C 需要修改 server/app.py、server/config.py 等,与 P2 有文件冲突
- P2 修复安全问题,不应被阻塞
### 6. 分层模型配置 — YAML 配置 + 默认推荐
**决策**:模型通过 YAML 的 `llm.model` 字段配置,提供默认推荐值。
**默认推荐**
| Agent | 默认模型 | 预估成本/1K tokens |
|-------|---------|-------------------|
| DirectAgent | `openai/gpt-4o-mini` | $0.00015 |
| ReactAgent | `anthropic/claude-sonnet-4-20250514` | $0.003 |
| RewooAgent | `anthropic/claude-sonnet-4-20250514` | $0.003 |
| PlanExecAgent | `anthropic/claude-opus-4-20250514` | $0.015 |
| ReflexionAgent | 执行: `sonnet`, 评估: `opus` | 混合 |
### 7. 多 Agent 协作上下文传递 — 按需升级
**决策**默认用直接注入TaskMessage.input_data复杂场景按需升级到 SharedWorkspace 或 Redis Pub/Sub。
| 场景 | 传递方式 | 原因 |
|------|---------|------|
| 顺序执行A→B | 直接注入 | 简单直接 |
| 并行执行A+B→C | SharedWorkspace | A/B 并行写入C 汇总读取 |
| 事件通知A 通知 B | Redis Pub/Sub | 异步解耦 |
| 对话连续性 | SessionManager + 摘要 | 跨 Agent 连续 |

View File

@ -0,0 +1,404 @@
---
title: "feat: Pipeline 级别对抗闭环Coding Harness"
status: active
created: 2026-06-12
origin: 头脑风暴对话 - Worker ↔ Verifier 对抗闭环改进方案
type: feat
---
# feat: Pipeline 级别对抗闭环Coding Harness
## 问题框架
当前 Pipeline Engine 的 `retry_count``retry_policy` 实现的是**盲目重试**(指数退避重跑相同逻辑),`QualityGate` 是**单向验证**validate → pass/fail。Worker 产出失败后不知道具体哪里有问题,重试时无法定向修复。
**目标:** 实现 Worker → Verifier → 带反馈打回 Worker → 定向修复 → 再次审查的对抗闭环,通过 Pipeline YAML 配置即可启用。
---
## 高层技术设计
### 对抗流转状态机
```mermaid
stateDiagram-v2
[*] --> Worker: 执行 Stage
Worker --> Verifier: 产出完成
Verifier --> [*]: 审查通过 (passed=true)
Verifier --> Worker: 审查不通过 (round < max)
Worker --> Verifier: 根据反馈修复
Verifier --> Escalate: 轮次耗尽 (round >= max)
Escalate --> [*]: 转人工或标记失败
```
### 反馈数据结构
```
ReviewFeedback
├── passed: bool
├── score: float (0-1)
├── summary: str (自然语言审查报告)
└── issues: list[ReviewIssue]
├── severity: critical/major/minor
├── category: logic_error/security/style/test_failure/architecture
├── description: str
├── location: str? (文件路径/行号)
└── suggestion: str?
```
### 配置扩展
`PipelineStage` 新增 4 个字段:
- `verifier`: str | None — Verifier Agent 名称
- `max_adversarial_rounds`: int — 最大对抗轮次(默认 3
- `feedback_mode`: str — 反馈模式structured+natural / structured / natural
- `escalate_on_exhaust`: str | None — 轮次耗尽后的升级目标
---
## 实施单元
### U1. 扩展 Pipeline Schema — 对抗字段和反馈数据模型
**Goal:** 在 `pipeline_schema.py` 中新增对抗闭环所需的数据模型和字段
**Requirements:**
- PipelineStage 支持配置 Verifier 和对抗参数
- 提供结构化的 ReviewFeedback 和 ReviewIssue 数据模型
- 提供 AdversarialState 用于追踪对抗轮次
**Dependencies:** 无
**Files:**
- `src/agentkit/orchestrator/pipeline_schema.py` (修改)
- `tests/unit/test_pipeline_schema.py` (修改)
**Approach:**
1. 新增 `ReviewIssue` Pydantic 模型severity, category, description, location, suggestion
2. 新增 `ReviewFeedback` Pydantic 模型passed, issues, summary, score
3. 新增 `AdversarialState` Pydantic 模型current_round, max_rounds, feedback_history, last_feedback
4. 在 `PipelineStage` 新增 4 个字段:
- `verifier: str | None = None`
- `max_adversarial_rounds: int = 3`
- `feedback_mode: str = "structured+natural"`
- `escalate_on_exhaust: str | None = None`
**Test scenarios:**
1. **Happy path:** 创建带 verifier 字段的 PipelineStage验证字段默认值正确
2. **Happy path:** 创建 ReviewFeedback 对象,验证序列化和反序列化正常
3. **Edge case:** verifier=None 时PipelineStage 正常创建(向后兼容)
4. **Edge case:** max_adversarial_rounds=0 时正常创建
---
### U2. Pipeline Engine 增强 — 对抗流转执行逻辑
**Goal:** 在 `PipelineEngine` 中实现 `_execute_stage_with_adversarial` 方法,处理 Worker ↔ Verifier 对抗循环
**Requirements:**
- 当 Stage 配置了 verifier 时自动进入对抗模式
- Verifier 审查不通过时,带反馈打回 Worker 重做
- 超过最大轮次后执行升级处理
- 保持与现有 `_execute_stage` 的向后兼容
**Dependencies:** U1
**Files:**
- `src/agentkit/orchestrator/pipeline_engine.py` (修改)
- `tests/unit/test_pipeline_adversarial.py` (新增)
**Approach:**
1. **新增 `_execute_stage_with_adversarial` 方法:**
- 检查 `stage.verifier` 是否存在,不存在则走原有逻辑
- 初始化 `AdversarialState`
- 进入对抗循环1 到 max_adversarial_rounds
- 执行 Worker Agent
- 执行 Verifier 审查 Worker 产出
- 如果通过:返回成功结果
- 如果不通过:
- 记录反馈到 feedback_history
- 如果轮次耗尽:调用 `_escalate` 处理
- 否则:调用 `_execute_agent_with_feedback` 打回 Worker
2. **新增 `_execute_agent_with_feedback` 方法:**
- 构建反馈上下文previous_attempt_failed, review_feedback, instruction
- 合并到原有上下文
- 调用 Dispatcher 执行 Agent
3. **新增 `_execute_verifier` 方法:**
- 调用 Verifier Agent 执行审查
- 解析返回结果为 ReviewFeedback 对象
- 记录审查日志
4. **新增 `_escalate` 方法:**
- 如果配置了 `escalate_on_exhaust`:转发到升级目标(如 human_approval
- 否则:返回失败结果,附带审查历史
5. **修改 `_execute_stage` 方法:**
- 检查是否配置了 verifier
- 如果配置了,路由到 `_execute_stage_with_adversarial`
- 否则保持原有逻辑
**Test scenarios:**
1. **Happy path:** Stage 无 verifier → 走原有逻辑,正常完成
2. **Happy path:** Stage 有 verifier审查通过 → 一次完成
3. **Happy path:** Stage 有 verifier审查不通过 → 打回 Worker → 修复后通过
4. **Edge case:** 超过 max_adversarial_rounds → 触发 escalate_on_exhaust
5. **Edge case:** escalate_on_exhaust=None → 返回失败,附带审查历史
6. **Error path:** Verifier 执行异常 → 记录错误,返回失败
7. **Error path:** Worker 重试时再次异常 → 继续下一轮或轮次耗尽
8. **Integration:** 完整对抗流程的状态追踪正确feedback_history 长度=实际轮次)
---
### U3. 反馈上下文构建和注入机制
**Goal:** 实现结构化的反馈上下文构建,让 Worker Agent 能理解审查反馈并定向修复
**Requirements:**
- 反馈上下文包含结构化问题列表和自然语言审查报告
- Worker 能根据反馈上下文调整生成策略
- 支持 feedback_mode 配置structured+natural / structured / natural
**Dependencies:** U2
**Files:**
- `src/agentkit/orchestrator/pipeline_engine.py` (修改,续 U2)
- `tests/unit/test_pipeline_adversarial.py` (修改,续 U2)
**Approach:**
1. **构建反馈上下文字典:**
```python
feedback_context = {
"previous_attempt_failed": True,
"review_feedback": {
"summary": feedback.summary,
"issues": [
{
"severity": issue.severity,
"category": issue.category,
"description": issue.description,
"suggestion": issue.suggestion,
}
for issue in feedback.issues
],
"previous_score": feedback.score,
},
"instruction": (
"Your previous output did not pass review. "
"Please fix the issues listed above and regenerate."
),
}
```
2. **根据 feedback_mode 调整上下文:**
- `structured+natural`: 包含完整 issues 列表和 summary
- `structured`: 只包含 issues 列表
- `natural`: 只包含 summary 和 instruction
3. **合并到原有上下文:**
- `merged_context = {**context, **feedback_context}`
- 传递给 Agent 执行
**Test scenarios:**
1. **Happy path:** feedback_mode="structured+natural" → 上下文包含 issues 和 summary
2. **Happy path:** feedback_mode="structured" → 上下文只包含 issues
3. **Happy path:** feedback_mode="natural" → 上下文只包含 summary
4. **Edge case:** feedback.feedback_history 有多轮记录 → 合并所有历史反馈
---
### U4. 创建 code_reviewer Skill 配置示例
**Goal:** 创建代码审查 Verifier Agent 的 Skill 配置,作为对抗模式的的标准 Verifier 模板
**Requirements:**
- 配置为 direct 执行模式
- system_prompt 定义严格的代码审查角色和检查维度
- 配置 output_schema 确保返回结构化的 ReviewFeedback 格式
**Dependencies:** U1需要 ReviewFeedback schema 存在)
**Files:**
- `configs/skills/code_reviewer.yaml` (新增)
**Approach:**
1. 创建 `code_reviewer.yaml`
- name: code_reviewer
- execution_mode: direct
- intent_match: "code.*review|review.*code"
- system_prompt: 定义代码审查角色、检查维度(逻辑正确性、安全漏洞、架构设计、测试覆盖、代码风格)
- tools: [shell_tool](用于运行测试用例)
- quality_gate: 配置 required_fields 和 output_schema
2. output_schema 定义:
- passed: boolean
- issues: array of {severity, category, description, location?, suggestion?}
- summary: string
- score: number (0-1)
**Test expectation:** none — 这是配置文件,通过 YAML 加载测试验证格式正确性
---
### U5. 创建 coding_harness Pipeline 配置示例
**Goal:** 创建完整的编码对抗 Pipeline 配置示例,展示如何使用对抗闭环功能
**Requirements:**
- 包含 develop → test → review对抗模式 → archive 四个阶段
- review 阶段配置 verifier、max_adversarial_rounds、escalate_on_exhaust
- 使用变量引用传递阶段间产出
**Dependencies:** U4
**Files:**
- `configs/pipelines/coding_harness.yaml` (新增)
**Approach:**
1. 创建 `coding_harness.yaml`
- name: coding_harness, version: "1.0"
- 阶段 1 (develop): developer_agent 实现功能
- 阶段 2 (test): tester_agent 运行测试,依赖 develop
- 阶段 3 (review): developer_agent 修复问题verifier=code_reviewer, max_adversarial_rounds=3, escalate_on_exhaust=human_approval
- 阶段 4 (archive): archiver_agent 提交代码,依赖 review
2. 配置变量引用:
- test 阶段输入: code="{{develop.code}}", test_files="{{develop.test_files}}"
- review 阶段输入: code="{{develop.code}}", test_results="{{test.test_results}}"
- archive 阶段输入: code="{{review.final_code}}"
**Test expectation:** none — 这是配置文件,通过 YAML 加载测试验证格式和引用正确性
---
### U6. 编写单元测试 — 对抗流转和反馈注入
**Goal:** 为对抗闭环功能编写完整的单元测试覆盖
**Requirements:**
- 覆盖 U1-U3 定义的所有测试场景
- 使用 mock 模拟 Dispatcher 和 Agent 执行
- 验证对抗流转逻辑正确性
**Dependencies:** U1, U2, U3
**Files:**
- `tests/unit/test_pipeline_adversarial.py` (新增)
**Approach:**
创建以下测试类:
1. **TestPipelineSchemaAdversarial:**
- test_stage_with_verifier
- test_stage_without_verifier_backward_compat
- test_review_feedback_serialization
- test_adversarial_state_tracking
2. **TestAdversarialExecution:**
- test_no_verifier_passthrough
- test_verifier_passes_first_round
- test_verifier_fails_then_worker_fixes
- test_max_rounds_exhausted_escalate
- test_max_rounds_exhausted_no_escalate
- test_verifier_execution_error
- test_worker_retry_error
3. **TestFeedbackContext:**
- test_structured_and_natural_mode
- test_structured_only_mode
- test_natural_only_mode
- test_multiple_rounds_feedback_merge
4. **TestEscalation:**
- test_escalate_to_human_approval
- test_escalate_to_fallback_agent
- test_no_escalation_configured
**Test scenarios:** 见各测试类定义
---
### U7. 编写集成测试 — 完整 Coding Harness Pipeline
**Goal:** 编写集成测试验证完整的 Coding Harness Pipeline 端到端流程
**Requirements:**
- 加载 coding_harness.yaml 配置
- 模拟完整的 develop → test → review → archive 流程
- 验证对抗闭环在 review 阶段正常工作
**Dependencies:** U4, U5, U6
**Files:**
- `tests/integration/test_coding_harness_pipeline.py` (新增)
**Approach:**
1. 创建集成测试:
- 使用 MockDispatcher 模拟 Agent 执行
- 模拟 develop 阶段产出代码
- 模拟 test 阶段运行测试
- 模拟 review 阶段:第一次审查不通过 → 打回修复 → 第二次审查通过
- 模拟 archive 阶段提交代码
2. 验证点:
- Pipeline 最终状态为 COMPLETED
- review 阶段经历了 2 轮对抗
- feedback_history 记录了审查反馈
- 各阶段输出变量正确传递
**Test scenarios:**
1. **Happy path:** 完整 Pipeline 执行review 阶段 2 轮对抗后通过
2. **Edge case:** review 阶段 3 轮对抗后仍不通过 → escalate 到 human_approval
3. **Error path:** test 阶段失败 → Pipeline 中止,不进入 review
---
## 范围边界
**包含:**
- Pipeline Schema 扩展(对抗字段和反馈数据模型)
- Pipeline Engine 对抗流转执行逻辑
- 反馈上下文构建和注入
- code_reviewer Skill 配置示例
- coding_harness Pipeline 配置示例
- 单元测试和集成测试
**不包含(延期到后续工作):**
- 任务复杂度评估器(自动判断是否启用对抗团队)
- IM 异步秒级响应Leader 立即回执 + 后台异步调度)
- 多路并行调研对抗(多路 Researcher + 独立 Verifier
- 对抗成本监控Token 消耗、时间、修复成功率记录)
- Verifier 多角色拆分LogicReviewer / SecurityReviewer / StyleReviewer 并行审查)
---
## 风险和依赖
### 风险
1. **Agent 反馈理解能力:** Worker Agent 可能无法完全理解结构化反馈并定向修复。缓解措施:使用 feedback_mode="structured+natural" 提供自然语言说明。
2. **Verifier 审查质量:** code_reviewer 的审查质量取决于 system_prompt 和 LLM 能力。缓解措施:提供高质量的 system_prompt 模板,支持后续优化。
3. **Token 消耗:** 多轮对抗可能消耗大量 Token。缓解措施max_adversarial_rounds 默认 3可配置。
### 依赖
- 现有 Pipeline Engine 基础设施DAG 拓扑排序、并行执行、变量解析)
- 现有 Dispatcher 接口dispatch、get_task_status
- 现有 Agent 配置系统ConfigDrivenAgent、SkillConfig
---
## 系统级影响
- **向后兼容:** PipelineStage 新增字段都有默认值,现有 Pipeline 配置无需修改
- **性能影响:** 无 verifier 配置的 Stage 走原有逻辑,无性能影响;有 verifier 的 Stage 可能增加执行时间(多轮对抗)
- **可观测性:** 对抗轮次和审查结果记录在 StageResult 的 output_data 中,可通过日志和状态管理查询

View File

@ -0,0 +1,490 @@
---
title: "feat: AgentKit Phase 9 — Integrated Next-Stage Plan"
status: active
created: 2026-06-12
plan-type: feat
depth: deep
origin: "整合 017/018/019/021 活跃计划剩余工作 + 本次会话发现的新问题"
---
# feat: AgentKit Phase 9 — Integrated Next-Stage Plan
## Summary
整合全部活跃计划的剩余工作形成统一的下一阶段实施计划。核心目标完成对话响应速度优化sub-1s 首 Token、补齐安全加固和劣势项改进的遗漏项、实现拍卖机制和智能并行工具执行。
**来源计划完成度:**
| 计划 | 完成度 | 剩余项 |
|------|--------|--------|
| 020 Pipeline 对抗闭环 | ~100% | 验证测试覆盖度 |
| 017 Multi-Agent Marketplace | ~90% | U9 拍卖机制 |
| 018 P2 安全加固 | ~90% | U6 配置热重载防御性修复 |
| 019 劣势项改进 | ~70% | U4 集成测试、U5 ReWOO 回退链配置化 |
| 021 响应速度优化 | ~60% | U2 合并路由 LLM 调用、U6 Chat 管线优化 |
**本次会话新增修复(已完成):**
- 确认卡片死锁修复(`pending_confirmations or {}` → `if is not None`
- Shell 白名单扩展30 → 90+ 命令,管道智能检测)
- SkillInstallTool 新增Agent 可正确安装技能)
- 前端技能列表自动刷新
## Problem Frame
当前 AgentKit 存在三个核心问题:
1. **对话响应仍不够快**:启发式分类器已消除路由层 LLM 调用,但当启发式不确定时仍走独立 IntentRouter LLM 调用Chat 管线中每次消息新建 ReActEngine、串行 Session I/O 等仍有优化空间
2. **安全加固和测试覆盖有缺口**配置热重载的线程安全问题、ReWOO 回退链不可配置、集成测试覆盖不足
3. **拍卖机制未实现**Multi-Agent Marketplace 的核心经济模型缺失
## Scope Boundaries
### In Scope
- 021-U2: 合并路由 LLM 调用complexity + intent 单次 LLM
- 021-U6: Chat 管线优化ReActEngine 复用、Session 操作并行化)
- 智能并行工具执行LLM 标注可并行性,自动判断)
- 018-U6: 配置热重载防御性修复
- 019-U4: 集成测试补充
- 019-U5: ReWOO 回退链配置化
- 017-U9: 拍卖机制实现
- 流式首 Token 前端渲染优化
### Out of Scope
- 分布式消息总线Redis 实现,后续按需)
- jieba 分词集成(可选依赖,后续按需)
- UI 可视化 traceJaeger/Zipkin 前端,运维配置)
- Headroom 检索优化013独立计划
- 分层记忆系统016独立计划
### Deferred to Follow-Up Work
- LLM Provider 级别 Prompt 缓存(需 LLM API 支持)
- 上下文压缩策略异步化(当前截断方案足够)
- 轻量模型自动降级(需 A/B 测试验证质量)
---
## Requirements
| ID | Requirement | Priority | Origin |
|----|-------------|----------|--------|
| R1 | 启发式分类器不确定时complexity + intent 合并为单次 LLM 调用 | P0 | 021-U2 |
| R2 | Chat 管线中 ReActEngine 实例复用,避免每次消息重建 | P0 | 021-U6 |
| R3 | 多 tool_calls 时由 LLM 标注可并行性,自动并行执行独立工具 | P1 | 新需求 |
| R4 | 配置热重载线程安全,非 asyncio 线程调用不触发竞态 | P1 | 018-U6 |
| R5 | ReWOO 回退链可从 YAML 配置,不硬编码 | P1 | 019-U5 |
| R6 | 关键路径集成测试覆盖路由链、ReWOO 回退、对抗闭环 | P1 | 019-U4 |
| R7 | 拍卖机制Agent 竞标任务,基于能力+成本+历史质量评分 | P2 | 017-U9 |
| R8 | 前端流式首 Token 即渲染,不等 final_answer | P2 | 021-P1 |
| R9 | 所有改动有配置开关,可秒级回退 | P0 | 全局 |
---
## Key Technical Decisions
### KTD1: 合并路由 LLM 调用 — 单次 LLM 同时输出 complexity + intent
`HeuristicClassifier` 返回 `complexity=0.5`(不确定区间)时,不再分别调用 `quick_classify()``IntentRouter._classify_with_llm()`,而是发起一次 LLM 调用prompt 要求同时输出:
```json
{"complexity": 0.7, "intent": "code_generation", "skill_hint": "react_agent"}
```
这比两次独立 LLM 调用节省 1-3s。LLM 的 structured output 能力确保格式可靠。
**Fallback**:若 LLM 返回格式异常,按 `complexity=0.5` 走默认 Agent。
### KTD2: 智能并行工具执行 — LLM 在 tool_calls 中标注 `parallelizable`
`ReActEngine` 的 system prompt 中指示 LLM当返回多个 tool_calls 时,在每个 tool_call 的 `_metadata` 中标注 `"parallelizable": true/false`。ReActEngine 收集所有 `parallelizable=true` 的工具,用 `asyncio.gather` 并行执行;`parallelizable=false` 或未标注的串行执行。
**为什么不用纯规则判断**:工具间的依赖关系是语义级的(如"先搜索再分析"),规则无法可靠判断。让 LLM 标注是最准确的方案。
**安全网**`parallel_tools` 配置仍保留,设为 `"auto"` 时启用智能判断,设为 `true` 时全部并行,设为 `false` 时全部串行。
### KTD3: ReActEngine 复用 — Agent 级别持有实例
`AgentPool.create_agent()` 中创建 `ReActEngine` 实例并绑定到 Agent 对象。`_handle_chat_message` 从 Agent 获取已有实例而非每次新建。每次调用 `execute_stream()` 前重置内部状态对话历史、step count
**为什么不用全局单例**:不同 Agent 可能有不同的 `max_steps`、`parallel_tools` 配置,需要实例隔离。
### KTD4: 拍卖机制 — Vickrey 拍卖 + 能力匹配
采用 Vickrey次价拍卖模型Agent 提交密封标价bid = cost_estimate出价最低者赢得任务但支付第二低价。结合能力匹配过滤不合格竞标者。
**为什么选 Vickrey**激励相容——Agent 的最优策略是如实报告成本,不需要策略性报价。比英式拍卖更简单,比固定分配更高效。
### KTD5: 配置热重载线程安全 — asyncio.run_coroutine_threadsafe
`_on_config_change``watchfiles` 线程调用时,通过 `asyncio.run_coroutine_threadsafe()` 将重载操作调度到事件循环,避免在非 asyncio 线程中直接操作 asyncio 对象。
---
## High-Level Technical Design
### 路由合并流程
```mermaid
flowchart TD
A[用户消息] --> B{HeuristicClassifier}
B -->|complexity < 0.3| C[Layer 0: 默认 Agent]
B -->|0.3 <= complexity <= 0.7| D[合并 LLM 调用: complexity + intent]
B -->|complexity > 0.7| E[Layer 2: 能力匹配]
D -->|有 skill_hint| F[路由到指定 Skill]
D -->|无 skill_hint| C
D -->|LLM 格式异常| C
E -->|匹配成功| G[路由到最佳 Agent]
E -->|无匹配| F
```
### 智能并行工具执行
```mermaid
flowchart TD
A[LLM 返回 tool_calls] --> B{parallel_tools 配置}
B -->|false| C[全部串行执行]
B -->|true| D[全部并行执行]
B -->|auto| E[检查每个 tool_call 的 parallelizable 标注]
E --> F[parallelizable=true 的工具 asyncio.gather 并行]
E --> G[parallelizable=false 或未标注的串行执行]
F --> H[合并结果,按 tool_call_id 排序]
G --> H
```
### 拍卖机制
```mermaid
sequenceDiagram
participant Client
participant Auctioneer
participant Agent1
participant Agent2
Client->>Auctioneer: 提交任务
Auctioneer->>Agent1: 广播任务需求
Auctioneer->>Agent2: 广播任务需求
Agent1->>Auctioneer: 提交标价 (bid=0.3)
Agent2->>Auctioneer: 提交标价 (bid=0.5)
Auctioneer->>Auctioneer: Vickrey 拍卖: 赢家=Agent1, 支付=0.5
Auctioneer->>Agent1: 分配任务 (payment=0.5)
Agent1->>Auctioneer: 返回结果
Auctioneer->>Client: 返回结果
```
---
## Implementation Units
### U1. 合并路由 LLM 调用
**Goal:** 当 HeuristicClassifier 不确定时,将 complexity 评估和 intent 分类合并为单次 LLM 调用,消除路由层的第二次 LLM 调用。
**Requirements:** R1, R9
**Dependencies:** None
**Files:**
- `src/agentkit/chat/skill_routing.py` — 新增 `MergedRouter`
- `src/agentkit/chat/skill_routing.py` — 修改 `CostAwareRouter._route_layer1()` 使用合并调用
- `tests/unit/test_cost_aware_router.py` — 新增测试
- `configs/agentkit.yaml` — 新增 `router.merged_llm_classify: true` 配置
**Approach:**
1. 在 `CostAwareRouter` 中新增 `_classify_merged()` 方法,构建单次 LLM prompt 要求同时输出 complexity + intent + skill_hint
2. 修改 `_route_layer1()`:当 `HeuristicClassifier` 返回不确定区间0.3-0.7)时,调用 `_classify_merged()` 而非分别调用 `quick_classify()``IntentRouter._classify_with_llm()`
3. LLM 返回格式:`{"complexity": float, "intent": str, "skill_hint": str|null}`,使用 JSON mode 确保格式可靠
4. FallbackLLM 格式异常时按 `complexity=0.5` 走默认 Agent
5. 配置开关:`router.merged_llm_classify: true`(默认),设为 false 时回退到独立调用
**Patterns to follow:** `HeuristicClassifier` 的配置开关模式(`classifier` 参数),`IntentRouter._classify_with_llm()` 的 LLM 调用模式
**Test scenarios:**
- 合并调用返回有效 JSON正确路由到指定 skill
- 合并调用返回格式异常fallback 到默认 Agent
- 合并调用返回 complexity < 0.3 Layer 0
- 合并调用返回 complexity > 0.7,走 Layer 2
- 配置 `merged_llm_classify: false` 时回退到独立调用
- 合并调用超时fallback 到默认 Agent
**Verification:** 启用 `merged_llm_classify` 后,不确定区间的路由只产生 1 次 LLM 调用(而非 2 次)
---
### U2. Chat 管线优化
**Goal:** ReActEngine 实例复用 + Session 操作并行化,减少每次消息的初始化和 I/O 开销。
**Requirements:** R2, R9
**Dependencies:** None
**Files:**
- `src/agentkit/core/agent_pool.py` — Agent 创建时绑定 ReActEngine 实例
- `src/agentkit/server/routes/chat.py` — 从 Agent 获取已有 ReActEngine
- `src/agentkit/core/react.py` — 新增 `reset()` 方法重置内部状态
- `tests/unit/test_chat_routes.py` — 新增测试
- `tests/unit/test_react_engine.py` — 新增 `test_reset` 测试
**Approach:**
1. 在 `ReActEngine` 中新增 `reset()` 方法清空内部对话历史、step count、cancellation token
2. 在 `AgentPool.create_agent()` 中创建 `ReActEngine` 实例并绑定到 `agent._react_engine`
3. 修改 `_handle_chat_message()`:从 `agent._react_engine` 获取实例,调用 `reset()` 后执行
4. 若 Agent 无 `_react_engine`(向后兼容),仍新建实例
5. Session 操作:`append_message` 的 user message 和 assistant message 可并行写入(两者无依赖)
**Patterns to follow:** `AgentPool.create_agent()` 的 Agent 初始化模式
**Test scenarios:**
- ReActEngine.reset() 清空内部状态后可正常执行
- 连续两次 execute_stream() 调用互不干扰
- Agent 无 _react_engine 时向后兼容新建实例
- Session 并行写入后 get_messages() 返回完整消息列表
**Verification:** 连续发送 10 条消息ReActEngine 实例 ID 保持不变
---
### U3. 智能并行工具执行
**Goal:** LLM 在 tool_calls 中标注可并行性ReActEngine 自动判断并行/串行执行。
**Requirements:** R3, R9
**Dependencies:** None
**Files:**
- `src/agentkit/core/react.py` — 修改 `_execute_loop()` 中的工具执行逻辑
- `src/agentkit/core/react.py` — 修改 system prompt 指示 LLM 标注 parallelizable
- `tests/unit/test_react_engine.py` — 新增并行测试
- `configs/agentkit.yaml``parallel_tools` 支持 `"auto"`
**Approach:**
1. 修改 `parallel_tools` 参数类型为 `bool | str`,支持 `True`/`False`/`"auto"`
2. 在 ReActEngine 的 system prompt 中添加指引:当返回多个 tool_calls 时,在每个 tool_call 的 arguments 中包含 `_parallelizable: true/false`
3. `_execute_loop()` 中:
- `parallel_tools=True` → 全部 `asyncio.gather`
- `parallel_tools=False` → 全部串行
- `parallel_tools="auto"` → 收集 `parallelizable=true` 的工具并行执行,其余串行
4. 执行顺序:先执行所有串行工具(按顺序),再并行执行所有可并行工具
5. 结果按 `tool_call_id` 顺序追加到对话历史
**Patterns to follow:** 现有 `asyncio.gather` 并行执行模式(`parallel_tools=True` 分支)
**Test scenarios:**
- `parallel_tools="auto"` + LLM 标注 2 个 parallelizable=true → 并行执行
- `parallel_tools="auto"` + LLM 标注 1 个 true 1 个 false → 串行先执行,再并行
- `parallel_tools="auto"` + LLM 未标注 parallelizable → 全部串行(安全默认)
- `parallel_tools=True` → 忽略标注,全部并行
- `parallel_tools=False` → 忽略标注,全部串行
- 并行执行结果按 tool_call_id 顺序追加
**Verification:** 发送需要多工具的消息,观察日志中的执行顺序和耗时
---
### U4. 配置热重载防御性修复
**Goal:** 修复配置热重载的线程安全问题,确保非 asyncio 线程调用不触发竞态。
**Requirements:** R4, R9
**Dependencies:** None
**Files:**
- `src/agentkit/server/app.py` — 修改 `_on_config_change()` 使用 `asyncio.run_coroutine_threadsafe()`
- `src/agentkit/server/app.py` — 添加 shell proc.kill() 错误处理
- `tests/unit/test_app_lifecycle.py` — 新增测试
**Approach:**
1. `_on_config_change()` 从 watchfiles 线程调用时,通过 `asyncio.run_coroutine_threadsafe(_async_reload_config(app, config), loop)` 调度到事件循环
2. 新增 `_async_reload_config()` 异步方法,包含原重载逻辑
3. 添加 `_config_reload_lock` 的线程安全版本(`threading.Lock` + `asyncio.Lock` 双锁)
4. shell proc.kill() 添加 try/except ProcessLookupError
**Patterns to follow:** `asyncio.run_coroutine_threadsafe()` 模式
**Test scenarios:**
- 从非 asyncio 线程触发配置变更,重载正常完成
- 并发配置变更,只有一个生效(锁保护)
- proc.kill() 时进程已退出,不抛异常
- 配置变更后 LLM Gateway 正确重建
**Verification:** 修改 agentkit.yaml 后观察日志,确认重载在事件循环中执行
---
### U5. ReWOO 回退链配置化
**Goal:** ReWOO 的 fallback 策略可从 YAML 配置,不硬编码。
**Requirements:** R5, R9
**Dependencies:** None
**Files:**
- `src/agentkit/core/rewoo.py``FALLBACK_STRATEGIES` 改为从配置读取
- `configs/skills/rewoo_agent.yaml` — 新增 `fallback_strategies` 字段
- `tests/unit/test_rewoo_engine.py` — 新增测试
**Approach:**
1. `ReWOOEngine.__init__()` 接受 `fallback_strategies: list[str]` 参数,默认 `["simplified_rewoo", "react", "direct"]`
2. Skill YAML 中新增 `fallback_strategies` 字段,覆盖默认值
3. `_plan_phase()` 失败时按配置的策略顺序尝试 fallback
4. 支持的策略名:`simplified_rewoo`、`react`、`direct`、`plan_exec`
**Patterns to follow:** `SkillConfig` 的字段扩展模式
**Test scenarios:**
- 默认 fallback 链simplified_rewoo → react → direct
- 自定义 fallback 链plan_exec → react → direct
- 空 fallback 链:直接抛异常
- 无效策略名:跳过并警告
**Verification:** 修改 rewoo_agent.yaml 的 fallback_strategies重启后观察日志
---
### U6. 集成测试补充
**Goal:** 补充关键路径的集成测试,保障其他模块改动的回归安全。
**Requirements:** R6
**Dependencies:** U1, U2, U3
**Files:**
- `tests/integration/test_router_engine_chain.py` — 路由→引擎全链路测试
- `tests/integration/test_rewoo_fallback.py` — ReWOO 回退链测试
- `tests/integration/test_parallel_tools.py` — 并行工具执行测试
- `tests/integration/test_merged_router.py` — 合并路由测试
**Approach:**
1. 路由链测试:用户消息 → HeuristicClassifier → 合并 LLM 调用 → Skill 匹配 → ReActEngine 执行 → 结果
2. ReWOO 回退测试Planning 失败 → simplified_rewoo → react → direct
3. 并行工具测试LLM 返回多个 tool_calls + parallelizable 标注 → 验证执行顺序
4. 合并路由测试:不确定区间消息 → 单次 LLM 调用 → 正确路由
**Patterns to follow:** 现有 `tests/integration/test_react_loop.py` 模式
**Test scenarios:**
- 路由链:问候语 → Layer 0 直达;代码问题 → 合并 LLM → react_agent
- ReWOO 回退mock LLM Planning 失败 → 验证 fallback 执行
- 并行工具2 个 parallelizable=true 工具 → 验证并行执行(耗时 < 串行
- 合并路由:不确定消息 → 验证只产生 1 次 LLM 调用
**Verification:** `pytest tests/integration/ -v` 全部通过
---
### U7. 拍卖机制实现
**Goal:** 实现 Vickrey 拍卖模型Agent 竞标任务,基于能力+成本+历史质量评分。
**Requirements:** R7
**Dependencies:** None
**Files:**
- `src/agentkit/marketplace/auction.py` — 新增 `Auctioneer`、`Bid`、`AuctionResult` 类
- `src/agentkit/marketplace/wealth.py` — 新增 `WealthTracker`
- `src/agentkit/chat/skill_routing.py``CostAwareRouter._route_layer2()` 集成拍卖
- `configs/agentkit.yaml` — 新增 `router.auction_enabled: true` 配置
- `tests/unit/test_auction.py` — 新增测试
**Approach:**
1. `Bid` 数据类agent_id、cost_estimate、capability_score、quality_history
2. `Auctioneer` 类:
- `announce_task(task_desc, required_capabilities)` → 广播给合格 Agent
- `submit_bid(bid)` → 收集标价
- `resolve_auction()` → Vickrey 拍卖:最低价者赢,支付第二低价
- 能力过滤:先按 `required_capabilities` 过滤不合格竞标者
3. `WealthTracker` 类:跟踪每个 Agent 的累计收入/支出,用于后续经济分析
4. 集成到 `CostAwareRouter._route_layer2()`:当 `auction_enabled=True` 且有多个候选 Agent 时,发起拍卖
5. 拍卖超时:默认 5s超时后按最低价直接分配
**Patterns to follow:** `CostAwareRouter` 的三层路由模式,`org_context.find_best_agent()` 的能力匹配模式
**Test scenarios:**
- 单一竞标者 → 直接分配,支付 0
- 两个竞标者 → Vickrey 拍卖,低价者赢,支付第二低价
- 竞标者能力不匹配 → 过滤掉,不参与拍卖
- 拍卖超时 → 按已有标价分配
- 无竞标者 → fallback 到 IntentRouter
- WealthTracker 正确累计收入/支出
**Verification:** 配置 `auction_enabled: true`,发送高复杂度消息,观察拍卖日志
---
### U8. 流式首 Token 前端渲染
**Goal:** 前端收到首个 token 即开始渲染,不等 final_answer降低感知延迟。
**Requirements:** R8
**Dependencies:** U2
**Files:**
- `src/agentkit/server/static/index.html` — 修改 WebSocket 消息处理逻辑
- `src/agentkit/core/react.py` — 流式事件增加 `is_final` 标记
**Approach:**
1. 当前前端收到 `token` 事件时已经在渲染文本(`currentAgentBubble.textContent += msg.content`),但 LLM 决定调工具时需要"收回"已渲染的 token
2. 新增 `thinking` 事件类型LLM 的思考过程(如 "Let me search for..."),前端渲染为灰色斜体
3. `token` 事件仅在 LLM 直接回答时发送(不调工具时)
4. 当 LLM 决定调工具时,发送 `tool_call` 事件,前端将 thinking 文本替换为工具调用卡片
5. `final_answer` 事件标记 `is_final: true`,前端完成渲染
**关键 UX 决策**:不在 LLM 可能调工具时渲染 token避免"文字闪烁消失"问题。只在确认是最终回答时才渲染 token 流。
**Patterns to follow:** 现有 `token` / `final_answer` 事件处理模式
**Test scenarios:**
- LLM 直接回答 → token 流式渲染,无闪烁
- LLM 先思考再调工具 → thinking 渲染为灰色,工具调用显示为卡片
- LLM 思考后直接回答 → thinking 后接 token 流式渲染
- 多轮工具调用 → 每轮工具调用显示为步骤卡片
**Verification:** 发送需要工具调用的消息,观察前端渲染无闪烁
---
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| 合并 LLM 调用格式不稳定 | 路由失败,走 fallback | JSON mode + 严格 schema 校验 + fallback 到默认 Agent |
| 智能并行工具执行依赖误判 | 工具结果错误 | `parallel_tools="auto"` 默认保守(未标注=串行);`true` 模式需显式启用 |
| 拍卖机制增加路由延迟 | 高复杂度任务响应更慢 | 拍卖超时 5s单竞标者时跳过拍卖直接分配 |
| ReActEngine 复用状态泄漏 | 上下文串扰 | `reset()` 方法严格清空所有内部状态;每次执行前强制调用 |
| 流式渲染闪烁 | 用户体验差 | 只在确认不调工具时渲染 tokenthinking 阶段渲染为灰色 |
---
## System-Wide Impact
- **路由层**`skill_routing.py` 变更最大(合并 LLM + 拍卖集成)
- **引擎层**`react.py` 变更(智能并行 + reset`rewoo.py` 变更(配置化回退)
- **服务层**`chat.py` 变更ReActEngine 复用),`app.py` 变更(热重载修复)
- **前端**`index.html` 变更(流式渲染优化)
- **新增模块**`marketplace/auction.py`、`marketplace/wealth.py`
---
## Open Questions
- 拍卖机制的 `cost_estimate` 如何计算?当前 Agent 无自省能力,可能需要基于历史执行时间统计
- `parallel_tools="auto"` 的 LLM 标注格式是否需要与 OpenAI function calling 兼容?当前 `_parallelizable` 字段放在 arguments 中可能被 LLM 忽略
- 流式渲染的 thinking 文本是否需要持久化到 Session当前不持久化重载后丢失
---
## Acceptance Examples
- AE1: 发送一般问题,首 Token 在 1s 内出现(当前 3-5s
- AE2: 发送需要多工具的消息,工具并行执行,总耗时 < 串行执行
- AE3: 修改 rewoo_agent.yaml 的 fallback_strategies重启后生效
- AE4: 配置 auction_enabled: true高复杂度任务触发拍卖日志
- AE5: 修改 agentkit.yaml 后服务自动重载,无异常日志

View File

@ -0,0 +1,382 @@
---
date: "2026-06-13"
status: active
origin: docs/brainstorms/2026-06-13-gui-productization-requirements.md
---
## Summary
对 Fischer AgentKit GUI 进行产品级提升,三线并行:布局重构为「左对话 + 右双栏」、建立双主题设计系统、增强交互体验。分 3 个迭代交付。
## Problem Frame
当前 GUI 处于"功能可用但体验粗糙"状态:四象限等分布局让对话空间被压缩到 1/4 屏幕Design Token 体系仅覆盖浅色主题,暗色主题缺失;无过渡动画、操作无反馈、空状态单调。需要从布局、视觉、交互三个维度全面提升到产品级。
## Key Technical Decisions
**KTD-1. 暗色主题通过 `[data-theme="dark"]` 选择器切换,而非独立 CSS 文件。**
现有 `tokens.css``:root` 上定义了约 80 个 token。暗色主题在 `[data-theme="dark"]` 选择器上覆盖同名变量,切换时只需修改 `document.documentElement.dataset.theme`。这保持了 CSS 变量为唯一真实来源Ant Design Vue 主题通过 `readToken()` 运行时自动跟随。无需引入 CSS-in-JS 主题切换或独立 CSS 文件。
**KTD-2. 布局重构通过调整 AgentLayout 的 SplitPane 嵌套结构实现,不修改子视图。**
当前 AgentLayout 使用三层 SplitPane 嵌套(水平 → 左侧垂直 + 右侧垂直)。重构为两层:水平(左对话 + 右侧)→ 右侧垂直(右上 + 右下)。左侧不再嵌套垂直 SplitPaneChatView 直接占满左半屏。所有子视图ChatView、WorkflowView、EvolutionView 等)代码零修改。
**KTD-3. 侧边导航改为 32px 图标模式,复用现有 QuadrantPanel 的 Tab 切换机制。**
点击导航图标时调用 QuadrantPanel 的 `setActiveTab()` 方法切换 Tab 并展开面板。导航状态通过 `activeNav` ref 与 QuadrantPanel 的 `activeTab` 双向同步。不引入新的路由机制。
**KTD-4. 交互增强使用 Vue 3 内置 `<Transition>``<TransitionGroup>`,配合现有 `transitions.css` 定义的动画类。**
现有 `transitions.css` 已定义 7 种动画类fade、slide-up、slide-down、slide-right、collapse、scale、stagger-list和 3 种关键帧动画skeleton-pulse、pulse-dot、gentle-bounce。交互增强直接复用这些动画类不引入第三方动画库。
---
## High-Level Technical Design
```mermaid
flowchart LR
subgraph Layout["布局重构"]
HSplit["水平 SplitPane<br/>55:45"]
Left["左半屏<br/>ChatView"]
Right["右半屏"]
VSplit["垂直 SplitPane<br/>60:40"]
TR["右上面板<br/>代码/工作流/知识库"]
BR["右下面板<br/>监控/技能/设置"]
Left --> HSplit
Right --> HSplit
TR --> VSplit
BR --> VSplit
VSplit --> Right
end
subgraph Theme["双主题"]
Light["浅色 Token<br/>:root"]
Dark["暗色 Token<br/>[data-theme=dark]"]
Toggle["TopNav 切换按钮"]
Toggle --> Light
Toggle --> Dark
end
subgraph Interaction["交互增强"]
Anim["过渡动画<br/>Transition 组件"]
Feedback["操作反馈<br/>骨架屏/Toast"]
Empty["空状态<br/>品牌化插图"]
Drag["拖拽增强<br/>比例提示"]
end
```
---
## Requirements Traceability
| Origin R-ID | Plan Coverage |
|---|---|
| R1. 左对话 + 右双栏布局 | U1 |
| R2. 面板折叠为 Tab 栏 | U1 (QuadrantPanel 已支持,调整默认行为) |
| R3. 侧边导航精简为图标模式 | U2 |
| R4. Design Token 体系基础 | U3 (已有基础,补充暗色 token) |
| R5. 小屏幕适配 | U1 (调整 responsive.css 断点) |
| R6. 暗色主题 | U3 |
| R7. 组件样式统一 | U4 |
| R8. 过渡动画 | U5 |
| R9. 操作反馈 | U6 |
| R10. 空状态设计 | U7 |
| R11. 拖拽交互增强 | U8 |
---
## Implementation Units
### 迭代 1布局骨架 + 暗色主题
### U1. AgentLayout 布局重构
**Goal:** 将四象限等分布局重构为「左对话 + 右双栏」布局。
**Requirements:** R1, R2, R5
**Dependencies:** 无
**Files:**
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (修改)
- `src/agentkit/server/frontend/src/styles/responsive.css` (修改)
- `src/agentkit/server/frontend/src/router/index.ts` (修改)
**Approach:**
- 修改 AgentLayout 的 SplitPane 嵌套结构:移除左侧垂直 SplitPaneChatView 直接作为水平 SplitPane 的 first slot
- 右侧保留垂直 SplitPane右上 + 右下),与当前相同
- 调整水平 SplitPane 默认比例为 55:45左:右)
- 调整路由:移除 `agent-terminal` 路由(终端不再作为独立象限),终端功能可通过右侧面板 Tab 访问
- 调整 responsive.css 断点:小屏幕阈值从 1280px 调整为 1024px
- QuadrantPanel 的折叠功能已实现,无需修改
**Patterns to follow:** 现有 SplitPane + QuadrantPanel 嵌套模式
**Test scenarios:**
- 页面加载后左半屏显示 ChatView右半屏上下分割为代码/工作流和监控
- 左右分割线可拖拽,默认比例 55:45
- 右侧上下分割线可拖拽,默认比例 60:40
- 分割比例保存到 localStorage刷新后恢复
- 右上面板可折叠为 Tab 栏
- 右下面板可折叠为 Tab 栏
- 两个面板同时折叠后对话面板获得最大空间
- 屏幕宽度 < 1024px 时显示小屏幕提示
- 旧路由 `/terminal` 重定向正确
**Verification:** 打开 GUI 后左半屏是对话,右半屏上下分割,拖拽和折叠功能正常
### U2. 侧边导航精简为图标模式
**Goal:** 将侧边导航精简为 32px 宽的图标导航,点击切换右侧面板 Tab。
**Requirements:** R3
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (修改)
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` (修改)
**Approach:**
- 在 AgentLayout 的水平 SplitPane 之前添加 32px 宽的图标导航栏
- 导航项对话MessageOutlined、工作流ApartmentOutlined、知识库BookOutlined、技能AppstoreOutlined、监控DashboardOutlined、设置SettingOutlined
- 点击导航图标时调用对应 QuadrantPanel 的 `setActiveTab()` 方法并展开面板
- 当前激活的导航图标高亮显示(使用 `--color-primary`
- TopNav 添加导航栏折叠/展开按钮
- 导航栏折叠时宽度为 0px展开时为 32px
- 导航状态与路由同步
**Patterns to follow:** QuadrantPanel 的 `setActiveTab()` 方法
**Test scenarios:**
- 导航栏显示 6 个图标,宽度 32px
- 点击「工作流」图标 → 右上面板切换到工作流 Tab 并展开
- 点击「监控」图标 → 右下面板切换到监控 Tab 并展开
- 点击「对话」图标 → 聚焦左侧对话面板
- 当前激活图标高亮
- TopNav 按钮可折叠/展开导航栏
- 折叠后导航栏宽度为 0px
**Verification:** 导航栏图标可切换右侧面板内容,折叠/展开正常
### U3. 暗色主题 Token 定义与切换
**Goal:** 在现有浅色 Token 基础上新增暗色主题 Token支持一键切换。
**Requirements:** R4, R6
**Dependencies:** 无
**Files:**
- `src/agentkit/server/frontend/src/styles/tokens.css` (修改 — 添加 `[data-theme="dark"]` 块)
- `src/agentkit/server/frontend/src/styles/theme.ts` (修改 — 暗色主题 Ant Design 映射)
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` (修改 — 添加主题切换按钮)
- `src/agentkit/server/frontend/src/stores/theme.ts` (新建 — 主题状态管理)
**Approach:**
- 在 `tokens.css` 末尾添加 `[data-theme="dark"]` 选择器块,覆盖所有颜色相关 token背景、文本、边框、主色、语义色、灰色阶、代码色
- 暗色主题配色:深色背景(#1a1a2e 系列)、荧光强调色(保持 Indigo 主色但调亮)、终端原生感
- 新建 `stores/theme.ts`:管理主题状态(`light`/`dark`),切换时修改 `document.documentElement.dataset.theme`,偏好保存到 localStorage
- TopNav 添加太阳/月亮图标切换按钮
- `theme.ts` 中的 `readToken()` 已在运行时从 CSS 变量读取,暗色 token 覆盖后 Ant Design 主题自动跟随
**Patterns to follow:** 现有 `tokens.css``:root` 定义模式,`theme.ts` 的 `readToken()` 模式
**Test scenarios:**
- 点击 TopNav 月亮图标 → 界面切换到暗色主题
- 点击太阳图标 → 切换回浅色主题
- 暗色主题下所有组件正常显示(文字可读、边框可见、按钮可点击)
- 暗色主题下 Ant Design 组件(按钮、输入框、下拉框、模态框)正常显示
- 主题偏好保存到 localStorage刷新后保持
- 代码块在暗色主题下使用暗色代码配色
**Verification:** 主题切换按钮可用,两种主题下所有界面元素正常显示
---
### 迭代 2组件样式统一
### U4. 组件样式统一与 Ant Design 覆盖清理
**Goal:** 所有组件统一引用 Design Token清理 App.vue 中的 `!important` 覆盖。
**Requirements:** R7
**Dependencies:** U3
**Files:**
- `src/agentkit/server/frontend/src/App.vue` (修改 — 清理全局覆盖)
- `src/agentkit/server/frontend/src/components/layout/SideNav.vue` (修改 — 迁移到 token)
- `src/agentkit/server/frontend/src/styles/theme.ts` (修改 — 增强组件级 token 映射)
- 各组件 scoped 样式中的硬编码值 (修改 — 替换为 token 引用)
**Approach:**
- 将 App.vue 中的 `.ant-btn`、`.ant-card` 等全局覆盖迁移到 `theme.ts` 的组件级 token 映射中,消除 `!important`
- SideNav.vue 的硬编码 `rgba()` 值替换为 token 引用
- 扫描所有组件 scoped 样式中的硬编码颜色/间距值,替换为 token 引用
- 主色统一为 `--color-primary`(消除 `#1677ff`/`#1890ff` 残留)
**Patterns to follow:** `theme.ts` 的组件级 token 映射模式
**Test scenarios:**
- App.vue 中无 `!important` 覆盖
- 所有组件 scoped 样式中无硬编码颜色值
- 浅色和暗色主题下所有组件样式一致
- Ant Design 组件(按钮、卡片、标签、模态框、选择框)圆角和间距统一
**Verification:** 搜索代码中无硬编码颜色值(除 token 定义文件外),两种主题下样式一致
---
### 迭代 3交互增强
### U5. 过渡动画
**Goal:** 为所有交互添加过渡动画。
**Requirements:** R8
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/QuadrantPanel.vue` (修改 — 折叠/展开动画)
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (修改 — Tab 切换动画)
- `src/agentkit/server/frontend/src/views/ChatView.vue` (修改 — 消息列表动画)
- `src/agentkit/server/frontend/src/styles/transitions.css` (修改 — 如需新增动画类)
**Approach:**
- QuadrantPanel 折叠/展开:使用 Vue `<Transition>` 包裹 body 区域,应用 `collapse` 动画类
- Tab 切换:使用 Vue `<Transition>` 包裹 content 区域,应用 `fade` 动画类
- ChatView 消息列表:使用 `<TransitionGroup>` 包裹消息列表,应用 `stagger-list` 动画类
- 路由切换:使用 `<Transition>` 包裹 `<router-view>`,应用 `fade` 动画类
**Patterns to follow:** 现有 `transitions.css` 定义的动画类
**Test scenarios:**
- 面板折叠/展开有平滑过渡200ms ease
- Tab 切换有淡入淡出150ms
- 新消息出现有交错渐入效果
- 动画不影响操作响应速度
**Verification:** 所有交互有流畅的过渡动画,无生硬切换
### U6. 操作反馈
**Goal:** 为用户操作提供即时反馈。
**Requirements:** R9
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/common/AppToast.vue` (新建 — Toast 通知组件)
- `src/agentkit/server/frontend/src/components/common/SkeletonLoader.vue` (新建 — 骨架屏组件)
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` (修改 — WebSocket 断连横幅)
- `src/agentkit/server/frontend/src/stores/chat.ts` (修改 — 使用 Toast 替代错误提示)
**Approach:**
- 新建 AppToast 组件:基于 Ant Design Vue `message` API 封装,支持 success/error/warning/info 四种类型
- 新建 SkeletonLoader 组件:使用现有 `skeleton-pulse` 关键帧动画,支持不同形状(文本/卡片/列表)
- TopNav 添加 WebSocket 断连横幅:使用 `slide-down` 动画类
- chat.ts 中的错误提示从 `console.error` 改为 Toast 通知
**Patterns to follow:** 现有 `transitions.css``skeleton-pulse` 动画
**Test scenarios:**
- 操作成功时显示绿色 Toast 通知
- 操作失败时显示红色 Toast 通知
- 加载状态显示骨架屏而非 `<a-spin>`
- WebSocket 断连时顶部显示黄色横幅
- 重连后横幅自动消失
**Verification:** 所有操作有即时视觉反馈
### U7. 空状态设计
**Goal:** 为所有空状态提供品牌化插图和引导文案。
**Requirements:** R10
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/common/EmptyState.vue` (新建 — 通用空状态组件)
- `src/agentkit/server/frontend/src/views/ChatView.vue` (修改 — 对话空状态)
- `src/agentkit/server/frontend/src/views/WorkflowView.vue` (修改 — 工作流空状态)
- `src/agentkit/server/frontend/src/views/EvolutionView.vue` (修改 — 监控空状态)
- `src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue` (修改 — 知识库空状态)
- `src/agentkit/server/frontend/src/views/SkillsView.vue` (修改 — 技能空状态)
**Approach:**
- 新建 EmptyState 通用组件:接受 iconAnt Design 图标组件、title、description、action可选操作按钮三个 props
- 各视图的空状态使用 EmptyState 组件替换当前的纯文字提示
- 对话空状态MessageOutlined + "开始对话" + "输入消息与 Agent 交互"
- 工作流空状态ApartmentOutlined + "创建工作流" + "拖拽节点构建自动化流程"
- 监控空状态DashboardOutlined + "暂无监控数据" + "执行任务后数据将自动更新"
- 知识库空状态BookOutlined + "添加知识源" + "上传文档或配置外部知识库"
- 技能空状态AppstoreOutlined + "注册技能" + "通过 YAML 配置定义技能"
**Patterns to follow:** Ant Design Vue 的 `a-empty` 组件模式
**Test scenarios:**
- 新用户打开对话页面显示空状态引导
- 工作流列表为空时显示空状态引导
- 监控数据为空时显示空状态引导
- 空状态组件在浅色和暗色主题下正常显示
**Verification:** 所有空状态有品牌化插图和引导文案
### U8. 拖拽交互增强
**Goal:** 优化拖拽操作的视觉反馈。
**Requirements:** R11
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/SplitPane.vue` (修改 — 拖拽比例提示)
- `src/agentkit/server/frontend/src/views/WorkflowView.vue` (修改 — 节点拖拽预览,如 FlowCanvas 支持)
**Approach:**
- SplitPane 拖拽时:在分割线旁显示当前比例百分比(如 "55%"),拖拽结束后淡出
- SplitPane 拖拽时:分割线高亮加粗(从 2px 到 4px颜色从 `--border-color` 变为 `--color-primary`
- 工作流节点拖拽:如果 Vue Flow 支持自定义拖拽预览,添加放置预览指示
**Patterns to follow:** 现有 SplitPane 的拖拽处理模式
**Test scenarios:**
- 拖拽分割线时显示当前比例百分比
- 拖拽时分割线高亮加粗
- 拖拽结束后比例提示淡出
- 键盘调整分割线时也显示比例提示
**Verification:** 拖拽操作有清晰的视觉反馈
---
## Scope Boundaries
**Deferred to follow-up work:**
- 代码 Diff 查看器实现右上「代码」Tab 仍为占位)
- Cmd+K 内联编辑
- @-mention 上下文引用
- 响应式移动端适配
- SideNav (Legacy) 组件迁移AppLayout 保留作为回退)
**Outside this product's identity:**
- 多用户协作/实时协同编辑
- 插件市场
- 代码编辑器(只读预览)
---
## Risks & Dependencies
| Risk | Impact | Mitigation |
|------|--------|-----------|
| 暗色主题 token 覆盖不完整 | 部分组件在暗色下显示异常 | 逐组件验证,优先覆盖高频组件 |
| Ant Design Vue 4.x CSS-in-JS 主题跟随 | 暗色切换后 Ant 组件可能不跟随 | `readToken()` 运行时读取确保跟随,需验证 |
| 布局重构影响子视图高度计算 | 子视图内容溢出或空白 | 子视图使用 `height: 100%` + `overflow: auto` |
| 过渡动画性能 | 大量 DOM 操作时卡顿 | 使用 CSS transform/opacity 触发 GPU 加速 |

View File

@ -0,0 +1,807 @@
---
title: "feat: AgentKit Platform Experience Upgrade"
status: active
created: 2026-06-13
plan-type: feat
depth: deep
origin: docs/brainstorms/2026-06-13-agentkit-platform-experience-upgrade-requirements.md
---
# feat: AgentKit Platform Experience Upgrade
## Summary
对 Fischer AgentKit 进行平台级体验升级,四线并行推进:布局重构为左对话+右双栏、对话体验深化(首 Token 即渲染+消息格式增强+@-mention 四类引用、响应速度核心优化、暗色主题与交互增强、Computer Use MVP——分三个冲击波迭代交付。
## Problem Frame
AgentKit 后端能力丰富ReAct、Skill、Pipeline、记忆、自进化、多 Agent 市场),但 GUI 仍处于"功能可用但体验粗糙"状态。核心痛点:对话空间被压缩到 1/4 屏幕、消息纯文本无高亮、首 Token 延迟 5-10 秒、无暗色主题、无交互反馈、Computer Use 为占位。需求文档(见 origin定义了 25 个需求R1-R25本计划定义如何实现。
---
## Key Technical Decisions
**KTD-1: 布局重构通过调整 SplitPane 嵌套实现,子视图零修改。** 当前 AgentLayout 使用三层 SplitPane水平→左垂直+右垂直重构为两层水平→右垂直ChatView 直接作为水平 SplitPane 的 first slot。QuadrantPanel 和 SplitPane 组件不变,只改 AgentLayout 的嵌套结构和路由映射。(see origin: R1)
**KTD-2: 消息格式增强基于 MarkdownIt 插件扩展 + DOMPurify 白名单扩展。** 当前 ChatMessage 已使用 MarkdownIt 渲染,添加 `markdown-it-highlightjs` 插件实现代码高亮,自定义 `tool_call``file_preview` 块级渲染器实现工具调用卡片和文件预览。关键:当前 DOMPurify 的 `ALLOWED_TAGS` 白名单不包含 `div`、`img`、`button`,自定义渲染器输出的 HTML 元素会被过滤,必须扩展白名单。不引入新的 Markdown 渲染引擎。(see origin: R8, R9, R10)
**KTD-3: @-mention 通过扩展 WebSocket 消息协议实现。** 在现有 `WsClientMessage``sources` 字段基础上扩展,引用项编码为 `{type: "mention", mention_type: "file"|"skill"|"workflow"|"agent", id: string, label: string}`,后端解析后注入 Agent 上下文。新增 `/api/v1/portal/mention-suggest` REST 端点提供 autocomplete 数据。(see origin: R13, R14)
**KTD-4: Computer Use MVP 使用 pyautogui + screencapture 实现截屏和点击。** 替换 `DockerComputerUseSession` 的 stub 为 `LocalComputerUseSession`macOS 使用 `screencapture` 截屏 + `pyautogui` 执行点击/输入操作Linux 使用 `xdotool` + `scrot`。不依赖 Docker 容器化。前端新增 ComputerUseView 实际界面替代占位页。(see origin: R19, R20)
**KTD-5: 暗色主题通过 `[data-theme="dark"]` CSS 选择器覆盖 + 响应式 Ant Design token。** 在现有 `tokens.css` 中添加 `[data-theme="dark"]` 块覆盖同名 CSS 变量,新增 `stores/theme.ts` 管理主题状态和 localStorage 持久化。关键:`styles/theme.ts` 的 `readToken()` 是模块加载时一次性执行的,切换主题后需重新调用生成新的 `themeConfig` 并传给 ConfigProvider否则 Ant Design 组件不跟随暗色主题。(see origin: R11)
**KTD-6: 响应速度优化 U1-U4 已实现,仅补充 portal.py 的 ReActEngine 复用。** `HeuristicClassifier`、`_classify_merged`、`parallel_tools`、`AsyncWriteQueue` 均已在代码中。`chat.py` 已有 ReActEngine 复用逻辑,但 `portal.py` 每次创建新实例,需对齐。(see origin: R5, R6, R7)
---
## High-Level Technical Design
### 迭代 1 架构:对话体验质变
```mermaid
flowchart TB
subgraph Frontend
AL[AgentLayout] --> SP_H[SplitPane horizontal 55:45]
SP_H --> CV[ChatView 全高]
SP_H --> SP_V[SplitPane vertical 60:40]
SP_V --> QP_TR[QuadrantPanel 右上]
SP_V --> QP_BR[QuadrantPanel 右下]
CV --> CI[ChatInput]
CV --> CM[ChatMessage]
CM --> MD[MarkdownIt + highlightjs]
CM --> TC[ToolCallCard 渲染器]
CM --> FP[FilePreview 渲染器]
CI --> WS[WebSocket]
end
subgraph Backend
WS --> PORTAL[portal.py]
PORTAL --> HC[HeuristicClassifier 已实现]
PORTAL --> MERGE[_classify_merged 已实现]
PORTAL --> RE[ReActEngine 复用]
RE --> STREAM[execute_stream]
STREAM --> |token 事件| WS
end
```
### @-mention 数据流(迭代 2
```mermaid
sequenceDiagram
participant U as User
participant CI as ChatInput
participant API as /mention-suggest
participant WS as WebSocket
participant PORTAL as portal.py
participant AGENT as Agent
U->>CI: 输入 @
CI->>API: GET /mention-suggest?q=keyword
API-->>CI: [{type, id, label, icon}]
U->>CI: 选择引用项
CI->>CI: 添加 ContextPill
U->>CI: 发送消息
CI->>WS: {type:chat, message, mentions:[...]}
WS->>PORTAL: 解析 mentions
PORTAL->>PORTAL: 查询引用内容
PORTAL->>AGENT: 注入上下文
AGENT-->>WS: 流式响应
WS-->>CI: 渲染响应
```
---
## Requirements Traceability
| Requirement | Iteration | Implementation Unit(s) |
|-------------|-----------|----------------------|
| R1. 左对话+右双栏 | 1 | U1 |
| R2. 面板折叠 | 1 | U1 |
| R3. 侧边导航图标 | 1 | U2 |
| R4. 小屏幕适配 | 1 | U1 |
| R5. 启发式分类器 | 1 | 已实现 |
| R6. 合并路由调用 | 1 | 已实现 |
| R7. 首 Token 即渲染 | 1 | U3 |
| R8. 代码块高亮 | 1 | U4 |
| R9. 工具调用可视化 | 1 | U5 |
| R10. 图片/文件预览 | 1 | U6 |
| R11. 暗色主题 | 2 | U7 |
| R12. 组件样式统一 | 2 | U8 |
| R13. @-mention Autocomplete | 2 | U9 |
| R14. @-mention 上下文注入 | 2 | U10 |
| R15. @-mention 引用标签 | 2 | U9 |
| R16. 过渡动画 | 2 | U11 |
| R17. 操作反馈 | 2 | U12 |
| R18. 空状态设计 | 2 | U13 |
| R19. 截屏查看 | 3 | U14 |
| R20. 简单点击操作 | 3 | U14 |
| R21. Computer Use 面板 | 3 | U15 |
| R22. 并行工具执行 | 3 | 已实现 |
| R23. 异步会话写入 | 3 | 已实现 |
| R24. 分割线拖拽增强 | 3 | U16 |
| R25. 面板折叠缩略预览 | 3 | U17 |
---
## Implementation Units
### 迭代 1对话体验质变
---
### U1. AgentLayout 布局重构
**Goal:** 将四象限等分布局重构为左对话+右双栏布局,对话面板占满左半屏。
**Requirements:** R1, R2, R4
**Dependencies:** None
**Files:**
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` — 重构 SplitPane 嵌套
- `src/agentkit/server/frontend/src/router/index.ts` — 移除 terminal 路由,调整 quadrant 元数据
- `src/agentkit/server/frontend/src/styles/responsive.css` — 调整断点
**Approach:**
1. 移除左侧垂直 SplitPaneChatView 直接作为水平 SplitPane 的 `#first` slot
2. 保留右侧垂直 SplitPane右上代码/工作流/知识库,右下:监控/技能/设置)
3. 水平 SplitPane 默认比例改为 0.5555:45
4. 移除 `agent-terminal` 路由定义
5. 调整 responsive.css≥1280px 完整展示1024-1280px 右下面板默认折叠,<1024px 提示。**注意** 当前 responsive.css 的选择器基于四象限布局结构 `.split-pane--horizontal > .split-pane__second .split-pane--vertical > .split-pane__second .quadrant-panel`重构后这些选择器完全失效必须根据新的两层 SplitPane 结构重写所有象限相关选择器
**Patterns to follow:** 现有 SplitPane 嵌套模式QuadrantPanel 的 Tab 配置模式localStorage 持久化 key 命名 `agent-*`
**Test scenarios:**
- 布局渲染为左对话+右双栏ChatView 占满左半屏高度
- 左右分割线可拖拽,默认 55:45范围 20%-80%
- 右侧上下分割线可拖拽,默认 60:40
- 分割比例保存到 localStorage刷新后恢复
- 右上面板折叠后仅显示 Tab 栏(约 38px
- 右下面板折叠后仅显示 Tab 栏
- 两个面板可同时折叠
- 折叠/展开有 200ms ease 过渡动画
- 屏幕宽度 <1024px 显示提示
- 屏幕宽度 1024-1280px 右下面板默认折叠
- terminal 路由不再存在
**Verification:** 手动测试布局在不同屏幕宽度下的表现;折叠/展开动画流畅localStorage 持久化正常。
---
### U2. 侧边导航精简为图标模式
**Goal:** 将侧边导航精简为 32px 宽图标导航栏,点击图标切换右侧面板 Tab 并展开。
**Requirements:** R3
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` — 添加侧边导航切换按钮
- `src/agentkit/server/frontend/src/components/layout/IconNav.vue` — 新建 32px 图标导航组件
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` — 集成 IconNav
**Approach:**
1. 新建 `IconNav.vue`32px 宽图标导航栏,只显示图标(对话、工作流、知识库、技能、监控、设置)。注意:现有 `SideNav.vue` 是遗留的 AppLayout 组件240px 宽暗色侧边栏),不在 AgentLayout 中使用,不需要修改。
2. 点击图标:调用对应 QuadrantPanel 的 `setActiveTab()` 并展开面板
3. 当前激活图标高亮(使用 `--color-primary` Token
4. TopNav 添加折叠/展开 SideNav 的按钮
5. SideNav 状态保存到 localStorage
**Patterns to follow:** QuadrantPanel 的 `setActiveTab()` 暴露方法;现有 SideNav 的导航项定义模式
**Test scenarios:**
- SideNav 宽度为 32px只显示图标
- 点击对话图标ChatView 获得焦点
- 点击工作流图标,右上面板切换到 workflow Tab 并展开
- 点击监控图标,右下面板切换到 monitor Tab 并展开
- 当前激活图标高亮
- TopNav 按钮可折叠/展开 SideNav
- 折叠状态保存到 localStorage
**Verification:** 手动测试图标导航与面板联动;折叠/展开正常。
---
### U3. 首步骤即渲染 + portal.py ReActEngine 复用
**Goal:** 前端接收到首个流式步骤即开始渲染;后端 portal.py 复用 ReActEngine 实例。
**Requirements:** R7
**Dependencies:** None
**Files:**
- `src/agentkit/server/frontend/src/stores/chat.ts` — 确认流式渲染逻辑正确处理首个步骤
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` — 确认流式内容渲染无闪烁
- `src/agentkit/server/routes/portal.py` — ReActEngine 复用(对齐 chat.py 模式,覆盖 SSE + WS 两个路径)
**Approach:**
1. 前端:当前 `handleWsMessage``step` 事件在 `event_type === 'final_answer'` 时逐块累加 content确认首个步骤到达即渲染。注意后端当前按"步骤"粒度推送thinking/tool_call/tool_result/final_answer不是逐 Token 推送。"首 Token 即渲染"在此架构下实际含义是"首步骤即渲染"——`final_answer` 事件的 `data.output` 是文本块,到达即显示。
2. 后端portal.py 有两处创建 ReActEngineSSE 路径约第 342 行WS 路径约第 661 行),均需改为复用 agent 上已绑定的 `_react_engine`(对齐 chat.py 第 197 行的 `getattr(agent, "_react_engine", None)` 模式)。复用时调用 `react_engine.reset()` 重置内部状态。
3. 注意 `agent_pool.py``create_agent_from_skill()` 已在 agent 上绑定 `agent._react_engine`portal.py 应优先使用该实例。
**Patterns to follow:** chat.py 的 `getattr(agent, "_react_engine", None)` 复用模式
**Test scenarios:**
- 发送简单问候,首 Token 在 1 秒内渲染
- 流式输出逐字显示,无整体延迟
- ReActEngine 在同一会话中复用,不创建新实例
- 会话结束后 ReActEngine 正确重置
- token 事件和 final_answer 事件均触发前端渲染更新
**Verification:** 手动测试对话首 Token 延迟;检查 portal.py 日志确认 ReActEngine 复用。
---
### U4. 代码块语法高亮
**Goal:** 消息中的代码块自动识别语言并语法高亮,支持复制按钮。
**Requirements:** R8
**Dependencies:** None
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` — 添加 highlight.js 插件 + 扩展 DOMPurify 白名单
- `src/agentkit/server/frontend/src/styles/` — 代码高亮主题样式
- `src/agentkit/server/frontend/package.json` — 添加 `highlight.js` 依赖
**Approach:**
1. 安装 `highlight.js``markdown-it-highlightjs`(或手动配置 MarkdownIt 的 highlight 选项)
2. 在 ChatMessage 的 MarkdownIt 实例中配置 highlight 函数使用 highlight.js
3. 代码高亮主题使用 Catppuccin Mocha与 tokens.css 中的代码主题一致)
4. 添加代码块复制按钮(点击复制代码内容到剪贴板,显示 Toast 反馈)
5. 代码块语言标签显示在右上角
6. **扩展 DOMPurify 白名单**:当前 `ALLOWED_TAGS` 不包含 `code`、`pre`、`span`highlight.js 生成的标签),需添加这些标签及 `class`、`data-language` 属性
**Patterns to follow:** 现有 MarkdownIt 配置模式tokens.css 中的代码主题色(`--color-code-*`
**Test scenarios:**
- Python 代码块正确高亮(关键字、字符串、注释、函数名)
- JavaScript 代码块正确高亮
- 未指定语言的代码块使用自动检测
- 复制按钮点击后内容复制到剪贴板,显示 Toast
- 代码块语言标签正确显示
- 流式输出时代码块逐步高亮(不闪烁)
**Verification:** 手动测试不同语言代码块的渲染效果。
---
### U5. 工具调用可视化
**Goal:** 工具调用显示为可折叠的步骤卡片,展示工具名称、参数摘要、执行状态、结果预览。
**Requirements:** R9
**Dependencies:** U3
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` — 添加工具调用渲染逻辑 + 扩展 DOMPurify 白名单
- `src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue` — 新建工具调用卡片组件
**Approach:**
1. 创建 `ToolCallCard.vue` 组件:可折叠卡片,显示工具名称(图标+名称、参数摘要截断显示、执行状态pending/running/completed/error、结果预览折叠时显示前 2 行)
2. 在 ChatMessage 中,检测 `step` 事件中的 `tool_call``tool_result` 事件类型,将配对的工具调用渲染为 ToolCallCard
3. 利用 chat store 中 `streamingSteps` 的数据,匹配工具调用和结果
4. 折叠/展开动画使用现有 `transitions.css``collapse`
5. **扩展 DOMPurify 白名单**:添加 `div`、`button`、`data-tool-call`、`data-tool-result` 等标签和属性,确保 ToolCallCard 的 HTML 不被过滤
**Patterns to follow:** 现有 `streamingSteps` 数据结构transitions.css 的 collapse 动画QuadrantPanel 的折叠模式
**Test scenarios:**
- 工具调用显示为卡片,包含工具名称和参数摘要
- 执行中状态显示 loading 指示器
- 完成后显示结果预览(前 2 行)
- 点击卡片展开查看完整参数和结果
- 多个工具调用按顺序显示
- 折叠/展开有平滑过渡动画
- 错误状态的工具调用显示错误信息
**Verification:** 手动测试触发工具调用的对话,验证卡片渲染和交互。
---
### U6. 图片和文件预览
**Goal:** 消息中的图片内联显示缩略图,文件显示为可下载卡片。
**Requirements:** R10
**Dependencies:** U4
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` — 添加图片/文件渲染逻辑 + 扩展 DOMPurify 白名单
- `src/agentkit/server/frontend/src/components/chat/FilePreview.vue` — 新建文件预览卡片组件
**Approach:**
1. 创建 `FilePreview.vue` 组件:文件名+大小+类型图标+下载按钮
2. 在 ChatMessage 的 MarkdownIt 渲染中,自定义 `image` 渲染规则:内联缩略图,点击放大
3. 检测消息中的文件链接URL 以常见文件扩展名结尾),渲染为 FilePreview 卡片
4. 图片缩略图使用 CSS `object-fit: contain`,最大高度 200px
5. **扩展 DOMPurify 白名单**:添加 `img` 标签及 `src`、`alt`、`loading` 属性
**Patterns to follow:** 现有 MarkdownIt 自定义渲染器模式tokens.css 的间距和圆角 Token
**Test scenarios:**
- 消息中的图片 URL 显示为内联缩略图
- 点击缩略图放大查看
- 文件链接显示为卡片(文件名+大小+类型图标)
- 下载按钮点击触发文件下载
- 非图片/文件链接正常渲染为超链接
**Verification:** 手动测试包含图片和文件链接的消息渲染。
---
### 迭代 2专业感 + 精准度
---
### U7. 暗色主题 Token 定义与切换
**Goal:** 在浅色 Token 基础上新增暗色主题 Token 变体,支持一键切换。
**Requirements:** R11
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/styles/tokens.css` — 添加 `[data-theme="dark"]`
- `src/agentkit/server/frontend/src/stores/theme.ts` — 新建主题 store
- `src/agentkit/server/frontend/src/styles/theme.ts` — 改造 `readToken()` 为可重复调用的函数,支持主题切换时重新生成 `themeConfig`
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` — 添加主题切换按钮
- `src/agentkit/server/frontend/src/App.vue` — 监听主题变化,更新 `data-theme` 属性和 ConfigProvider
**Approach:**
1. 在 tokens.css 末尾添加 `[data-theme="dark"]` 选择器块,覆盖所有颜色变量(背景、前景、边框、品牌色、语义色、代码主题色)
2. 暗色配色方案:深色背景 `#1a1a2e` 系列、荧光强调色、终端原生感
3. 创建 `stores/theme.ts``currentTheme` ref'light'|'dark'`toggleTheme()` 方法localStorage 持久化
4. **改造 `styles/theme.ts`**:当前 `readToken()` 在模块加载时一次性执行,生成静态 `themeConfig`。切换暗色主题后 CSS 变量值变了,但 `themeConfig` 不会重新计算。需将 `themeConfig` 改为响应式:导出 `getThemeConfig()` 函数App.vue 监听 `currentTheme` 变化时重新调用生成新 config 并传给 ConfigProvider
5. App.vue 监听 `currentTheme` 变化,更新 `document.documentElement.dataset.theme` 和 ConfigProvider 的 `theme` prop
6. TopNav 添加太阳/月亮图标切换按钮
**Patterns to follow:** 现有 tokens.css 的变量命名模式theme.ts 的 `readToken()` 运行时映射
**Test scenarios:**
- 点击切换按钮,界面从浅色切换到暗色
- 所有组件在暗色主题下正常显示(文字可读、对比度足够)
- 代码块在暗色主题下使用 Catppuccin Mocha 配色
- 主题偏好保存到 localStorage刷新后恢复
- 切换过渡平滑CSS transition on color variables
**Verification:** 手动测试暗色主题下所有页面的显示效果。
---
### U8. 组件样式统一
**Goal:** 所有组件统一引用 Design Token消除硬编码值。
**Requirements:** R12
**Dependencies:** U7
**Files:**
- `src/agentkit/server/frontend/src/components/layout/SideNav.vue` — 替换硬编码颜色
- `src/agentkit/server/frontend/src/App.vue` — 替换 `!important` 全局覆盖为 Token 驱动
- 各组件 scoped 样式中的硬编码值逐一替换
**Approach:**
1. 全局搜索 `rgba(`、`#` 开头的硬编码颜色值(排除 tokens.css 本身)
2. 逐一替换为对应的 CSS 变量引用
3. App.vue 中的 Ant Design 全局覆盖从 `!important` 改为通过 ConfigProvider token 注入
4. 确保暗色主题下替换后的变量值正确
**Patterns to follow:** tokens.css 的变量命名theme.ts 的 readToken() 映射
**Test scenarios:**
- 零硬编码颜色值tokens.css 除外)
- 浅色和暗色主题下所有组件样式一致
- Ant Design 组件通过 ConfigProvider token 驱动样式
- 无 `!important` 覆盖(特殊情况除外)
**Verification:** 代码搜索确认无硬编码颜色值;双主题视觉验证。
---
### U9. @-mention Autocomplete 前端
**Goal:** 对话输入框中输入 `@` 触发下拉选择器,支持四类引用,选中后显示为 ContextPill。
**Requirements:** R13, R15
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue` — 添加 @-mention 触发和选择逻辑
- `src/agentkit/server/frontend/src/components/chat/MentionDropdown.vue` — 新建下拉选择器组件
- `src/agentkit/server/frontend/src/api/client.ts` — 添加 mention-suggest API 调用
- `src/agentkit/server/frontend/src/api/types.ts` — 添加 MentionItem 类型定义 + 扩展 WsClientMessage 添加 `mentions` 字段
**Approach:**
1. 定义 `MentionItem` 类型:`{type: 'file'|'skill'|'workflow'|'agent', id: string, label: string, icon?: string, description?: string}`
2. ChatInput 监听输入,检测 `@` 字符触发 MentionDropdown
3. MentionDropdown 调用 `/api/v1/portal/mention-suggest?q=keyword` 获取建议列表
4. 选中后生成 ContextPill 添加到 `contextPills` 数组(复用现有 ContextPillData 接口,扩展 type/id 字段)
5. 发送消息时,将 mentions 数组附加到 WebSocket 消息
6. **R15 覆盖**ChatMessage 渲染时检测消息的 mentions 元数据,将 @引用渲染为可点击的标签/链接(点击跳转到对应面板或打开详情)
**⚠ 前后端协议耦合**U9 和 U10 必须在同一迭代内同步交付,否则 WebSocket 协议不兼容。
**Patterns to follow:** 现有 ContextPill 数据结构Ant Design Vue 的 AutoComplete/Select 组件模式
**Test scenarios:**
- 输入 `@` 触发下拉选择器
- 输入 `@文件名` 过滤显示匹配的知识库文档
- 输入 `@技能名` 过滤显示匹配的技能
- 输入 `@工作流名` 过滤显示匹配的工作流
- 输入 `@Agent名` 过滤显示匹配的 Agent
- 选中引用项后显示为 ContextPill
- ContextPill 可点击删除
- 发送消息时 mentions 数组正确附加
**Verification:** 手动测试四类 @-mention 的 autocomplete 和选择流程。
---
### U10. @-mention 后端上下文注入
**Goal:** 后端解析 @-mention 引用,将对应内容注入 Agent 推理上下文。
**Requirements:** R14
**Dependencies:** U9
**Files:**
- `src/agentkit/server/routes/portal.py` — 解析 mentions注入上下文
- `src/agentkit/server/routes/portal.py` — 新增 `/portal/mention-suggest` 端点
**Approach:**
1. 新增 `GET /api/v1/portal/mention-suggest?q=keyword` 端点聚合查询知识库文档、技能、工作流、Agent返回 `MentionItem[]`
2. WebSocket 消息中解析 `mentions` 字段
3. 根据 mention_type 和 id 查询对应内容:
- `file` → 从 KnowledgeBase 检索文档片段
- `skill` → 从 SkillRegistry 获取技能描述和工具定义
- `workflow` → 从 WorkflowStore 获取工作流定义
- `agent` → 从 AgentPool 获取 Agent 配置
4. 将引用内容作为结构化上下文注入 system_prompt 或 messages
**Patterns to follow:** 现有 portal.py 的路由和 CostAwareRouter 模式;各 Registry 的查询 API
**Test scenarios:**
- `/mention-suggest?q=test` 返回匹配的文件、技能、工作流、Agent
- @文件引用后Agent 回复中引用了文档内容
- @技能引用后Agent 使用了指定技能
- @工作流引用后Agent 了解工作流定义
- @Agent 引用后Agent 了解目标 Agent 的能力
- 多个 @-mention 同时使用,所有引用内容均注入
- 无效引用ID 不存在)优雅降级,不阻塞对话
**Verification:** 手动测试各类 @-mention 的上下文注入效果。
---
### U11. 过渡动画
**Goal:** 为所有交互添加过渡动画。
**Requirements:** R16
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/styles/transitions.css` — 确认/补充动画类
- `src/agentkit/server/frontend/src/components/layout/QuadrantPanel.vue` — Tab 切换淡入淡出
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` — 路由切换动画
- 各列表渲染组件 — 交错渐入
**Approach:**
1. 确认 transitions.css 中已有 fade、collapse、scale、stagger-list 类
2. QuadrantPanel Tab 切换添加 `<Transition name="fade">` 包裹
3. AgentLayout 路由切换添加 `<Transition name="fade">`
4. 列表项使用 `<TransitionGroup name="stagger-list">`
5. 所有时长引用 Design Token`--transition-fast: 150ms`、`--transition-normal: 200ms`
**Patterns to follow:** 现有 transitions.css 的动画类定义模式
**Test scenarios:**
- 面板折叠/展开有 200ms ease 过渡
- Tab 切换有 150ms 淡入淡出
- 列表项交错渐入stagger 50ms
- 路由切换有 200ms 淡入淡出
- 动画不阻塞交互(使用 CSS transition 而非 JS 动画)
**Verification:** 手动测试各交互动画效果。
---
### U12. 操作反馈
**Goal:** 为用户操作提供即时反馈。
**Requirements:** R17
**Dependencies:** U7
**Files:**
- `src/agentkit/server/frontend/src/components/common/ToastService.ts` — 新建 Toast 服务
- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue` — 按钮点击反馈
- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` — WebSocket 断连横幅
- 各加载状态组件 — 骨架屏替代 a-spin
**Approach:**
1. 创建 ToastService基于 Ant Design Vue 的 `message` 组件,封装 success/error/warning/info 方法
2. 按钮点击添加 `:active` 缩放反馈CSS `transform: scale(0.97)`
3. 加载状态:关键区域使用骨架屏(复用 transitions.css 的 `skeleton-pulse` 动画)
4. WebSocket 断连TopNav 下方显示红色横幅提示,重连后自动消失
**Patterns to follow:** Ant Design Vue message 组件transitions.css 的 skeleton-pulse 动画
**Test scenarios:**
- 操作成功显示绿色 Toast
- 操作失败显示红色 Toast
- 按钮点击有缩放反馈
- 加载状态显示骨架屏而非 Spin
- WebSocket 断连时顶部显示红色横幅
- 重连后横幅自动消失
**Verification:** 手动测试各类操作反馈。
---
### U13. 空状态设计
**Goal:** 为所有空状态提供品牌化插图和引导文案。
**Requirements:** R18
**Dependencies:** U7
**Files:**
- `src/agentkit/server/frontend/src/components/common/EmptyState.vue` — 新建空状态组件
- `src/agentkit/server/frontend/src/views/ChatView.vue` — 对话空状态
- `src/agentkit/server/frontend/src/views/WorkflowView.vue` — 工作流空状态
- `src/agentkit/server/frontend/src/views/EvolutionView.vue` — 监控空状态
- `src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue` — 知识库空状态
- `src/agentkit/server/frontend/src/views/SkillsView.vue` — 技能空状态
**Approach:**
1. 创建 EmptyState.vue 通用组件:接受 `title`、`description`、`icon`、`action` props
2. 各视图在数据为空时渲染 EmptyState提供引导文案和操作按钮
3. 图标使用 Ant Design Vue 的内置图标,配合品牌色
**Patterns to follow:** Ant Design Vue 的 `a-empty` 组件模式
**Test scenarios:**
- 对话空状态显示"开始你的第一次对话"引导
- 工作流空状态显示"创建第一个工作流"引导
- 监控空状态显示数据来源说明
- 知识库空状态显示"上传文档或配置信息源"引导
- 技能空状态显示"注册技能"引导
- 空状态组件在暗色主题下正常显示
**Verification:** 手动测试各视图的空状态显示。
---
### 迭代 3能力扩展
---
### U14. Computer Use MVP 后端pyautogui + screencapture
**Goal:** 实现本地截屏和点击操作的后端闭环。
**Requirements:** R19, R20
**Dependencies:** None
**Files:**
- `src/agentkit/tools/computer_use_session.py` — 新增 `LocalComputerUseSession`
- `src/agentkit/tools/computer_use.py` — 确保工具注册和降级链正确
**Approach:**
1. 创建 `LocalComputerUseSession` 类,实现 `start()`、`stop()`、`screenshot()`、`execute_action()` 方法
2. `screenshot()` 实现macOS 使用 `screencapture -x -t png <path>` 命令截屏,读取文件返回 base64Linux 使用 `scrot``xdg-screenshot`
3. `execute_action()` 实现:使用 `pyautogui` 库执行点击(`pyautogui.click(x, y)`)、输入(`pyautogui.typewrite()`)、滚动等操作
4. 注册到 `ComputerUseSessionManager`,作为默认会话类型(替代 Docker stub
5. 确保降级链正确Anthropic API → LocalComputerUseSession → Shell 替代建议
6. 添加 `pyautogui` 到项目依赖(`pyproject.toml`
**Patterns to follow:** 现有 `InMemoryComputerUseSession` 的接口模式ToolRegistry 注册模式
**Test scenarios:**
- OpenCLI 会话创建和销毁正常
- 截屏返回有效的 base64 PNG 数据
- 点击操作执行并返回结果
- ComputerUseTool 的降级链正确工作
- 会话管理器正确管理 OpenCLI 会话生命周期
**Verification:** 手动测试通过对话触发截屏和点击操作。
---
### U15. Computer Use 前端面板
**Goal:** 右上面板新增 Computer Use Tab展示截屏画面和操作历史。
**Requirements:** R21
**Dependencies:** U14
**Files:**
- `src/agentkit/server/frontend/src/views/ComputerUseView.vue` — 替换占位页为实际界面
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` — 右上面板添加 Computer Use Tab
- `src/agentkit/server/frontend/src/router/index.ts` — 添加 Computer Use 路由
**Approach:**
1. 重写 ComputerUseView.vue截屏画面显示区域支持缩放和滚动、操作历史列表、手动截屏按钮
2. 截屏画面通过 WebSocket 接收 base64 图片数据,渲染为 `<img>` 标签
3. 操作历史显示时间戳、操作类型、坐标/参数、结果摘要
4. 手动截屏按钮触发后端截屏命令
5. 右上面板 QuadrantPanel 添加 Computer Use Tab
**Patterns to follow:** 现有 QuadrantPanel Tab 配置模式WebSocket 消息处理模式
**Test scenarios:**
- Computer Use Tab 在右上面板显示
- 截屏画面正确渲染
- 截屏画面支持缩放和滚动
- 操作历史按时间倒序显示
- 手动截屏按钮触发截屏
- 暗色主题下正常显示
**Verification:** 手动测试 Computer Use 面板的截屏显示和操作历史。
---
### U16. 分割线拖拽增强
**Goal:** 拖拽分割线时高亮显示,显示当前比例百分比。
**Requirements:** R24
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/SplitPane.vue` — 添加拖拽高亮和比例显示
**Approach:**
1. 拖拽时 handle 元素添加高亮样式(加宽 + 品牌色背景)
2. 拖拽时显示比例百分比标签(如 "55%"),定位在 handle 旁边
3. 使用 CSS transition 确保高亮和标签的显示/隐藏平滑
**Patterns to follow:** 现有 SplitPane 的拖拽逻辑和 handle 样式
**Test scenarios:**
- 拖拽时分割线高亮
- 拖拽时显示当前比例百分比
- 百分比标签定位正确不遮挡内容
- 拖拽结束后高亮和标签消失
**Verification:** 手动测试拖拽交互。
---
### U17. 面板折叠缩略预览
**Goal:** 面板折叠时显示缩略内容预览。
**Requirements:** R25
**Dependencies:** U1
**Files:**
- `src/agentkit/server/frontend/src/components/layout/QuadrantPanel.vue` — 折叠时显示缩略预览
**Approach:**
1. 折叠状态下,在 Tab 栏下方显示约 60px 高的缩略预览区域
2. 预览内容根据当前活跃 Tab 类型显示:监控显示关键指标数字、技能显示技能数量、工作流显示节点缩略图
3. 预览区域使用半透明背景,不占用过多空间
**Patterns to follow:** QuadrantPanel 的折叠模式Design Token 的间距和圆角
**Test scenarios:**
- 右上面板折叠后显示缩略预览
- 右下面板折叠后显示缩略预览
- 预览内容根据活跃 Tab 更新
- 预览区域不遮挡 Tab 栏
- 暗色主题下预览区域正常显示
**Verification:** 手动测试折叠预览效果。
---
## Scope Boundaries
**在范围内:** 见需求文档的 Scope Boundaries。
**延迟到后续迭代:**
- Cmd+K 内联编辑
- Computer Use Docker 容器化隔离
- 代码 Diff 查看器实现
- 代码 Diff Accept/Reject 回滚
- 响应式移动端适配
- httpx 连接池配置优化U5 已实现,无需额外工作)
- A/B 测试框架和性能基准 CI
- Ant Design Vue 按需引入unplugin-vue-components
- ECharts 按需引入
**不在本产品身份内:**
- 多用户协作/实时协同编辑
- 插件市场
- 代码编辑器
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| @-mention 后端查询聚合性能差 | Medium | Medium — 慢速 autocomplete 影响体验 | mention-suggest 端点添加缓存TTL 30s结果集限制 20 条 |
| 本地截屏兼容性问题 | Medium | High — 不同 OS 环境截屏命令不同 | 检测 OS 类型选择对应命令,添加 fallback 到 InMemory 模式 |
| 暗色主题对比度不足 | Low | Medium — 部分组件在暗色下不可读 | 使用 WCAG AA 标准验证对比度,添加自动化对比度检查 |
| 消息格式增强破坏现有渲染 | Low | High — 已有消息显示异常 | MarkdownIt 插件添加 fallback解析失败时回退到纯文本DOMPurify 白名单扩展需谨慎测试 |
| 布局重构影响现有路由和状态 | Medium | Medium — localStorage 旧 key 导致异常 | 添加 key 版本化,旧 key 自动迁移或清理 |
| DOMPurify 白名单扩展引入 XSS | Low | High — 恶意内容可能注入 | 仅添加必要的标签和属性,不开放 `on*` 事件属性,`img` 的 `src` 限制为相对路径和已知域名 |
| Ant Design token 不跟随暗色切换 | Medium | Medium — 组件颜色不一致 | 改造 theme.ts 为响应式,切换时重新生成 themeConfig |
| 与 001 计划的 U-unit 重叠 | High | High — 并行执行产生合并冲突 | 003 吸收 001 的重叠 U-unit001 标注为"由 003 覆盖",不并行执行 |
---
## System-Wide Impact
- **前端布局:** AgentLayout 从三层 SplitPane 改为两层,所有依赖象限位置的路由和状态需调整
- **前端样式:** 暗色主题影响所有组件,需全面测试
- **WebSocket 协议:** @-mention 扩展了 `WsClientMessage``mentions` 字段,需前后端同步升级
- **后端路由:** 新增 `/portal/mention-suggest` 端点portal.py 需解析 mentions
- **工具系统:** 新增 OpenCLIComputerUseSessionComputerUseTool 降级链调整
- **响应速度:** portal.py 的 ReActEngine 复用减少实例创建开销
---
## Outstanding Questions
**Deferred to Implementation:**
- @-mention 后端解析的具体协议细节mentions 字段在 WebSocket 消息中的 JSON 结构
- 工具调用步骤卡片的折叠/展开交互细节:默认折叠还是展开
- 骨架屏的具体形状和占位内容
- 暗色主题的具体色值需要视觉调优
- R22/R23 对 portal.py 路径无效的决策portal.py 使用自己的 ConversationStore内存 dict不使用 SessionManager异步写入和并行工具执行对 portal 路径无影响。是否需要迁移 portal.py 到 SessionManager
**Plan Relationship:**
- 本计划003吸收了 001 计划GUI 产品化)的所有重叠 U-unit布局重构、暗色主题、交互增强001 计划应标注为"由 003 覆盖",避免并行执行产生合并冲突
---
## Sources & Research
- 需求文档:`docs/brainstorms/2026-06-13-agentkit-platform-experience-upgrade-requirements.md`
- 响应速度优化计划U1-U4 已实现):`docs/plans/2026-06-12-021-feat-chat-response-speed-optimization-plan.md`
- GUI 产品化计划(前一轮):`docs/plans/2026-06-13-001-feat-gui-productization-plan.md`
- 前端产品化计划:`docs/plans/2026-06-12-023-feat-frontend-productization-plan.md`
- 现有 Design Token 体系:`src/agentkit/server/frontend/src/styles/tokens.css`
- 现有 WebSocket 协议:`src/agentkit/server/frontend/src/api/types.ts`
- 现有 Computer Use 工具:`src/agentkit/tools/computer_use.py`、`src/agentkit/tools/computer_use_session.py`

View File

@ -0,0 +1,498 @@
---
title: "feat: P0 Production Hardening — LLM Cache, Semantic Routing, State Persistence"
status: active
created_at: 2026-06-14
type: feat
origin: "行业调研与项目审视2026-06-14"
depth: deep
---
# P0 Production Hardening — LLM Cache, Semantic Routing, State Persistence
## Summary
Three P0 gaps identified from industry benchmarking and project audit: (1) LLM response caching to reduce 30-50% token cost, (2) embedding-based semantic routing to improve intent matching quality at zero LLM cost, (3) critical state persistence for UsageTracker, EvolutionStore, and CascadeDetector to survive restarts and enable multi-instance deployment. Each unit requires detailed architecture design and code reasoning before implementation — design-first, code-second.
## Problem Frame
AgentKit has strong differentiation in self-evolution, quality management, and multi-paradigm engines, but three production-critical gaps prevent enterprise deployment:
1. **Every LLM request hits the provider** — no caching. Identical or similar requests waste tokens and money. Competitors like Dify have built-in caching.
2. **Routing relies on keyword matching and LLM classification** — no semantic understanding. Embedding-based routing is industry standard (Agentic RAG trend) and AgentKit already has embedding infrastructure but doesn't use it for routing.
3. **Critical state lives in memory** — UsageTracker, CascadeDetector, and EvolutionStore lose data on restart. Multi-instance deployment is impossible without shared state.
These gaps are P0 because they directly impact cost (caching), quality (routing accuracy), and reliability (state persistence) — the three pillars of production readiness.
---
## Requirements
- R1. LLM cache must support exact-match (hash-based) and semantic-match (embedding-based) cache hits
- R2. LLM cache must integrate transparently into `LLMGateway.chat()` without changing the public API
- R3. LLM cache must record usage on cache hits (0 cost) to maintain usage tracking integrity
- R4. Semantic routing must insert between Layer 1 and Layer 2 in `CostAwareRouter`
- R5. Semantic routing must use existing `OpenAIEmbedder` and `compute_cosine_similarity()` infrastructure
- R6. Semantic routing must pre-compute skill embeddings at registration time, not at query time
- R7. UsageTracker must persist records to Redis with O(1) write and efficient aggregation
- R8. CascadeDetector must persist state to Redis using atomic INCR operations
- R9. EvolutionStore must support both PostgreSQL and SQLite backends with a unified interface
- R10. All three features must degrade gracefully when Redis/PG is unavailable (fallback to in-memory)
- R11. Each unit must have detailed architecture design and code reasoning documented before implementation begins
---
## Key Technical Decisions
- **KTD-1. Cache key design**: Use `SHA256(model + system_prompt_hash + messages_content_hash + temperature + tools_hash)` for exact match. For semantic match, embed the last user message and compare against cached embeddings using cosine similarity > threshold. Rationale: exact match is fast and deterministic; semantic match catches paraphrased requests. Both are needed because exact match alone misses too many hits, and semantic match alone is too slow for every request.
- **KTD-2. Cache storage backend**: Implement `LLMCache` as a Protocol with `InMemoryLLMCache` and `RedisLLMCache` backends. In-memory uses `OrderedDict` with LRU eviction (following `EmbeddingCache` pattern). Redis uses `agentkit:llm_cache:{hash}` keys with TTL. Rationale: follows existing factory pattern (`create_message_bus`, `create_session_store`); in-memory for dev/single-instance, Redis for production.
- **KTD-3. Semantic routing insertion point**: Insert as Layer 1.5 between `HeuristicClassifier` and `_classify_merged()`. When Layer 1 returns medium complexity (0.3-0.7), try semantic routing first. If similarity > 0.85, return skill match directly (skip LLM). If similarity 0.6-0.85, pass skill hint to Layer 2 LLM (reduces LLM classification tokens). If < 0.6, proceed to Layer 2 unchanged. Rationale: this placement maximizes cost savings by avoiding LLM calls when semantic match is confident, while preserving the existing fallback chain.
- **KTD-4. Skill embedding source text**: Embed `f"{skill.description} | {' '.join(skill.intent.keywords)} | {' '.join(cap.tag for cap in skill.capabilities)}"` for each skill. Cache embeddings in a dict keyed by skill name, re-embed on skill registration/update. Rationale: combines all semantic signals; description alone misses keyword intent; keywords alone misses semantic meaning.
- **KTD-5. UsageTracker persistence strategy**: Use Redis Hash for time-series data. Key pattern: `agentkit:usage:{date}` with fields `{agent}:{model}` → JSON `{tokens, cost, latency_ms, count}`. Write via `HINCRBYFLOAT` for atomic increment. Query via `HGETALL` + client-side aggregation. Rationale: O(1) write, acceptable query performance, natural TTL by date, follows Redis patterns in project.
- **KTD-6. CascadeDetector persistence strategy**: Use Redis atomic operations. Key pattern: `agentkit:cascade:{session_id}:interactions` (INCR + TTL) and `agentkit:cascade:{session_id}:depth` (SET/GET + TTL). Rationale: INCR is atomic, no race conditions across instances; TTL prevents memory leaks; matches session lifecycle.
- **KTD-7. EvolutionStore interface unification**: Extend the base `EvolutionStore` Protocol to include `skill_version` and `ab_test` methods. Make `PersistentEvolutionStore` (SQLite) implement the unified Protocol. Add a new `PostgreSQLEvolutionStore` that uses async SQLAlchemy like the existing `EvolutionStore` but with the full unified interface. Rationale: current split (sync SQLite vs async PG) creates maintenance burden; unified Protocol enables backend-agnostic usage.
- **KTD-8. Graceful degradation pattern**: All three features use the same pattern — try preferred backend, catch connection error, log warning, fall back to in-memory. Controlled by `cache.backend`, `usage_store.backend`, `cascade_store.backend` config values (`"auto"` | `"redis"` | `"memory"`). `"auto"` tries Redis, falls back to memory. Rationale: production needs persistence, but dev/testing shouldn't require Redis.
---
## High-Level Technical Design
### LLM Cache Flow
```mermaid
flowchart TB
A[LLMGateway.chat] --> B{Cache enabled?}
B -->|no| F[Call Provider]
B -->|yes| C[Generate exact key]
C --> D{Exact match?}
D -->|hit| E[Return cached response]
D -->|miss| G[Generate embedding of last user msg]
G --> H{Semantic match? similarity > 0.92}
H -->|hit| E
H -->|miss| F
F --> I[Write to cache]
I --> J[Record usage]
E --> K[Record usage with 0 cost]
```
### Semantic Routing Flow
```mermaid
flowchart TB
A[CostAwareRouter.route] --> B[Layer 0: Regex rules]
B -->|matched| Z[Return DIRECT_CHAT]
B -->|unmatched| C[Layer 1: HeuristicClassifier]
C -->|low complexity| Z
C -->|medium-high| D[Layer 1.5: Semantic Router NEW]
D -->|sim > 0.85| E[Return SKILL_REACT with matched skill]
D -->|sim 0.6-0.85| F[Pass skill_hint to Layer 2]
D -->|sim < 0.6| G[Layer 2: LLM classification]
F --> G
G --> H[Return routing result]
```
### State Persistence Architecture
```mermaid
flowchart TB
subgraph "Current (In-Memory)"
UT1[UsageTracker dict]
CD1[CascadeDetector dict]
ES1[EvolutionStore SQLite]
end
subgraph "Target (Persistent)"
UT2[UsageStore Protocol]
CD2[CascadeStateStore Protocol]
ES2[UnifiedEvolutionStore Protocol]
UT2 -->|redis| R1[Redis Hash agentkit:usage:date]
UT2 -->|memory| M1[InMemoryUsageStore]
CD2 -->|redis| R2[Redis INCR agentkit:cascade:session]
CD2 -->|memory| M2[InMemoryCascadeStore]
ES2 -->|postgresql| P1[PG EvolutionEventModel + SkillVersionModel]
ES2 -->|sqlite| S1[PersistentEvolutionStore]
ES2 -->|memory| M3[InMemoryEvolutionStore]
end
```
---
## Scope Boundaries
### In Scope
- LLM response caching (exact + semantic match, in-memory + Redis backends)
- Semantic routing as Layer 1.5 in CostAwareRouter
- UsageTracker Redis persistence
- CascadeDetector Redis persistence
- EvolutionStore interface unification
- Configuration for all three features
- Architecture design documents for each unit before coding
### Deferred for Follow-Up
- Semantic cache using pgvector (current semantic match uses in-memory embedding comparison)
- Cache warming / pre-population strategies
- Routing cache (caching routing results for similar queries)
- Usage analytics dashboard (visualization of usage data)
- Multi-tenant resource quotas
- Rate limiting and concurrency control (P2)
- Distributed tracing visualization (P2)
---
## Implementation Units
### U1. LLM Cache Core
**Goal:** Implement the `LLMCache` Protocol, `InMemoryLLMCache`, and `RedisLLMCache` with exact-match and semantic-match capabilities.
**Dependencies:** None
**Files:**
- `src/agentkit/llm/cache.py` (new) — `LLMCache` Protocol, `InMemoryLLMCache`, `RedisLLMCache`, `CacheResult`, `CacheKey` generation
- `src/agentkit/llm/cache_key.py` (new) — `generate_cache_key()`, `generate_messages_hash()`, `generate_system_prompt_hash()`
- `tests/unit/llm/test_cache.py` (new) — unit tests for cache backends
**Approach:**
Architecture design before coding:
1. **CacheKey design reasoning**: The key must capture all inputs that affect LLM output. `model` determines which model responds. `system_prompt` sets behavior. `messages` carry the conversation. `temperature` affects randomness (only cache temperature=0 deterministically). `tools` affect tool_call availability. Hash each component independently so partial changes don't invalidate the entire key.
2. **Exact match implementation**: SHA-256 hash of concatenated component hashes. Store as `agentkit:llm_cache:{sha256_hex}` in Redis with TTL. In-memory uses OrderedDict keyed by hash string.
3. **Semantic match implementation**: For cache misses on exact match, embed the last user message using `OpenAIEmbedder`. Compare against cached embeddings using `compute_cosine_similarity()`. Store embeddings alongside cached responses. In-memory: linear scan of all cached embeddings. Redis: store embeddings in a separate key `agentkit:llm_cache_emb:{sha256_hex}`.
4. **Cache write policy**: Only cache responses where `temperature == 0` (deterministic). For temperature > 0, only exact-match cache applies (no semantic match, since outputs are non-deterministic).
5. **Cache invalidation**: TTL-based (configurable, default 3600s for exact, 86400s for semantic). Manual invalidation via `invalidate(pattern=None)` for admin operations.
**Patterns to follow:**
- `EmbeddingCache` in `src/agentkit/memory/embedder.py` — LRU + TTL pattern
- `create_session_store()` factory in `src/agentkit/session/store.py` — backend factory pattern
- `RedisSessionStore._get_redis()` — lazy Redis initialization
**Test scenarios:**
- Exact match: same messages + model → cache hit, returns identical response
- Exact miss: different messages → cache miss, calls provider, writes to cache
- Semantic match: paraphrased question (similarity > 0.92) → cache hit
- Semantic miss: unrelated question (similarity < 0.6) cache miss
- Temperature > 0: only exact match attempted, no semantic match
- TTL expiry: cached entry expires after TTL, next request is a miss
- Redis unavailable: falls back to in-memory cache with warning log
- Cache with tool_calls: response containing tool_calls is cached correctly
- Concurrent access: two concurrent requests for same key don't cause double-write issues
**Verification:** Unit tests pass; cache hit rate metric is observable; no change to `LLMGateway` public API.
---
### U2. LLM Cache Integration
**Goal:** Integrate `LLMCache` into `LLMGateway.chat()` transparently, with usage tracking on cache hits.
**Dependencies:** U1
**Files:**
- `src/agentkit/llm/gateway.py` (modify) — inject cache check before provider call, cache write after provider response
- `src/agentkit/llm/config.py` (modify) — add `CacheConfig` to `LLMConfig`
- `src/agentkit/server/app.py` (modify) — pass cache config to `LLMGateway`
- `tests/unit/llm/test_gateway_cache.py` (new) — integration tests for cached gateway
**Approach:**
Architecture design before coding:
1. **Insertion point reasoning**: Cache check must happen AFTER `LLMRequest` construction (line ~79 in gateway.py) but BEFORE provider call (line ~87). This ensures all request normalization (alias resolution, model fallback list) has completed. Cache write happens AFTER response validation but BEFORE usage tracking.
2. **Cache hit usage tracking**: On cache hit, call `_usage_tracker.record()` with the original `usage` data from the cached response but with `cost=0` and `latency_ms` from cache lookup time. This preserves usage query integrity — `get_usage()` still shows all requests, just with zero cost for cached ones.
3. **Stream handling**: `chat_stream()` is NOT cached in this iteration. Streaming requires collecting all chunks before caching, which adds latency and complexity. Document this as a known limitation.
4. **Configuration integration**: Add `CacheConfig` dataclass with `enabled: bool = False`, `backend: str = "auto"`, `exact_ttl: int = 3600`, `semantic_ttl: int = 86400`, `similarity_threshold: float = 0.92`, `max_entries: int = 10000`. Nest under `LLMConfig.cache`.
**Patterns to follow:**
- `LLMConfig` dataclass + `from_dict()` pattern for config
- `LLMGateway.__init__()` dependency injection pattern
**Test scenarios:**
- Cache disabled: requests pass through to provider normally
- Cache enabled, first request: cache miss, provider called, response cached
- Cache enabled, second identical request: cache hit, provider NOT called
- Cache hit usage tracking: usage record has 0 cost, correct token counts
- Cache miss + fallback: primary model fails, fallback model response cached under fallback model key
- Config from YAML: `LLMConfig.from_dict({"cache": {"enabled": true}})` works correctly
**Verification:** Integration tests pass; `LLMGateway.chat()` returns same `LLMResponse` shape whether cached or not; usage tracking includes cache hits.
---
### U3. Semantic Router
**Goal:** Implement embedding-based semantic routing as Layer 1.5 in `CostAwareRouter`, using existing `OpenAIEmbedder` and `compute_cosine_similarity()`.
**Dependencies:** None (independent of U1/U2, uses existing embedding infrastructure)
**Files:**
- `src/agentkit/chat/semantic_router.py` (new) — `SemanticRouter` class, `SkillEmbeddingIndex`
- `src/agentkit/chat/skill_routing.py` (modify) — integrate Layer 1.5 into `CostAwareRouter.route()`
- `tests/unit/chat/test_semantic_router.py` (new) — unit tests for semantic router
**Approach:**
Architecture design before coding:
1. **SkillEmbeddingIndex design reasoning**: Pre-compute embeddings for all registered skills at initialization. Source text: `f"{description} | {' '.join(keywords)} | {' '.join(capability_tags)}"`. Store as `dict[str, tuple[list[float], str]]` (skill_name → (embedding, source_text)). On skill registration/update, re-embed only the changed skill. This avoids O(n) embedding computation per query.
2. **Query-time flow**: Embed user query → compute cosine similarity against all skill embeddings → return top match if above threshold. This is O(n) in number of skills, but with <100 skills and 1536-dim vectors, this takes <5ms on CPU. No need for approximate nearest neighbor (ANN) index at this scale.
3. **Threshold design**: Three zones:
- `similarity > 0.85`: HIGH confidence → return skill match directly, skip Layer 2 LLM
- `0.6 <= similarity <= 0.85`: MEDIUM confidence → pass skill hint to Layer 2, reducing LLM classification tokens
- `similarity < 0.6`: LOW confidence → no semantic signal, Layer 2 runs unmodified
4. **Integration into CostAwareRouter**: Modify `route()` method. After Layer 1 (`_classify_merged()`), if complexity is medium (0.3-0.7), call `semantic_router.route(query)`. Based on confidence zone, either return directly or enhance the Layer 2 prompt with skill hint.
5. **Embedding provider**: Use `OpenAIEmbedder` by default. Support `MockEmbedder` for testing. Embedder is injected via constructor, not created internally.
**Patterns to follow:**
- `OpenAIEmbedder` + `EmbeddingCache` pattern for embedding computation
- `compute_cosine_similarity()` in `src/agentkit/utils/vector_math.py`
- `CostAwareRouter` constructor injection pattern
**Test scenarios:**
- Exact skill match: query "生成一篇关于AI的文章" matches `content_generator` skill (sim > 0.85)
- Partial skill match: query "优化内容" matches `geo_optimizer` skill (sim 0.6-0.85), skill hint passed to LLM
- No skill match: query "今天天气怎么样" has sim < 0.6 for all skills, Layer 2 runs normally
- Skill registration: new skill added → embedding computed and indexed
- Skill update: skill description changed → embedding re-computed
- Empty skill registry: semantic router returns None gracefully
- Embedder failure: OpenAIEmbedder throws error → semantic router logs warning, returns None, Layer 2 runs normally
- Chinese query: "帮我写一篇文章" matches content_generator skill correctly
**Verification:** Semantic router returns correct skill matches; Layer 2 LLM calls reduced by >50% for medium-complexity queries; no regression in routing accuracy.
---
### U4. UsageStore Persistence
**Goal:** Persist UsageTracker records to Redis, with in-memory fallback and efficient aggregation queries.
**Dependencies:** None
**Files:**
- `src/agentkit/llm/usage_store.py` (new) — `UsageStore` Protocol, `InMemoryUsageStore`, `RedisUsageStore`
- `src/agentkit/llm/providers/tracker.py` (modify) — delegate to `UsageStore` backend
- `tests/unit/llm/test_usage_store.py` (new) — unit tests for usage store backends
**Approach:**
Architecture design before coding:
1. **Redis data model reasoning**: Use Redis Hash per date for time-partitioned storage. Key: `agentkit:usage:{YYYY-MM-DD}`, field: `{agent_name}:{model}`, value: JSON `{prompt_tokens, completion_tokens, total_tokens, cost, latency_ms, count}`. Write via pipeline: `HINCRBYFLOAT` for numeric fields + `HINCRBY` for count. This is O(1) per write, atomic, and naturally partitions by date.
2. **Aggregation query design**: For `get_usage(agent=None, start=None, end=None)`: scan date keys in range via `HGETALL`, filter by agent/model in application code, aggregate in memory. For single-agent queries, use field prefix matching. This is O(days × agents) which is acceptable for dashboard queries.
3. **UsageStore Protocol**: Define `record(agent, model, usage: UsageRecord) -> None`, `query(agent=None, model=None, start=None, end=None) -> list[UsageRecord]`, `get_summary(agent=None, start=None, end=None) -> UsageSummary`. Both sync and async versions (sync for backward compat, async for Redis).
4. **Migration from UsageTracker**: `UsageTracker` becomes a thin wrapper that delegates to `UsageStore`. Existing `record()` and `get_usage()` APIs preserved. Internal `_records` list replaced by store backend.
5. **TTL management**: Each date key gets TTL of 90 days (configurable). This prevents unbounded Redis memory growth while preserving 3 months of usage data.
**Patterns to follow:**
- `SessionStore` Protocol in `src/agentkit/session/store.py` — Protocol definition pattern
- `RedisSessionStore._get_redis()` — lazy Redis initialization
- `create_session_store()` — factory function pattern
- `agentkit:usage:` key namespace convention
**Test scenarios:**
- Record and query: record usage → query returns matching records
- Date partitioning: records on different dates stored in different keys
- Aggregation: multiple records for same agent/model aggregated correctly
- Agent filter: query with agent filter returns only that agent's records
- Date range filter: query with start/end returns only records in range
- TTL: date keys have correct TTL set
- Redis unavailable: falls back to in-memory store with warning
- Concurrent writes: two concurrent records for same agent/model don't lose data
- Empty query: query with no matching records returns empty list
**Verification:** Usage data survives process restart; `get_usage()` returns same shape as before; Redis memory usage bounded by TTL.
---
### U5. CascadeStateStore Persistence
**Goal:** Persist CascadeDetector state to Redis using atomic operations, enabling multi-instance cascade detection.
**Dependencies:** None
**Files:**
- `src/agentkit/quality/cascade_store.py` (new) — `CascadeStateStore` Protocol, `InMemoryCascadeStore`, `RedisCascadeStore`
- `src/agentkit/quality/cascade_detector.py` (modify) — delegate to `CascadeStateStore` backend
- `tests/unit/quality/test_cascade_store.py` (new) — unit tests for cascade store backends
**Approach:**
Architecture design before coding:
1. **Redis data model reasoning**: Use simple string keys with INCR for atomic counting. Key: `agentkit:cascade:{session_id}:interactions` (INCR + TTL), `agentkit:cascade:{session_id}:depth` (GET/SET + TTL). TTL aligned with session TTL (default 86400s). INCR is atomic — no race conditions across instances.
2. **Protocol design**: `CascadeStateStore` with `increment_interactions(session_id) -> int`, `get_interactions(session_id) -> int`, `set_depth(session_id, depth) -> None`, `get_depth(session_id) -> int`, `reset(session_id) -> None`, `get_stats(session_id) -> CascadeStats`.
3. **Integration into CascadeDetector**: Replace internal `_interaction_counts` and `_loop_depths` dicts with `CascadeStateStore` backend. All methods delegate to store. `CascadeDetector` becomes stateless — all state lives in the store.
4. **Session TTL alignment**: When `increment_interactions()` is called, refresh the key TTL to match session TTL. This ensures state is cleaned up when sessions expire.
**Patterns to follow:**
- Same Protocol + factory + fallback pattern as U4
- Redis INCR atomic operation pattern
- `agentkit:cascade:` key namespace
**Test scenarios:**
- Increment and get: increment interactions → get returns correct count
- Set and get depth: set depth → get returns correct depth
- Reset: reset session → interactions and depth both cleared
- TTL: keys have TTL set, expire after session timeout
- Multi-instance: two instances incrementing same session see consistent count
- Redis unavailable: falls back to in-memory store
- Session isolation: different sessions have independent state
**Verification:** Cascade detection state survives process restart; multi-instance deployment detects cascades correctly; no false positives from state loss.
---
### U6. EvolutionStore Interface Unification
**Goal:** Unify `EvolutionStore` and `PersistentEvolutionStore` interfaces, add PostgreSQL backend with full feature set.
**Dependencies:** None
**Files:**
- `src/agentkit/evolution/evolution_store.py` (modify) — define unified `EvolutionStoreProtocol`, refactor existing stores
- `src/agentkit/evolution/models.py` (modify) — add `SkillVersionModel` and `ABTestResultModel` to async PG models
- `src/agentkit/evolution/pg_store.py` (new) — `PostgreSQLEvolutionStore` implementing unified Protocol with async SQLAlchemy
- `tests/unit/evolution/test_unified_store.py` (new) — tests for unified interface
**Approach:**
Architecture design before coding:
1. **Protocol design reasoning**: Current `EvolutionStore` (async PG) has `record()`, `rollback()`, `list_events()`. `PersistentEvolutionStore` (sync SQLite) adds `record_skill_version()`, `list_skill_versions()`, `record_ab_test_result()`, `get_ab_test_results()`. The unified Protocol must include ALL methods from both. Each backend implements what it can; unsupported methods raise `NotImplementedError` with clear message.
2. **PostgreSQL model migration**: Add `SkillVersionModel` and `ABTestResultModel` to `src/agentkit/evolution/models.py` using async SQLAlchemy (matching `EpisodeModel` pattern in memory/models.py). These models already exist for SQLite; the PG versions use the same schema but with async engine.
3. **PostgreSQLEvolutionStore**: New class using async SQLAlchemy session (injected via constructor, same pattern as existing `EvolutionStore`). Implements all Protocol methods. Uses `run_in_executor` for any sync ORM operations if needed.
4. **Factory update**: `create_evolution_store(backend="memory"|"sqlite"|"postgresql", ...)` returns the appropriate backend. `"postgresql"` creates `PostgreSQLEvolutionStore` with async engine.
5. **Backward compatibility**: Existing `EvolutionStore` class is not removed — it becomes an internal implementation detail. The Protocol is the public interface. Code using `EvolutionStore` directly continues to work.
**Patterns to follow:**
- `EpisodeModel` in `src/agentkit/memory/models.py` — async PG model pattern
- `create_evolution_store()` factory — extend with new backend
- `PersistentEvolutionStore._run_sync()` — sync/async bridge pattern
**Test scenarios:**
- Protocol compliance: all backends implement all Protocol methods
- PG store: record event → list events returns recorded event
- PG store: record skill version → list versions returns version history
- PG store: record AB test result → get results returns test data
- SQLite store: existing functionality preserved after refactor
- Memory store: existing functionality preserved after refactor
- Factory: `create_evolution_store(backend="postgresql")` returns correct type
- PG unavailable: falls back to SQLite with warning
**Verification:** All backends pass unified Protocol compliance test; existing evolution tests pass; PG store supports skill_version and ab_test operations.
---
### U7. Configuration Integration and End-to-End Verification
**Goal:** Wire all three features into the application configuration, add `agentkit.yaml` schema support, and verify end-to-end behavior.
**Dependencies:** U1, U2, U3, U4, U5, U6
**Files:**
- `src/agentkit/server/app.py` (modify) — initialize cache, usage store, cascade store with config
- `src/agentkit/cli/main.py` (modify) — pass config to gateway and router
- `agentkit.yaml` (modify) — add cache, semantic_routing, usage_store, cascade_store config sections
- `tests/integration/test_p0_hardening.py` (new) — end-to-end integration tests
**Approach:**
1. **Configuration schema**: Add to `agentkit.yaml`:
```yaml
llm:
cache:
enabled: true
backend: "auto" # auto | redis | memory
exact_ttl: 3600
semantic_ttl: 86400
similarity_threshold: 0.92
max_entries: 10000
routing:
semantic:
enabled: true
similarity_high: 0.85 # direct match threshold
similarity_low: 0.6 # hint threshold
usage_store:
backend: "auto" # auto | redis | memory
ttl_days: 90
cascade_store:
backend: "auto" # auto | redis | memory
session_ttl: 86400
evolution_store:
backend: "auto" # auto | postgresql | sqlite | memory
```
2. **Application wiring**: In `app.py` lifespan, initialize all stores and inject into gateway/router. Follow existing pattern of creating components from config.
3. **End-to-end verification**: Integration test that exercises the full flow: user query → semantic routing → LLM cache → usage tracking → cascade detection → evolution logging.
**Test scenarios:**
- Full flow with Redis: all features use Redis backend, data persists across simulated restart
- Full flow without Redis: all features fall back to in-memory, no errors
- Config from YAML: `agentkit.yaml` parsed correctly, all features configured
- Cache + routing interaction: cached response for semantically routed query works correctly
- Usage tracking with cache: cached requests show 0 cost in usage summary
- Cascade detection across instances: simulated multi-instance scenario detects cascade correctly
**Verification:** All integration tests pass; application starts with new config; features degrade gracefully when backends unavailable.
---
## Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|------|--------|-----------|------------|
| Semantic cache returns stale/wrong response | High — user gets incorrect answer | Medium — embedding similarity doesn't guarantee semantic equivalence | Default to temperature=0 only for semantic cache; configurable threshold; TTL expiry; admin invalidation API |
| Redis single point of failure | High — all persistence lost | Low — Redis is typically HA | Auto-fallback to in-memory; health check in doctor command; alert on fallback activation |
| Embedding API latency adds to routing time | Medium — slower routing for first query | Medium — embedding API ~100ms | Pre-compute skill embeddings; cache query embeddings; async embedding with timeout |
| UsageStore Redis memory growth | Medium — Redis OOM | Low — TTL + date partitioning bounds growth | 90-day TTL default; monitoring on Redis memory; configurable TTL |
| EvolutionStore interface unification breaks existing code | High — evolution system stops working | Low — Protocol is backward compatible | Keep existing classes as internal implementations; comprehensive test coverage before refactor |
---
## Open Questions
- Should semantic cache also cache streaming responses (requires chunk collection)? Deferred — current plan only caches non-streaming `chat()`.
- Should UsageStore support real-time streaming of usage data (e.g., via Redis Pub/Sub)? Deferred — current plan only supports query-based access.
- What is the optimal embedding model for Chinese+English mixed text? `text-embedding-3-small` is adequate but not optimal. Consider `bge-m3` or `multilingual-e5` as alternatives. Deferred to implementation-time benchmarking.
---
## Sources & Research
- Industry benchmarking: LangChain, Dify, CrewAI, Letta, AutoGen feature comparison (2025-2026)
- Project audit: 12 core files analyzed across memory, evolution, routing, quality, and LLM subsystems
- Existing patterns: `EmbeddingCache`, `RedisSessionStore`, `create_evolution_store()`, `SessionStore` Protocol

View File

@ -0,0 +1,616 @@
# U1 Architecture Design: LLM Cache Core
> Status: APPROVED — Design reviewed, embedding model set to bge-m3 for Chinese-first
> Date: 2026-06-14
> Unit: U1 of P0 Production Hardening Plan
---
## 1. Design Goals
1. **Transparent caching**: `LLMGateway.chat()` callers cannot distinguish cached vs. uncached responses
2. **Dual-match strategy**: Exact-match (hash) for deterministic hits + Semantic-match (embedding) for paraphrased hits
3. **Backend pluggability**: `InMemoryLLMCache` for dev, `RedisLLMCache` for production, via factory
4. **Chinese-first embedding**: Default embedding model optimized for Chinese+English mixed text, with configurable fallback
---
## 2. Component Architecture
```
┌──────────────────────────────────────────────────────┐
│ LLMGateway.chat() │
│ │
│ 1. Build LLMRequest │
│ 2. ┌─ Cache Check ─────────────────────────────┐ │
│ │ generate_cache_key(req) │ │
│ │ cache.get(key) ──→ CacheResult │ │
│ │ ├─ HIT (exact) → return cached response │ │
│ │ └─ MISS → semantic_search(query_emb) │ │
│ │ ├─ HIT (semantic) → return response │ │
│ │ └─ MISS → call provider │ │
│ └─────────────────────────────────────────────┘ │
│ 3. Call provider → LLMResponse │
│ 4. cache.put(key, response, query_embedding) │
│ 5. Record usage │
└──────────────────────────────────────────────────────┘
```
### File Structure
```
src/agentkit/llm/
├── cache.py # NEW: LLMCache Protocol + InMemoryLLMCache + RedisLLMCache + CacheResult
├── cache_key.py # NEW: generate_cache_key(), hash helpers
├── gateway.py # MODIFIED in U2: inject cache check
├── config.py # MODIFIED in U2: add CacheConfig
└── ...
```
---
## 3. Data Model Design
### 3.1 CacheKey
**Reasoning**: The cache key must capture ALL inputs that deterministically affect LLM output. Missing any component leads to false cache hits (wrong response returned).
| Component | Why Included | Hash Method |
|-----------|-------------|-------------|
| `model` | Different models produce different outputs | UTF-8 encode → SHA-256 |
| `system_prompt` | Changes behavior fundamentally | SHA-256 of full text |
| `messages` | Core conversation context | SHA-256 of JSON-serialized messages |
| `temperature` | Affects randomness; only 0.0 is deterministic | Float string representation |
| `tools` | Available tools affect tool_call generation | SHA-256 of JSON-serialized tools list |
| `tool_choice` | "auto" vs "none" changes behavior | UTF-8 encode → SHA-256 |
**Key formula**:
```python
key = SHA256(
SHA256(model) +
SHA256(system_prompt) +
SHA256(json(messages, sort_keys=True)) +
SHA256(str(temperature)) +
SHA256(json(tools, sort_keys=True)) +
SHA256(tool_choice)
)
```
**Design Decision — Why not include `max_tokens`?**
`max_tokens` is a truncation limit, not a semantic input. A response cached with `max_tokens=2000` is still valid when requested with `max_tokens=4000` (the response was simply shorter). However, the reverse is unsafe — a response generated with `max_tokens=4000` might be longer than a `max_tokens=2000` request expects. **Decision**: Include `max_tokens` in the key to be safe. The cost of a few extra cache misses is negligible compared to returning a response that violates the caller's token limit.
**Revised key formula**:
```python
key = SHA256(
SHA256(model) +
SHA256(system_prompt) +
SHA256(json(messages, sort_keys=True)) +
SHA256(f"{temperature:.2f}") +
SHA256(json(tools, sort_keys=True)) +
SHA256(tool_choice) +
SHA256(str(max_tokens))
)
```
### 3.2 CacheEntry
```python
@dataclass
class CacheEntry:
"""A cached LLM response with metadata."""
response: LLMResponse # The cached response
query_embedding: list[float] # Embedding of last user message (for semantic match)
created_at: float # time.monotonic() when cached
hit_count: int # Number of cache hits
```
### 3.3 CacheResult
```python
@dataclass
class CacheResult:
"""Result of a cache lookup."""
hit: bool # Whether a cache hit occurred
response: LLMResponse | None # The cached response (None on miss)
match_type: str # "exact" | "semantic" | "" (miss)
```
---
## 4. Protocol Design
### 4.1 LLMCache Protocol
```python
class LLMCache(Protocol):
"""LLM response cache interface."""
async def get(self, key: str) -> CacheResult:
"""Look up a cached response by exact key, then semantic search."""
...
async def put(self, key: str, response: LLMResponse, query_embedding: list[float] | None = None) -> None:
"""Store a response in the cache."""
...
async def invalidate(self, pattern: str | None = None) -> int:
"""Invalidate cache entries. If pattern is None, invalidate all. Returns count of invalidated entries."""
...
async def stats(self) -> dict[str, int]:
"""Return cache statistics: {total_entries, total_hits, total_misses}."""
...
```
**Reasoning for async Protocol**: All methods are async because `RedisLLMCache` uses `redis.asyncio`. Making the Protocol async ensures both backends share the same interface without sync/async bridging.
**Why `get()` does both exact + semantic?** The caller (LLMGateway) shouldn't need to know about the two-tier lookup. It calls `cache.get(key)` and gets a `CacheResult` with `match_type` indicating how the hit occurred. This encapsulation keeps the integration point simple.
### 4.2 Semantic Search Design
**Critical Question**: Should semantic search be inside `get()` or a separate method?
**Analysis**:
- **Option A**: `get(key)` does exact match first, then semantic search on miss. Single call, simple integration.
- **Option B**: Separate `semantic_search(embedding)` method. More flexible, but requires caller to manage two calls.
**Decision**: Option A. The semantic search needs the `query_embedding`, which must be computed before calling `get()`. But embedding computation is expensive (~100ms). We don't want to compute embeddings on every cache miss — only when semantic caching is enabled and temperature == 0.
**Revised design**:
```python
class LLMCache(Protocol):
async def get(self, key: str) -> CacheResult:
"""Exact-match lookup only."""
...
async def semantic_search(self, query_embedding: list[float], threshold: float = 0.92) -> CacheResult:
"""Semantic similarity search across all cached entries."""
...
async def put(self, key: str, response: LLMResponse, query_embedding: list[float] | None = None) -> None:
"""Store response with optional embedding for semantic matching."""
...
```
**Integration flow in LLMGateway (U2)**:
```python
# 1. Exact match
result = await cache.get(key)
if result.hit:
return result.response
# 2. Semantic match (only for temperature == 0)
if request.temperature == 0 and query_embedding is not None:
result = await cache.semantic_search(query_embedding)
if result.hit:
return result.response
# 3. Call provider
response = await provider.chat(request)
await cache.put(key, response, query_embedding)
```
This gives the gateway explicit control over when to attempt semantic search, avoiding unnecessary embedding computation.
---
## 5. InMemoryLLMCache Implementation Design
### 5.1 Data Structure
```python
class InMemoryLLMCache:
def __init__(self, max_entries: int = 10000, exact_ttl: int = 3600, semantic_ttl: int = 86400, similarity_threshold: float = 0.92):
self._max_entries = max_entries
self._exact_ttl = exact_ttl
self._semantic_ttl = semantic_ttl
self._similarity_threshold = similarity_threshold
# Exact cache: key → CacheEntry
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
# Semantic index: key → query_embedding (parallel to _cache)
self._embeddings: dict[str, list[float]] = {}
# Stats
self._hits = 0
self._misses = 0
```
### 5.2 Key Operations
**`get(key)`**:
1. Look up `key` in `_cache`
2. If found and not expired (check `created_at + exact_ttl > now`): increment `hit_count`, move to end (LRU), return `CacheResult(hit=True, match_type="exact")`
3. If expired: delete from `_cache` and `_embeddings`
4. Return `CacheResult(hit=False)`
**`semantic_search(query_embedding, threshold)`**:
1. If `_embeddings` is empty: return miss
2. For each `(key, emb)` in `_embeddings`:
a. Check if entry is still valid (`created_at + semantic_ttl > now`)
b. If expired: skip (lazy cleanup)
c. Compute `cosine_similarity(query_embedding, emb)`
d. Track best match
3. If best similarity >= threshold: return `CacheResult(hit=True, match_type="semantic")`
4. Return miss
**Performance**: O(n) scan over all embeddings. With <10000 entries and 1536-dim vectors, this takes <10ms using numpy. Acceptable for now. If scale becomes an issue, switch to FAISS or pgvector (deferred).
**`put(key, response, query_embedding)`**:
1. Create `CacheEntry(response, query_embedding or [], now, 0)`
2. If key exists: update, move to end
3. If new and at capacity: evict LRU (popitem(last=False))
4. Store embedding in `_embeddings[key]` if provided
**`invalidate(pattern)`**:
1. If pattern is None: clear all
2. If pattern: iterate keys, match against pattern, delete matching entries
### 5.3 LRU Eviction Strategy
Follow `EmbeddingCache` pattern: `OrderedDict` with `move_to_end()` on access, `popitem(last=False)` on eviction. This is O(1) for both access and eviction.
**Why not size-based eviction?** LLM responses vary widely in size (100 bytes to 10KB). Entry-count-based eviction is simpler and more predictable. With `max_entries=10000` and average response ~1KB, memory usage is ~10MB — acceptable.
---
## 6. RedisLLMCache Implementation Design
### 6.1 Key Schema
```
agentkit:llm_cache:{sha256_hex} → JSON(CacheEntry) with TTL
agentkit:llm_cache_emb:{sha256_hex} → JSON(list[float]) with TTL
```
**Why two keys instead of one?**
- Semantic search needs to iterate all embeddings without downloading full response bodies
- Embedding keys are small (~12KB for 1536-dim float list) vs. response keys (variable, potentially large with tool_calls)
- Different TTLs: exact cache may have shorter TTL than semantic cache
**Alternative considered**: Single key with embedded embedding. Rejected because `KEYS agentkit:llm_cache:*` + `GET` for each key to extract embedding would download all response bodies for semantic search, which is wasteful.
### 6.2 Key Operations
**`get(key)`**:
1. `GET agentkit:llm_cache:{key}` → deserialize CacheEntry
2. If found: `INCR agentkit:llm_cache_hits:{key}` (optional, for stats), return hit
3. Return miss
**`semantic_search(query_embedding, threshold)`**:
1. `KEYS agentkit:llm_cache_emb:*` → get all embedding keys
2. `MGET` all embedding keys → deserialize embeddings
3. Compute cosine similarity for each
4. If best >= threshold: `GET agentkit:llm_cache:{best_key}` → return hit
5. Return miss
**Performance concern**: `KEYS` is O(N) and blocks Redis. For production with >1000 cached entries, this is unacceptable.
**Mitigation**: Use `SCAN` instead of `KEYS` for iteration. Store a Redis Set `agentkit:llm_cache_index` containing all active cache keys. On `put()`, `SADD agentkit:llm_cache_index {key}`. On `invalidate()`, `SREM`. For semantic search, `SMEMBERS agentkit:llm_cache_index``MGET` embeddings.
**Revised key schema**:
```
agentkit:llm_cache:{sha256_hex} → JSON(CacheEntry) with TTL
agentkit:llm_cache_emb:{sha256_hex} → JSON(list[float]) with TTL
agentkit:llm_cache_index → SET of active cache keys (no TTL, managed manually)
```
**`put(key, response, query_embedding)`**:
1. Pipeline: `SET agentkit:llm_cache:{key} → JSON(CacheEntry) EX exact_ttl`
2. If embedding provided: `SET agentkit:llm_cache_emb:{key} → JSON(embedding) EX semantic_ttl`
3. `SADD agentkit:llm_cache_index {key}`
**`invalidate(pattern)`**:
1. If pattern is None: `SMEMBERS agentkit:llm_cache_index` → pipeline DEL all keys → DEL index
2. If pattern: `SMEMBERS` → filter by pattern → pipeline DEL matching keys → SREM from index
### 6.3 Lazy Redis Initialization
Follow `RedisSessionStore._get_redis()` pattern:
```python
class RedisLLMCache:
def __init__(self, redis_url: str = "redis://localhost:6379", ...):
self._redis_url = redis_url
self._redis: aioredis.Redis | None = None
async def _get_redis(self) -> aioredis.Redis:
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
return self._redis
```
### 6.4 Connection Error Handling
```python
async def get(self, key: str) -> CacheResult:
try:
redis = await self._get_redis()
data = await redis.get(f"agentkit:llm_cache:{key}")
...
except (redis.ConnectionError, redis.TimeoutError) as e:
logger.warning(f"Redis cache unavailable, returning miss: {e}")
return CacheResult(hit=False)
```
**Design Decision**: On Redis failure, return cache miss (not error). The cache is a performance optimization, not a correctness requirement. Failing open is the correct behavior.
---
## 7. Factory Function
```python
def create_llm_cache(
backend: str = "auto",
redis_url: str = "redis://localhost:6379",
max_entries: int = 10000,
exact_ttl: int = 3600,
semantic_ttl: int = 86400,
similarity_threshold: float = 0.92,
) -> LLMCache:
"""Create an LLM cache backend.
Args:
backend: "auto" (try Redis, fallback to memory), "redis", "memory"
...
"""
if backend in ("auto", "redis"):
try:
import redis.asyncio as aioredis
return RedisLLMCache(redis_url=redis_url, ...)
except ImportError:
logger.warning("redis package not available, falling back to in-memory cache")
return InMemoryLLMCache(...)
return InMemoryLLMCache(...)
```
**Follows existing pattern**: `create_session_store()`, `create_evolution_store()`.
---
## 8. CacheKey Generation Design
### 8.1 Module: `cache_key.py`
```python
import hashlib
import json
def generate_cache_key(
model: str,
messages: list[dict[str, str]],
temperature: float,
tools: list[dict] | None = None,
tool_choice: str = "auto",
max_tokens: int = 2000,
system_prompt: str | None = None,
) -> str:
"""Generate a deterministic SHA-256 cache key from LLM request parameters."""
components = [
_hash_str(model),
_hash_str(system_prompt or _extract_system_prompt(messages)),
_hash_json(messages),
_hash_str(f"{temperature:.2f}"),
_hash_json(tools),
_hash_str(tool_choice),
_hash_str(str(max_tokens)),
]
combined = "".join(components)
return hashlib.sha256(combined.encode()).hexdigest()
def _extract_system_prompt(messages: list[dict]) -> str:
"""Extract system prompt from messages list."""
for msg in messages:
if msg.get("role") == "system":
return msg.get("content", "")
return ""
def _hash_str(s: str) -> str:
return hashlib.sha256(s.encode()).hexdigest()
def _hash_json(obj) -> str:
if obj is None:
return hashlib.sha256(b"null").hexdigest()
return hashlib.sha256(json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
```
### 8.2 Why Separate `system_prompt` Parameter?
The `messages` list already contains the system prompt. But in AgentKit, the system prompt is injected separately from the user's messages (via `MemoryStore.build_system_prompt()`). The gateway receives `messages` that already include the system prompt. So `system_prompt` is extracted from `messages[0]` when `role == "system"`.
**No separate parameter needed** — `_extract_system_prompt()` handles extraction. This avoids requiring callers to pass system_prompt separately.
---
## 9. Semantic Match: Temperature Gate
**Rule**: Semantic matching is ONLY attempted when `temperature == 0.0`.
**Reasoning**:
- At `temperature > 0`, LLM outputs are non-deterministic. Two semantically similar requests may produce different outputs.
- Caching a `temperature=0.7` response and returning it for a semantically similar query is misleading — the user expects randomness.
- At `temperature=0.0`, outputs are deterministic (within provider guarantees), so semantic matching is safe.
**Implementation**: The gateway checks `temperature` before calling `semantic_search()`. The cache itself does not enforce this — it's a policy decision made by the caller.
---
## 10. Serialization Design
### 10.1 LLMResponse Serialization
`LLMResponse` contains `content: str`, `model: str`, `usage: TokenUsage`, `tool_calls: list[ToolCall]`.
**For InMemoryLLMCache**: No serialization needed — store Python objects directly.
**For RedisLLMCache**: Serialize to JSON.
```python
def _serialize_response(response: LLMResponse) -> dict:
return {
"content": response.content,
"model": response.model,
"usage": {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
},
"tool_calls": [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
for tc in response.tool_calls
],
"latency_ms": response.latency_ms,
}
def _deserialize_response(data: dict) -> LLMResponse:
return LLMResponse(
content=data["content"],
model=data["model"],
usage=TokenUsage(**data["usage"]),
tool_calls=[ToolCall(**tc) for tc in data.get("tool_calls", [])],
latency_ms=data.get("latency_ms", 0.0),
)
```
### 10.2 Embedding Serialization
Embeddings are `list[float]` with 1536 dimensions. JSON serialization produces ~12KB per embedding.
**Alternative**: Binary serialization (struct.pack) would reduce to ~6KB but adds complexity. JSON is sufficient for now.
---
## 11. Edge Cases & Failure Modes
| Edge Case | Behavior | Rationale |
|-----------|----------|-----------|
| Response with `tool_calls` | Cached normally | Tool call responses are deterministic at temperature=0 |
| Empty response (`content=""`) | Cached normally | Empty responses are valid (e.g., tool-only responses) |
| Very large response (>100KB) | Cached, but counted as single entry | Size-based eviction deferred; entry-count is sufficient |
| Concurrent `put()` for same key | Last write wins | No data corruption risk; both writes are valid responses |
| Redis `SET` fails | Log warning, cache miss on next read | Fail open, never block LLM calls |
| Embedding API fails during `put()` | Store response without embedding | Exact-match still works; semantic match degraded |
| Embedding API fails during `semantic_search()` | Return cache miss | Don't block on embedding failures |
| `invalidate()` while `get()` in progress | Possible stale read | Acceptable for cache; eventual consistency |
---
## 12. Test Strategy
### 12.1 Unit Tests (`tests/unit/llm/test_cache.py`)
Using `pytest` + `pytest-asyncio`:
1. **test_exact_match_hit**: Same key → cache hit, `match_type="exact"`
2. **test_exact_match_miss**: Different key → cache miss
3. **test_semantic_match_hit**: Paraphrased query with similarity > 0.92 → hit, `match_type="semantic"`
4. **test_semantic_match_miss**: Unrelated query with similarity < 0.6 miss
5. **test_semantic_match_boundary**: Similarity exactly at threshold → hit
6. **test_ttl_expiry_exact**: Entry expires after exact_ttl → miss
7. **test_ttl_expiry_semantic**: Entry expires after semantic_ttl → miss
8. **test_lru_eviction**: Add max_entries + 1 → oldest evicted
9. **test_invalidate_all**: `invalidate()` clears all entries
10. **test_invalidate_pattern**: `invalidate("prefix:*")` clears matching entries
11. **test_cache_stats**: `stats()` returns correct counts
12. **test_tool_calls_cached**: Response with tool_calls cached and restored correctly
13. **test_concurrent_puts**: Two concurrent puts for same key → no error
14. **test_redis_fallback**: Redis import fails → InMemoryLLMCache returned
15. **test_cache_key_deterministic**: Same inputs → same key
16. **test_cache_key_different_model**: Different model → different key
17. **test_cache_key_different_temperature**: Different temperature → different key
### 12.2 Mock Embedder for Testing
Use `MockEmbedder` from `src/agentkit/memory/embedder.py`. Since `MockEmbedder` generates deterministic embeddings based on text hash, semantically similar text will produce similar embeddings (same hash prefix → similar vector). This is sufficient for testing the similarity threshold logic.
**Limitation**: `MockEmbedder` doesn't produce truly semantically meaningful embeddings. For testing semantic matching behavior, we'll manually construct embeddings with known cosine similarities.
```python
def _make_embedding(base: list[float], noise: float = 0.0) -> list[float]:
"""Create a unit vector with optional noise for similarity testing."""
vec = [x + noise for x in base]
magnitude = sum(x**2 for x in vec) ** 0.5
return [x / magnitude for x in vec] if magnitude > 0 else vec
```
---
## 13. Dependency Analysis
### 13.1 Internal Dependencies
| Dependency | Usage | Risk |
|-----------|-------|------|
| `agentkit.llm.protocol.LLMResponse` | Cache entry data type | Stable, no change needed |
| `agentkit.llm.protocol.TokenUsage` | Part of LLMResponse | Stable |
| `agentkit.llm.protocol.ToolCall` | Part of LLMResponse | Stable |
| `agentkit.memory.embedder.Embedder` | Embedding computation for semantic match | Injected, not imported directly |
| `agentkit.utils.vector_math.compute_cosine_similarity` | Similarity computation | Stable utility |
### 13.2 External Dependencies
| Dependency | Usage | Required? |
|-----------|-------|-----------|
| `redis.asyncio` | RedisLLMCache backend | Optional (only for "redis" backend) |
| `numpy` | Fast cosine similarity | Optional (pure-python fallback exists) |
---
## 14. Implementation Sequence
Within U1, the implementation order is:
1. **`cache_key.py`** — No dependencies, pure functions, easy to test
2. **`cache.py`** — `CacheResult`, `CacheEntry`, `LLMCache` Protocol, `InMemoryLLMCache`
3. **`cache.py`** — `RedisLLMCache`, `create_llm_cache()` factory
4. **`test_cache.py`** — All unit tests
This order allows incremental testing: cache_key tests first, then InMemoryLLMCache tests, then RedisLLMCache tests.
---
## 15. Open Design Questions
1. **Should `semantic_search()` return the best match or all matches above threshold?**
- **Current decision**: Best match only. The gateway needs one response, not a ranked list. If we need ranked results later, we can add a `search()` method.
2. **Should the cache store the original `messages` alongside the response?**
- **Current decision**: No. The key already deterministically represents the messages. Storing them again wastes memory. If we need message-level debugging, we can add it later.
3. **Should `RedisLLMCache` use Redis Hash instead of individual keys?**
- **Current decision**: Individual keys with SET index. Hash would allow `HGETALL` for all entries, but makes TTL per-entry impossible (Redis Hash fields don't support individual TTLs). Individual keys with a SET index is the standard pattern.
4. **What embedding model to use for semantic cache?**
- **Decision**: Default to `bge-m3` (BAAI/bge-m3 via Xinference or TEI endpoint) for Chinese+English mixed text. `bge-m3` supports:
- Multi-lingual (102 languages, strong Chinese)
- Multi-granularity (dense + sparse + ColBERT)
- Multi-function (retrieval + classification + similarity)
- 1024-dim dense vectors (vs. 1536 for OpenAI)
- Fallback to `text-embedding-3-small` when only OpenAI API is available.
- The embedder is injected via constructor, so the model choice is a configuration concern, not a code concern.
- **Config example**:
```yaml
llm:
cache:
embedding:
provider: "xinference" # "xinference" | "openai" | "local"
model: "bge-m3" # model name at provider
base_url: "http://localhost:9997/v1"
```
---
## 16. Argumentation Summary
| Design Choice | Alternatives Considered | Why This Choice |
|--------------|------------------------|----------------|
| SHA-256 hash key | UUID, MD5, composite string key | SHA-256 is collision-resistant, deterministic, fixed-length; MD5 has known collisions; UUID is non-deterministic |
| OrderedDict LRU | heapq, custom doubly-linked-list | OrderedDict is Python-idiomatic, O(1) access+eviction, matches EmbeddingCache pattern |
| Separate `get()` + `semantic_search()` | Combined `get()` with auto-semantic | Explicit control avoids unnecessary embedding computation; caller decides when to attempt semantic match |
| Redis SET index for semantic search | KEYS pattern scan, Redis Hash | KEYS blocks Redis; Hash doesn't support per-field TTL; SET index is standard pattern |
| Fail-open on Redis error | Raise exception, return None | Cache is optimization, not correctness; failing open ensures LLM calls always work |
| Temperature gate for semantic match | Always attempt semantic match | temperature>0 outputs are non-deterministic; semantic match would return misleading cached responses |
| JSON serialization for Redis | MessagePack, Pickle, Protobuf | JSON is human-readable, debuggable, no extra dependencies; sufficient for <10KB entries |
| bge-m3 default embedding | text-embedding-3-small, multilingual-e5 | bge-m3 is SOTA for Chinese+English mixed text; 1024-dim saves 33% memory vs OpenAI 1536-dim; OpenAI-compatible API via Xinference/TEI |

View File

@ -0,0 +1,271 @@
# U2 Architecture Design: LLM Cache Integration
> Status: APPROVED — Design follows U1 architecture, minimal integration surface
> Date: 2026-06-14
> Unit: U2 of P0 Production Hardening Plan
---
## 1. Design Goals
1. **Transparent injection**: Cache check happens inside `LLMGateway.chat()` without changing the public API
2. **Usage tracking on cache hits**: Cached requests record 0 cost to maintain usage query integrity
3. **Opt-in by default**: Cache disabled unless explicitly configured
4. **Stream exclusion**: `chat_stream()` is NOT cached in this iteration
---
## 2. Integration Point Analysis
### Current `LLMGateway.chat()` flow (gateway.py:34-121):
```
1. _resolve_model_alias(model) → resolved_model
2. Check providers exist
3. Start telemetry span
4. _get_models_to_try(resolved_model) → models_to_try
5. For each model:
a. _resolve_model(model_name) → (provider, actual_model)
b. Build LLMRequest
c. provider.chat(req) → response
d. Break on success
6. Calculate cost
7. Record usage
8. Record telemetry
9. Return response
```
### Cache insertion points:
**Cache CHECK** (before step 5): After `LLMRequest` is constructed, before provider call.
- Reason: All request normalization (alias resolution, model fallback list) has completed.
- The `resolved_model` and `actual_model` are known, so the cache key is deterministic.
**Cache WRITE** (after step 5d): After successful response, before usage tracking.
- Reason: Response is validated (no exception thrown). Usage tracking needs to happen regardless of cache hit/miss.
**Cache HIT usage tracking** (step 6-7): On cache hit, record usage with cost=0.
---
## 3. Modified Flow
```python
async def chat(self, messages, model, agent_name="", task_type="", tools=None, tool_choice="auto", **kwargs):
resolved_model = self._resolve_model_alias(model)
# ... provider check, telemetry span setup ...
# ── Cache check (NEW) ──
cache_key = None
query_embedding = None
if self._cache is not None:
from agentkit.llm.cache_key import generate_cache_key
cache_key = generate_cache_key(
model=resolved_model,
messages=messages,
temperature=kwargs.get("temperature", 0.7),
tools=tools,
tool_choice=tool_choice,
max_tokens=kwargs.get("max_tokens", 2000),
)
result = await self._cache.get(cache_key)
if result.hit:
# Record usage with 0 cost
latency_ms = (time.monotonic() - start) * 1000
self._usage_tracker.record(
agent_name=agent_name,
model=result.response.model,
usage=result.response.usage,
cost=0.0,
latency_ms=latency_ms,
)
return result.response
# Semantic match (only for temperature == 0)
temperature = kwargs.get("temperature", 0.7)
if temperature == 0 and self._embedder is not None:
try:
last_user_msg = next(
(m["content"] for m in reversed(messages) if m.get("role") == "user"),
"",
)
if last_user_msg:
query_embedding = await self._embedder.embed(last_user_msg)
result = await self._cache.semantic_search(query_embedding)
if result.hit:
latency_ms = (time.monotonic() - start) * 1000
self._usage_tracker.record(
agent_name=agent_name,
model=result.response.model,
usage=result.response.usage,
cost=0.0,
latency_ms=latency_ms,
)
return result.response
except Exception as e:
logger.warning(f"Semantic cache search failed: {e}")
# ── Normal provider call ──
for model_name in models_to_try:
# ... existing fallback loop ...
# ── Cache write (NEW) ──
if self._cache is not None and cache_key is not None:
try:
await self._cache.put(cache_key, response, query_embedding)
except Exception as e:
logger.warning(f"Cache write failed: {e}")
# ... existing usage tracking, telemetry ...
return response
```
---
## 4. CacheConfig Design
```python
@dataclass
class CacheConfig:
"""LLM Cache configuration."""
enabled: bool = False
backend: str = "auto" # "auto" | "redis" | "memory"
redis_url: str = "redis://localhost:6379"
exact_ttl: int = 3600
semantic_ttl: int = 86400
similarity_threshold: float = 0.92
max_entries: int = 10000
# Embedding config for semantic cache
embedding_provider: str = "openai" # "openai" | "xinference" | "local"
embedding_model: str = "bge-m3" # model name at provider
embedding_base_url: str | None = None
embedding_api_key: str | None = None
```
**Nesting**: `CacheConfig` is nested under `LLMConfig.cache`.
```python
@dataclass
class LLMConfig:
providers: dict[str, ProviderConfig] = field(default_factory=dict)
model_aliases: dict[str, str] = field(default_factory=dict)
fallbacks: dict[str, list[str]] = field(default_factory=dict)
cache: CacheConfig | None = None # NEW
```
---
## 5. LLMGateway Constructor Change
```python
class LLMGateway:
def __init__(self, config: LLMConfig | None = None):
self._providers: dict[str, LLMProvider] = {}
self._usage_tracker = UsageTracker()
self._config = config or LLMConfig()
# Cache (NEW)
self._cache: LLMCache | None = None
self._embedder: Embedder | None = None
if self._config.cache and self._config.cache.enabled:
from agentkit.llm.cache import create_llm_cache
self._cache = create_llm_cache(
backend=self._config.cache.backend,
redis_url=self._config.cache.redis_url,
max_entries=self._config.cache.max_entries,
exact_ttl=self._config.cache.exact_ttl,
semantic_ttl=self._config.cache.semantic_ttl,
similarity_threshold=self._config.cache.similarity_threshold,
)
# Embedder for semantic cache
self._embedder = self._create_embedder(self._config.cache)
```
**Design Decision**: Cache and embedder are created in `__init__`, not lazily. This ensures configuration errors are caught at startup, not at first request.
---
## 6. Embedder Factory Method
```python
def _create_embedder(self, cache_config: CacheConfig) -> Embedder | None:
"""Create embedder for semantic cache based on config."""
try:
if cache_config.embedding_provider == "openai":
from agentkit.memory.embedder import OpenAIEmbedder
return OpenAIEmbedder(
api_key=cache_config.embedding_api_key,
model=cache_config.embedding_model,
base_url=cache_config.embedding_base_url,
)
elif cache_config.embedding_provider in ("xinference", "local"):
# Xinference/TEI uses OpenAI-compatible API
from agentkit.memory.embedder import OpenAIEmbedder
return OpenAIEmbedder(
api_key=cache_config.embedding_api_key or "not-needed",
model=cache_config.embedding_model,
base_url=cache_config.embedding_base_url or "http://localhost:9997/v1",
)
except Exception as e:
logger.warning(f"Failed to create embedder for semantic cache: {e}")
return None
```
**Design Decision**: Use `OpenAIEmbedder` for all providers since Xinference and TEI expose OpenAI-compatible `/embeddings` endpoints. No need for a separate XinferenceEmbedder class.
---
## 7. Stream Handling
`chat_stream()` is NOT cached in this iteration. Document as known limitation.
**Reasoning**:
- Streaming requires collecting all chunks before caching, adding latency
- Chunk collection adds complexity (error handling mid-stream, partial responses)
- Most cacheable requests (temperature=0, simple queries) don't need streaming
- Streaming is typically used for long-form generation where caching is less beneficial
---
## 8. Edge Cases
| Edge Case | Behavior |
|-----------|----------|
| Cache disabled (default) | No cache check, no performance impact |
| Cache enabled, first request | Cache miss, provider called, response cached |
| Cache hit with tool_calls | Return cached response including tool_calls |
| Embedder fails during semantic search | Log warning, return miss, proceed to provider |
| Cache write fails | Log warning, response still returned to caller |
| Fallback model used | Cache key uses `resolved_model`, not `actual_model` — same query hits cache regardless of which fallback responded |
**Fallback model cache key issue**: When model A fails and fallback model B responds, the cache key is based on `resolved_model` (the alias), not `actual_model` (B). This means a subsequent request for the same alias will get a cache hit even if model A is back online. This is **correct behavior** — the user asked for the alias, not a specific model.
However, if the user explicitly specifies model B (not an alias), the cache key will be different. This is also correct — different model = different cache entry.
---
## 9. Test Strategy
### Integration Tests (`tests/unit/test_gateway_cache.py`)
1. **test_cache_disabled**: Requests pass through to provider normally
2. **test_cache_enabled_first_request**: Cache miss, provider called, response cached
3. **test_cache_enabled_second_request**: Cache hit, provider NOT called
4. **test_cache_hit_usage_tracking**: Usage record has 0 cost, correct token counts
5. **test_cache_miss_fallback**: Primary model fails, fallback response cached
6. **test_config_from_dict**: `LLMConfig.from_dict({"cache": {"enabled": True}})` works
7. **test_semantic_cache_hit**: temperature=0, semantically similar query hits cache
8. **test_semantic_cache_skipped_for_nonzero_temp**: temperature>0 skips semantic search
---
## 10. Argumentation Summary
| Design Choice | Alternatives Considered | Why This Choice |
|--------------|------------------------|----------------|
| Cache check after LLMRequest construction | Before construction | Request normalization must complete first; key depends on resolved model |
| Cache write before usage tracking | After usage tracking | Response must be cached before tracking so cache-hit tracking uses same response |
| OpenAIEmbedder for all providers | Separate XinferenceEmbedder | Xinference/TEI use OpenAI-compatible API; no need for separate class |
| No stream caching | Collect chunks then cache | Adds latency and complexity; most cacheable requests don't need streaming |
| Cache key uses resolved_model alias | Uses actual_model | User requests alias, not specific model; cache should be model-agnostic within alias |

View File

@ -0,0 +1,235 @@
# U3 Architecture Design: Semantic Router
> Status: APPROVED — Design follows existing CostAwareRouter layer pattern
> Date: 2026-06-14
> Unit: U3 of P0 Production Hardening Plan
---
## 1. Design Goals
1. **Zero LLM cost for confident matches**: When semantic similarity > 0.85, skip Layer 2 LLM classification entirely
2. **Reduce LLM tokens for medium matches**: When similarity 0.6-0.85, pass skill hint to Layer 2, reducing classification tokens
3. **Chinese-first**: Embedding model must handle Chinese+English mixed text well
4. **Pre-computed skill embeddings**: Compute at skill registration time, not query time
5. **Graceful degradation**: If embedder fails, fall through to existing Layer 1/2 flow
---
## 2. Insertion Point Analysis
### Current `CostAwareRouter.route()` flow:
```
Layer 0: Rule-based (zero cost)
→ explicit_skill / greeting / chat_mode / identity → return
Layer 1: Complexity classification
→ low (<0.3) DIRECT_CHAT return
→ medium (0.3-0.7) → _classify_merged() or IntentRouter → return
→ high (>0.7) → Layer 2
Layer 2: Capability matching / Auction
→ return
```
### Semantic Router insertion: **Between Layer 1 complexity classification and the medium/high branching**
```
Layer 1: Complexity classification → complexity score
→ low (<0.3) DIRECT_CHAT return
→ medium (0.3-0.7):
┌─ Layer 1.5: Semantic Router (NEW) ─────────────┐
│ embed query → compare with skill embeddings │
│ sim > 0.85 → SKILL_REACT with matched skill │
│ sim 0.6-0.85 → pass skill_hint to _classify_merged │
│ sim < 0.6 proceed to _classify_merged normally
└──────────────────────────────────────────────────┘
→ high (>0.7):
┌─ Layer 1.5: Semantic Router (NEW) ─────────────┐
│ sim > 0.85 → SKILL_REACT with matched skill │
│ sim 0.6-0.85 → pass skill_hint to Layer 2 │
│ sim < 0.6 proceed to Layer 2 normally
└──────────────────────────────────────────────────┘
```
**Why both medium AND high complexity?** The plan says "when Layer 1 returns medium complexity (0.3-0.7), try semantic routing first." But semantic routing is also valuable for high complexity — if we can confidently match a skill at zero cost, we should. The cost saving is even greater for high complexity (which would use more expensive Layer 2 LLM calls).
---
## 3. Component Design
### 3.1 SkillEmbeddingIndex
```python
class SkillEmbeddingIndex:
"""Pre-computed embedding index for registered skills."""
def __init__(self, embedder: Embedder):
self._embedder = embedder
self._index: dict[str, tuple[list[float], str]] = {} # skill_name → (embedding, source_text)
async def build(self, skill_registry) -> None:
"""Build index from all registered skills."""
...
async def update_skill(self, skill_name: str, skill) -> None:
"""Re-embed a single skill (on registration/update)."""
...
def remove_skill(self, skill_name: str) -> None:
"""Remove a skill from the index."""
...
async def search(self, query: str, top_k: int = 5) -> list[tuple[str, float]]:
"""Search for skills matching the query. Returns [(skill_name, similarity)]."""
...
```
### 3.2 SemanticRouter
```python
class SemanticRouter:
"""Embedding-based semantic routing as Layer 1.5."""
def __init__(
self,
embedder: Embedder,
similarity_high: float = 0.85,
similarity_low: float = 0.6,
):
self._index = SkillEmbeddingIndex(embedder)
self._similarity_high = similarity_high
self._similarity_low = similarity_low
self._enabled = True
async def route(self, query: str) -> SemanticRouteResult:
"""Route a query using semantic similarity.
Returns:
SemanticRouteResult with:
- confidence: "high" | "medium" | "low"
- skill_name: matched skill name (None if low confidence)
- similarity: cosine similarity score
"""
...
@dataclass
class SemanticRouteResult:
confidence: str # "high" | "medium" | "low"
skill_name: str | None
similarity: float
```
---
## 4. Skill Embedding Source Text
**Design Decision**: What text to embed for each skill?
```python
source_text = f"{skill.description} | {' '.join(skill.intent.keywords)} | {' '.join(cap.tag for cap in skill.capabilities)}"
```
**Why this combination?**
- `description`: Captures the semantic meaning of what the skill does
- `intent.keywords`: Captures the trigger phrases users might use
- `capability tags`: Captures the functional categories
**Chinese consideration**: Skill descriptions and keywords are often in Chinese. The embedding model must handle this well. `bge-m3` is the default for this reason.
---
## 5. Integration into CostAwareRouter
### 5.1 Constructor Change
```python
class CostAwareRouter:
def __init__(self, ..., semantic_router: SemanticRouter | None = None):
self._semantic_router = semantic_router
...
```
### 5.2 Route Method Modification
The key change is in `route()`, after Layer 1 complexity classification:
```python
# After complexity is determined (medium or high)
if self._semantic_router is not None and complexity >= 0.3:
try:
semantic_result = await self._semantic_router.route(clean_content)
if semantic_result.confidence == "high":
# Direct skill match — skip Layer 2
result = await resolve_skill_routing(
content=content,
skill_registry=skill_registry,
intent_router=intent_router,
...,
force_skill=semantic_result.skill_name, # NEW parameter
)
result.match_method = "semantic_high"
result.match_confidence = semantic_result.similarity
result.execution_mode = ExecutionMode.SKILL_REACT
return result
elif semantic_result.confidence == "medium":
# Pass skill hint to Layer 1.5 merged classify or Layer 2
skill_hint = semantic_result.skill_name
except Exception as e:
logger.warning(f"Semantic routing failed, falling through: {e}")
```
### 5.3 Skill Hint Propagation
For medium confidence matches, the skill hint is passed to `_classify_merged()` or `_route_layer2()` via a new `skill_hint` parameter. This reduces the LLM classification prompt by providing a strong signal.
**Implementation**: Add `skill_hint: str | None = None` parameter to `_classify_merged()` and `_route_layer2()`. When provided, include it in the LLM prompt: "Based on semantic analysis, the query may relate to skill '{skill_hint}'. Please confirm or override."
---
## 6. Embedding Caching
Skill embeddings are pre-computed and cached in `SkillEmbeddingIndex`. Query embeddings are computed per-request but can be cached using the existing `EmbeddingCache` from `agentkit.memory.embedder`.
**Design**: The `SemanticRouter` uses an `OpenAIEmbedder` with `EmbeddingCache` for query embeddings. Skill embeddings are stored in `SkillEmbeddingIndex` and only re-computed on skill registration/update.
---
## 7. Edge Cases
| Edge Case | Behavior |
|-----------|----------|
| No skills registered | `SkillEmbeddingIndex` is empty, `route()` returns low confidence |
| Embedder API fails | Catch exception, return low confidence, fall through to existing flow |
| Skill has no description | Use `skill.name` as fallback source text |
| Chinese query, English skill description | `bge-m3` handles cross-lingual matching |
| Multiple skills with similar embeddings | Return top match; if top_k > 1, could return alternatives (deferred) |
| Semantic router disabled (None) | Existing flow unchanged, zero overhead |
---
## 8. Test Strategy
1. **test_semantic_high_confidence**: Query matches skill with sim > 0.85 → SKILL_REACT returned
2. **test_semantic_medium_confidence**: Query matches skill with sim 0.6-0.85 → skill_hint passed
3. **test_semantic_low_confidence**: Query has sim < 0.6 normal routing proceeds
4. **test_semantic_router_disabled**: No semantic_router → existing flow unchanged
5. **test_embedder_failure**: Embedder throws error → falls through gracefully
6. **test_skill_registration_updates_index**: New skill added → embedding computed
7. **test_chinese_query**: Chinese query matches Chinese skill description
---
## 9. Argumentation Summary
| Design Choice | Alternatives Considered | Why This Choice |
|--------------|------------------------|----------------|
| Layer 1.5 for both medium AND high | Only medium | High complexity benefits even more from zero-cost skill match |
| Pre-computed skill embeddings | Compute per query | O(n) embedding per query is ~100ms × n_skills; pre-compute is O(1) per query |
| bge-m3 default | text-embedding-3-small | Chinese+English mixed text; bge-m3 is SOTA for multilingual |
| Skill hint for medium confidence | Direct match for medium | Medium confidence isn't reliable enough for direct match; hint reduces LLM tokens without risking wrong routing |
| Separate SemanticRouter class | Inline in CostAwareRouter | Separation of concerns; testable independently; can be disabled without touching router |

BIN
ocr Normal file

Binary file not shown.

17
skills-lock.json Normal file
View File

@ -0,0 +1,17 @@
{
"version": 1,
"skills": {
"find-skills": {
"source": "vercel-labs/skills",
"sourceType": "github",
"skillPath": "skills/find-skills/SKILL.md",
"computedHash": "9e1c8b3103f92fa8092568a44fe64858de7c5c9dc65ce4bea8f168080e889cfd"
},
"open-code-review": {
"source": "alibaba/open-code-review",
"sourceType": "github",
"skillPath": "skills/open-code-review/SKILL.md",
"computedHash": "f8ba911346cfbe7ec70c98fc41bbc9163f37606b879cf7dc2468ad4541653bae"
}
}
}

View File

@ -205,7 +205,7 @@ class RedisUsageStore:
await self._redis.aclose() await self._redis.aclose()
self._redis = None self._redis = None
if self._sync_redis is not None: if self._sync_redis is not None:
self._sync_redis.aclose() self._sync_redis.close()
self._sync_redis = None self._sync_redis = None
def _degrade_to_fallback(self) -> None: def _degrade_to_fallback(self) -> None:

View File

@ -143,7 +143,7 @@ class RedisCascadeStateStore:
if not self._degraded: if not self._degraded:
self._degraded = True self._degraded = True
if self._fallback is None: if self._fallback is None:
self._fallback = InMemoryCascadeStateStore() self._fallback = InMemoryCascadeStateStore(session_ttl=self._session_ttl)
logger.warning("Redis cascade store unreachable, degraded to in-memory") logger.warning("Redis cascade store unreachable, degraded to in-memory")
def increment_interaction(self, session_id: str) -> int: def increment_interaction(self, session_id: str) -> int:
@ -223,6 +223,12 @@ class RedisCascadeStateStore:
if self._fallback is not None: if self._fallback is not None:
self._fallback.reset(session_id) self._fallback.reset(session_id)
def close(self) -> None:
"""Close the Redis connection pool."""
if self._sync_redis is not None:
self._sync_redis.close()
self._sync_redis = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Factory # Factory

View File

@ -0,0 +1,93 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AgentLayout: typeof import('./src/components/layout/AgentLayout.vue')['default']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
AModal: typeof import('ant-design-vue/es')['Modal']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AppLayout: typeof import('./src/components/layout/AppLayout.vue')['default']
ApprovalNode: typeof import('./src/components/workflow/ApprovalNode.vue')['default']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default']
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default']
MetricsPanel: typeof import('./src/components/evolution/MetricsPanel.vue')['default']
NodePalette: typeof import('./src/components/workflow/NodePalette.vue')['default']
OptimizationPanel: typeof import('./src/components/evolution/OptimizationPanel.vue')['default']
ParallelNode: typeof import('./src/components/workflow/ParallelNode.vue')['default']
PathOptimizerPanel: typeof import('./src/components/evolution/PathOptimizerPanel.vue')['default']
PitfallPanel: typeof import('./src/components/evolution/PitfallPanel.vue')['default']
PitfallRoutePanel: typeof import('./src/components/evolution/PitfallRoutePanel.vue')['default']
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
SkillNode: typeof import('./src/components/workflow/SkillNode.vue')['default']
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default']
TitleBar: typeof import('./src/components/layout/TitleBar.vue')['default']
ToolCallCard: typeof import('./src/components/chat/ToolCallCard.vue')['default']
ToolCallIndicator: typeof import('./src/components/chat/ToolCallIndicator.vue')['default']
TopNav: typeof import('./src/components/layout/TopNav.vue')['default']
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
}
}

View File

@ -1,5 +1,5 @@
<template> <template>
<a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig.value"> <a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig">
<SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" /> <SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" />
<router-view v-else /> <router-view v-else />
</a-config-provider> </a-config-provider>

View File

@ -17,6 +17,17 @@ export interface IChatResponse {
status: 'completed' | 'pending' status: 'completed' | 'pending'
} }
/** Tool call data within a message */
export interface IToolCallData {
id: string
name: string
status: 'pending' | 'running' | 'completed' | 'error'
params?: string
result?: string
error?: string
duration?: number
}
/** Single chat message */ /** Single chat message */
export interface IChatMessage { export interface IChatMessage {
id: string id: string
@ -28,6 +39,7 @@ export interface IChatMessage {
confidence?: number confidence?: number
task_id?: string task_id?: string
status?: 'completed' | 'pending' status?: 'completed' | 'pending'
tool_calls?: IToolCallData[]
} }
/** Conversation with messages */ /** Conversation with messages */

View File

@ -103,12 +103,18 @@ function handleInput(): void {
detectMention() detectMention()
} }
function getCursorPosition(): number {
// Access the native textarea element via Ant Design Vue ref
const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.input
return el?.selectionStart ?? inputText.value.length
}
function detectMention(): void { function detectMention(): void {
const text = inputText.value const text = inputText.value
const cursorPos = text.length // textarea cursor at end for v-model const cursorPos = getCursorPosition()
// Find the last @ that starts a potential mention // Find the last @ that starts a potential mention before cursor
const lastAtIndex = text.lastIndexOf('@', cursorPos) const lastAtIndex = text.lastIndexOf('@', cursorPos - 1)
if (lastAtIndex === -1) { if (lastAtIndex === -1) {
closeMention() closeMention()
return return
@ -136,6 +142,7 @@ function detectMention(): void {
} }
function insertMention(skill: SkillSuggestion): void { function insertMention(skill: SkillSuggestion): void {
if (mentionStartIndex.value === -1) return
const text = inputText.value const text = inputText.value
const before = text.slice(0, mentionStartIndex.value) const before = text.slice(0, mentionStartIndex.value)
const after = text.slice(mentionStartIndex.value + 1 + mentionQuery.value.length) const after = text.slice(mentionStartIndex.value + 1 + mentionQuery.value.length)

View File

@ -17,8 +17,16 @@
</a-avatar> </a-avatar>
</div> </div>
<div class="chat-message__body"> <div class="chat-message__body">
<!-- Tool call indicators --> <!-- Tool call cards -->
<div v-if="toolCalls.length > 0" class="chat-message__tools"> <div v-if="message.tool_calls && message.tool_calls.length > 0" class="chat-message__tool-cards">
<ToolCallCard
v-for="tc in message.tool_calls"
:key="tc.id"
:tool-call="tc"
/>
</div>
<!-- Tool call indicators (legacy) -->
<div v-else-if="toolCalls.length > 0" class="chat-message__tools">
<ToolCallIndicator <ToolCallIndicator
v-for="(tc, idx) in toolCalls" v-for="(tc, idx) in toolCalls"
:key="idx" :key="idx"
@ -28,7 +36,7 @@
</div> </div>
<!-- Message content --> <!-- Message content -->
<div class="chat-message__content" :class="[`chat-message__content--${message.role}`]"> <div class="chat-message__content" :class="[`chat-message__content--${message.role}`]">
<div v-if="message.role === 'assistant'" class="chat-message__markdown" v-html="renderedContent"></div> <div v-if="message.role === 'assistant'" ref="markdownRef" class="chat-message__markdown" v-html="renderedContent"></div>
<span v-else>{{ message.content }}</span> <span v-else>{{ message.content }}</span>
<a-spin v-if="isLoading" size="small" class="chat-message__loading" /> <a-spin v-if="isLoading" size="small" class="chat-message__loading" />
</div> </div>
@ -52,20 +60,67 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { Avatar as AAvatar, Tag as ATag, Spin as ASpin } from 'ant-design-vue' import hljs from 'highlight.js/lib/core'
import python from 'highlight.js/lib/languages/python'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import bash from 'highlight.js/lib/languages/bash'
import yaml from 'highlight.js/lib/languages/yaml'
import sql from 'highlight.js/lib/languages/sql'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import markdown from 'highlight.js/lib/languages/markdown'
hljs.registerLanguage('python', python)
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('html', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('markdown', markdown)
import { Avatar as AAvatar, Tag as ATag, Spin as ASpin, message as antMessage } from 'ant-design-vue'
import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/icons-vue' import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { IChatMessage } from '@/api/types' import type { IChatMessage } from '@/api/types'
import ToolCallIndicator from './ToolCallIndicator.vue' import ToolCallIndicator from './ToolCallIndicator.vue'
import ToolCallCard from './ToolCallCard.vue'
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
linkify: true, linkify: true,
breaks: true, breaks: true,
highlight(str: string, lang: string): string {
let highlighted: string
if (lang && hljs.getLanguage(lang)) {
highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
} else {
// Skip highlightAuto for unmarked code blocks too expensive for large blocks
highlighted = MarkdownIt.prototype.utils.escapeHtml(str)
}
return `<pre class="hljs" data-language="${lang || ''}"><code>${highlighted}</code></pre>`
},
}) })
// Custom image renderer: inline thumbnail with click-to-zoom
md.renderer.rules.image = (tokens, idx, _options, _env, _self) => {
const token = tokens[idx]
const rawSrc = token.attrGet('src') || ''
const rawAlt = token.content || ''
// Only allow http/https URLs to prevent javascript:/data: URI injection
if (!/^https?:\/\//i.test(rawSrc)) return ''
// Escape HTML attribute values to prevent attribute injection
const src = rawSrc.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
const alt = rawAlt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `<img src="${src}" alt="${alt}" class="chat-message__inline-image" loading="lazy" />`
}
// Sanitize markdown output to prevent XSS (javascript: links, data: URIs, etc.) // Sanitize markdown output to prevent XSS (javascript: links, data: URIs, etc.)
function sanitize(html: string): string { function sanitize(html: string): string {
return DOMPurify.sanitize(html, { return DOMPurify.sanitize(html, {
@ -73,9 +128,12 @@ function sanitize(html: string): string {
'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'a', 'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'a',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span',
'div', 'img', 'sup', 'sub',
], ],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-language', 'src', 'alt', 'loading'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
// Restrict URI schemes to http/https only (blocks javascript:, data:, vbscript:)
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/[^\s]*)$/i,
}) })
} }
@ -89,6 +147,7 @@ interface IProps {
} }
const props = defineProps<IProps>() const props = defineProps<IProps>()
const markdownRef = ref<HTMLElement | null>(null)
const isLoading = computed(() => { const isLoading = computed(() => {
return props.message.role === 'assistant' && props.message.status === 'pending' && !props.message.content return props.message.role === 'assistant' && props.message.status === 'pending' && !props.message.content
@ -108,19 +167,51 @@ const renderedContent = computed(() => {
return sanitize(md.render(props.message.content)) return sanitize(md.render(props.message.content))
}) })
// Add copy buttons to code blocks after rendering
watch(renderedContent, () => {
nextTick(() => {
if (!markdownRef.value) return
const pres = markdownRef.value.querySelectorAll('pre.hljs')
pres.forEach((pre) => {
if (pre.querySelector('.chat-message__code-header')) return // already enhanced
const lang = pre.getAttribute('data-language') || ''
const code = pre.querySelector('code')
if (!code) return
const header = document.createElement('div')
header.className = 'chat-message__code-header'
if (lang) {
const langLabel = document.createElement('span')
langLabel.className = 'chat-message__code-lang'
langLabel.textContent = lang
header.appendChild(langLabel)
}
const copyBtn = document.createElement('button')
copyBtn.className = 'chat-message__copy-btn'
copyBtn.innerHTML = '<span class="anticon"><svg viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor"><path d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v608c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V112c0-26.5-21.5-48-48-48zM704 256H192c-26.5 0-48 21.5-48 48v608c0 26.5 21.5 48 48 48h512c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-8 608H200V312h496v552z"/></svg></span>'
copyBtn.title = '复制代码'
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(code.textContent || '').then(() => {
antMessage.success('已复制到剪贴板')
}).catch(() => {
antMessage.error('复制失败')
})
})
header.appendChild(copyBtn)
pre.insertBefore(header, pre.firstChild)
})
})
})
const toolCalls = computed<ToolCall[]>(() => { const toolCalls = computed<ToolCall[]>(() => {
// Extract tool calls from message metadata or content patterns
const calls: ToolCall[] = [] const calls: ToolCall[] = []
const content = props.message.content || '' const content = props.message.content || ''
// Detect tool use patterns like [Read], [Edit], [Bash] at line start only
const toolPattern = /^\[(Read|Edit|Bash|Write|Search|Grep|Glob)\]/gm const toolPattern = /^\[(Read|Edit|Bash|Write|Search|Grep|Glob)\]/gm
let match let match
while ((match = toolPattern.exec(content)) !== null) { while ((match = toolPattern.exec(content)) !== null) {
const toolName = match[1].toLowerCase() const toolName = match[1].toLowerCase()
calls.push({ type: toolName, name: match[1] }) calls.push({ type: toolName, name: match[1] })
} }
return calls return calls
}) })
</script> </script>
@ -129,7 +220,13 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message { .chat-message {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-4); padding: var(--space-4) var(--space-5);
animation: messageSlideIn 0.3s ease-out;
}
@keyframes messageSlideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
} }
.chat-message--user { .chat-message--user {
@ -138,14 +235,17 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message__avatar { .chat-message__avatar {
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.chat-message__avatar--assistant { .chat-message__avatar--assistant {
background: var(--gradient-brand) !important; background: var(--gradient-brand) !important;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
} }
.chat-message__avatar--user { .chat-message__avatar--user {
background-color: var(--color-success) !important; background-color: var(--color-success) !important;
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
} }
.chat-message__body { .chat-message__body {
@ -159,6 +259,13 @@ const toolCalls = computed<ToolCall[]>(() => {
align-items: flex-end; align-items: flex-end;
} }
.chat-message__tool-cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 100%;
}
.chat-message__tools { .chat-message__tools {
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
@ -166,7 +273,7 @@ const toolCalls = computed<ToolCall[]>(() => {
} }
.chat-message__content { .chat-message__content {
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
line-height: var(--leading-normal); line-height: var(--leading-normal);
font-size: var(--font-base); font-size: var(--font-base);
@ -184,6 +291,7 @@ const toolCalls = computed<ToolCall[]>(() => {
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-bottom-left-radius: var(--radius-sm); border-bottom-left-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
} }
.chat-message__markdown { .chat-message__markdown {
@ -201,11 +309,95 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message__markdown :deep(pre) { .chat-message__markdown :deep(pre) {
background: var(--code-bg); background: var(--code-bg);
color: var(--code-fg); color: var(--code-fg);
padding: var(--space-3); padding: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow-x: auto; overflow: hidden;
margin: var(--space-2) 0; margin: var(--space-2) 0;
font-size: var(--font-sm); font-size: var(--font-sm);
position: relative;
}
.chat-message__markdown :deep(pre.hljs) {
background: var(--code-bg);
}
.chat-message__markdown :deep(pre code) {
display: block;
padding: var(--space-3);
overflow-x: auto;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
}
/* Code block header (language label + copy button) */
.chat-message__markdown :deep(.chat-message__code-header) {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) var(--space-3);
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-message__markdown :deep(.chat-message__code-lang) {
font-size: var(--font-xs);
color: var(--code-comment);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat-message__markdown :deep(.chat-message__copy-btn) {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--code-comment);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
padding: 0;
}
.chat-message__markdown :deep(.chat-message__copy-btn:hover) {
color: var(--code-fg);
background: rgba(255, 255, 255, 0.1);
}
/* highlight.js Catppuccin Mocha theme tokens */
.chat-message__markdown :deep(.hljs-keyword) { color: var(--code-keyword); }
.chat-message__markdown :deep(.hljs-string) { color: var(--code-string); }
.chat-message__markdown :deep(.hljs-number) { color: var(--code-number); }
.chat-message__markdown :deep(.hljs-comment) { color: var(--code-comment); font-style: italic; }
.chat-message__markdown :deep(.hljs-function) { color: var(--code-function); }
.chat-message__markdown :deep(.hljs-variable) { color: var(--code-variable); }
.chat-message__markdown :deep(.hljs-type) { color: var(--code-type); }
.chat-message__markdown :deep(.hljs-built_in) { color: var(--code-type); }
.chat-message__markdown :deep(.hljs-attr) { color: var(--code-function); }
.chat-message__markdown :deep(.hljs-selector-tag) { color: var(--code-keyword); }
.chat-message__markdown :deep(.hljs-selector-class) { color: var(--code-type); }
.chat-message__markdown :deep(.hljs-selector-id) { color: var(--code-type); }
.chat-message__markdown :deep(.hljs-literal) { color: var(--code-number); }
.chat-message__markdown :deep(.hljs-meta) { color: var(--code-comment); }
.chat-message__markdown :deep(.hljs-title) { color: var(--code-function); }
.chat-message__markdown :deep(.hljs-params) { color: var(--code-fg); }
.chat-message__markdown :deep(.hljs-section) { color: var(--code-function); }
.chat-message__markdown :deep(.hljs-addition) { background: var(--code-added-bg); }
.chat-message__markdown :deep(.hljs-deletion) { background: var(--code-removed-bg); }
.chat-message__markdown :deep(.chat-message__inline-image) {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: var(--radius-md);
cursor: pointer;
margin: var(--space-2) 0;
transition: opacity var(--transition-fast);
}
.chat-message__markdown :deep(.chat-message__inline-image:hover) {
opacity: 0.9;
} }
.chat-message__markdown :deep(code) { .chat-message__markdown :deep(code) {
@ -216,8 +408,9 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message__markdown :deep(:not(pre) > code) { .chat-message__markdown :deep(:not(pre) > code) {
background: var(--color-primary-light); background: var(--color-primary-light);
color: var(--color-primary); color: var(--color-primary);
padding: 1px var(--space-1); padding: 2px var(--space-1);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--font-xs);
} }
.chat-message__markdown :deep(ul), .chat-message__markdown :deep(ul),
@ -231,6 +424,7 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message__markdown :deep(h3) { .chat-message__markdown :deep(h3) {
margin-top: var(--space-3); margin-top: var(--space-3);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-weight: var(--font-weight-semibold);
} }
.chat-message__loading { .chat-message__loading {
@ -246,5 +440,6 @@ const toolCalls = computed<ToolCall[]>(() => {
.chat-message__time { .chat-message__time {
font-size: var(--font-xs); font-size: var(--font-xs);
color: var(--text-placeholder); color: var(--text-placeholder);
padding: 0 var(--space-1);
} }
</style> </style>

View File

@ -81,12 +81,12 @@ function formatRelativeTime(dateStr: string): string {
display: flex; display: flex;
height: 100%; height: 100%;
background: var(--bg-primary); background: var(--bg-primary);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color-split);
transition: width var(--transition-normal); transition: width var(--transition-normal);
} }
.chat-sidebar--collapsed { .chat-sidebar--collapsed {
width: 32px; width: 36px;
} }
.chat-sidebar:not(.chat-sidebar--collapsed) { .chat-sidebar:not(.chat-sidebar--collapsed) {
@ -102,7 +102,7 @@ function formatRelativeTime(dateStr: string): string {
.chat-sidebar__header { .chat-sidebar__header {
padding: var(--space-3); padding: var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color-split);
} }
.chat-sidebar__list { .chat-sidebar__list {
@ -162,7 +162,7 @@ function formatRelativeTime(dateStr: string): string {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 36px;
height: 100%; height: 100%;
border: none; border: none;
background: transparent; background: transparent;
@ -170,6 +170,7 @@ function formatRelativeTime(dateStr: string): string {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: all var(--transition-fast); transition: all var(--transition-fast);
border-radius: 0;
} }
.chat-sidebar__toggle:hover { .chat-sidebar__toggle:hover {

View File

@ -0,0 +1,155 @@
<template>
<div class="file-preview" @click="handleClick">
<div class="file-preview__icon">
<FileOutlined v-if="!isImage" style="font-size: 24px; color: var(--color-primary)" />
<img
v-else
:src="url"
:alt="name"
class="file-preview__thumbnail"
loading="lazy"
@click.stop="showPreview = true"
/>
</div>
<div class="file-preview__info">
<span class="file-preview__name">{{ name }}</span>
<span v-if="size" class="file-preview__size">{{ size }}</span>
</div>
<a-button type="link" size="small" class="file-preview__download" @click.stop="download">
<DownloadOutlined />
</a-button>
<!-- Image preview modal -->
<div v-if="showPreview && isImage" class="file-preview__overlay" @click.stop="showPreview = false">
<img :src="url" :alt="name" class="file-preview__full" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Button as AButton } from 'ant-design-vue'
import { FileOutlined, DownloadOutlined } from '@ant-design/icons-vue'
const props = defineProps<{
url: string
name: string
size?: string
}>()
const showPreview = ref(false)
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico']
const isImage = computed(() => {
const ext = props.name.split('.').pop()?.toLowerCase() || ''
return imageExtensions.includes(ext)
})
/** Validate URL protocol — only allow http/https to prevent javascript:/data: injection */
function isSafeUrl(url: string): boolean {
return /^https?:\/\//i.test(url)
}
function handleClick() {
if (isImage.value && isSafeUrl(props.url)) {
showPreview.value = true
}
}
function download() {
if (!isSafeUrl(props.url)) return
const a = document.createElement('a')
a.href = props.url
a.download = props.name
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
</script>
<style scoped>
.file-preview {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color var(--transition-fast);
max-width: 320px;
}
.file-preview:hover {
border-color: var(--color-primary-light);
}
.file-preview__icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
overflow: hidden;
}
.file-preview__thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--radius-sm);
}
.file-preview__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-preview__name {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-preview__size {
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.file-preview__download {
flex-shrink: 0;
padding: 0;
}
.file-preview__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.file-preview__full {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-md);
}
</style>

View File

@ -1,5 +1,11 @@
<template> <template>
<div v-if="visible && filteredSkills.length > 0" class="mention-dropdown" :style="positionStyle"> <div
v-if="visible && filteredSkills.length > 0"
class="mention-dropdown"
:style="positionStyle"
role="listbox"
aria-label="技能列表"
>
<div class="mention-dropdown__header"> <div class="mention-dropdown__header">
<span class="mention-dropdown__title">技能</span> <span class="mention-dropdown__title">技能</span>
<span class="mention-dropdown__hint">选择或继续输入筛选</span> <span class="mention-dropdown__hint">选择或继续输入筛选</span>
@ -7,9 +13,12 @@
<div class="mention-dropdown__list"> <div class="mention-dropdown__list">
<button <button
v-for="(skill, idx) in filteredSkills" v-for="(skill, idx) in filteredSkills"
:id="`mention-item-${idx}`"
:key="skill.name" :key="skill.name"
class="mention-dropdown__item" class="mention-dropdown__item"
:class="{ 'mention-dropdown__item--active': idx === activeIndex }" :class="{ 'mention-dropdown__item--active': idx === activeIndex }"
role="option"
:aria-selected="idx === activeIndex"
@click="selectSkill(skill)" @click="selectSkill(skill)"
@mouseenter="activeIndex = idx" @mouseenter="activeIndex = idx"
> >
@ -32,11 +41,11 @@ interface IProps {
visible: boolean visible: boolean
query: string query: string
skills: SkillSuggestion[] skills: SkillSuggestion[]
position?: { top: number; left: number } position?: { left: number }
} }
const props = withDefaults(defineProps<IProps>(), { const props = withDefaults(defineProps<IProps>(), {
position: () => ({ top: 0, left: 0 }), position: () => ({ left: 0 }),
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -44,14 +53,16 @@ const emit = defineEmits<{
close: [] close: []
}>() }>()
const MAX_VISIBLE_ITEMS = 8
const activeIndex = ref(0) const activeIndex = ref(0)
const filteredSkills = computed(() => { const filteredSkills = computed(() => {
const q = props.query.toLowerCase() const q = props.query.toLowerCase()
if (!q) return props.skills.slice(0, 8) if (!q) return props.skills.slice(0, MAX_VISIBLE_ITEMS)
return props.skills return props.skills
.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)) .filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
.slice(0, 8) .slice(0, MAX_VISIBLE_ITEMS)
}) })
const positionStyle = computed(() => ({ const positionStyle = computed(() => ({
@ -86,9 +97,14 @@ function handleKeyDown(e: KeyboardEvent) {
} }
} }
onMounted(() => { // Only attach/detach listener when visible to avoid intercepting keys globally
watch(() => props.visible, (isVisible) => {
if (isVisible) {
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
}) } else {
document.removeEventListener('keydown', handleKeyDown)
}
}, { immediate: true })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)

View File

@ -0,0 +1,206 @@
<template>
<div :class="['tool-call-card', `tool-call-card--${toolCall.status}`]">
<div class="tool-call-card__header" @click="expanded = !expanded">
<div class="tool-call-card__title">
<component :is="icon" class="tool-call-card__icon" />
<span class="tool-call-card__name">{{ toolCall.name }}</span>
<span v-if="toolCall.duration" class="tool-call-card__duration">{{ toolCall.duration }}ms</span>
</div>
<div class="tool-call-card__status">
<LoadingOutlined v-if="toolCall.status === 'running'" spin class="tool-call-card__spinner" />
<CheckCircleOutlined v-else-if="toolCall.status === 'completed'" class="tool-call-card__check" />
<CloseCircleOutlined v-else-if="toolCall.status === 'error'" class="tool-call-card__error-icon" />
<RightOutlined :class="['tool-call-card__expand', { 'tool-call-card__expand--open': expanded }]" />
</div>
</div>
<div v-if="toolCall.params && expanded" class="tool-call-card__params">
<div class="tool-call-card__label">参数</div>
<pre class="tool-call-card__pre">{{ toolCall.params }}</pre>
</div>
<div v-if="(toolCall.result || toolCall.error) && expanded" class="tool-call-card__result">
<div class="tool-call-card__label">{{ toolCall.error ? '错误' : '结果' }}</div>
<pre :class="['tool-call-card__pre', { 'tool-call-card__pre--error': !!toolCall.error }]">{{ toolCall.error || toolCall.result }}</pre>
</div>
<div v-if="!expanded && toolCall.result" class="tool-call-card__preview">
{{ previewText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, type Component } from 'vue'
import {
LoadingOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
RightOutlined,
ReadOutlined,
EditOutlined,
CodeOutlined,
FileAddOutlined,
SearchOutlined,
FolderOpenOutlined,
ThunderboltOutlined,
ApiOutlined,
} from '@ant-design/icons-vue'
import type { IToolCallData } from '@/api/types'
const props = defineProps<{
toolCall: IToolCallData
}>()
const expanded = ref(false)
const iconMap: Record<string, Component> = {
read: ReadOutlined,
edit: EditOutlined,
bash: CodeOutlined,
write: FileAddOutlined,
search: SearchOutlined,
grep: SearchOutlined,
glob: FolderOpenOutlined,
tool: ApiOutlined,
}
const icon = computed(() => {
const key = props.toolCall.name.toLowerCase()
for (const [prefix, component] of Object.entries(iconMap)) {
if (key.includes(prefix)) return component
}
return ThunderboltOutlined
})
const previewText = computed(() => {
const result = props.toolCall.result || ''
const lines = result.split('\n').filter(Boolean)
return lines.slice(0, 2).join('\n') + (lines.length > 2 ? '...' : '')
})
</script>
<style scoped>
.tool-call-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
font-size: var(--font-sm);
transition: border-color var(--transition-fast);
}
.tool-call-card:hover {
border-color: var(--color-primary-light);
}
.tool-call-card--running {
border-color: var(--color-primary);
}
.tool-call-card--error {
border-color: var(--color-error);
}
.tool-call-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
cursor: pointer;
background: var(--bg-secondary);
user-select: none;
}
.tool-call-card__header:hover {
background: var(--bg-tertiary);
}
.tool-call-card__title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.tool-call-card__icon {
font-size: 14px;
color: var(--color-primary);
}
.tool-call-card__name {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.tool-call-card__duration {
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.tool-call-card__status {
display: flex;
align-items: center;
gap: var(--space-1);
}
.tool-call-card__spinner {
font-size: 14px;
color: var(--color-primary);
}
.tool-call-card__check {
font-size: 14px;
color: var(--color-success);
}
.tool-call-card__error-icon {
font-size: 14px;
color: var(--color-error);
}
.tool-call-card__expand {
font-size: 10px;
color: var(--text-placeholder);
transition: transform var(--transition-fast);
}
.tool-call-card__expand--open {
transform: rotate(90deg);
}
.tool-call-card__params,
.tool-call-card__result {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-color-split);
}
.tool-call-card__label {
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-bottom: var(--space-1);
font-weight: var(--font-weight-medium);
}
.tool-call-card__pre {
background: var(--code-bg);
color: var(--code-fg);
padding: var(--space-2);
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: var(--font-xs);
margin: 0;
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
}
.tool-call-card__pre--error {
color: var(--color-error);
}
.tool-call-card__preview {
padding: var(--space-2) var(--space-3);
color: var(--text-tertiary);
font-size: var(--font-xs);
white-space: pre-wrap;
border-top: 1px solid var(--border-color-split);
max-height: 40px;
overflow: hidden;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="dashboard-overview"> <div class="dashboard-overview">
<div class="overview-cards"> <div class="overview-cards">
<a-card hoverable class="overview-card" @click="navigate('experiences')"> <a-card hoverable class="overview-card" @click="openDrawer('tasks')">
<a-statistic <a-statistic
title="总任务数" title="总任务数"
:value="metrics?.total_tasks ?? 0" :value="metrics?.total_tasks ?? 0"
@ -10,7 +10,7 @@
<template #prefix><CheckCircleOutlined /></template> <template #prefix><CheckCircleOutlined /></template>
</a-statistic> </a-statistic>
</a-card> </a-card>
<a-card hoverable class="overview-card" @click="navigate('experiences')"> <a-card hoverable class="overview-card" @click="openDrawer('agents')">
<a-statistic <a-statistic
title="Agent 活跃数" title="Agent 活跃数"
:value="activeAgentCount" :value="activeAgentCount"
@ -19,7 +19,7 @@
<template #prefix><TeamOutlined /></template> <template #prefix><TeamOutlined /></template>
</a-statistic> </a-statistic>
</a-card> </a-card>
<a-card hoverable class="overview-card" @click="navigate('usage')"> <a-card hoverable class="overview-card" @click="openDrawer('usage')">
<a-statistic <a-statistic
title="LLM 用量" title="LLM 用量"
:value="usageSummary.total_tokens" :value="usageSummary.total_tokens"
@ -29,7 +29,7 @@
</a-statistic> </a-statistic>
<div class="overview-card__footer">{{ usageSummary.total_requests }} 次请求</div> <div class="overview-card__footer">{{ usageSummary.total_requests }} 次请求</div>
</a-card> </a-card>
<a-card hoverable class="overview-card" @click="navigate('metrics')"> <a-card hoverable class="overview-card" @click="openDrawer('metrics')">
<a-statistic <a-statistic
title="质量通过率" title="质量通过率"
:value="metrics ? (metrics.success_rate * 100).toFixed(1) : '0.0'" :value="metrics ? (metrics.success_rate * 100).toFixed(1) : '0.0'"
@ -45,7 +45,7 @@
<div class="overview-section"> <div class="overview-section">
<div class="overview-section__header"> <div class="overview-section__header">
<h3>最近经验</h3> <h3>最近经验</h3>
<a-button type="link" size="small" @click="navigate('experiences')">查看全部</a-button> <a-button type="link" size="small" @click="openDrawer('experiences')">查看全部</a-button>
</div> </div>
<div v-if="recentExperiences.length === 0" class="overview-section__empty"> <div v-if="recentExperiences.length === 0" class="overview-section__empty">
<a-empty description="暂无经验记录" :image-style="{ height: '40px' }" /> <a-empty description="暂无经验记录" :image-style="{ height: '40px' }" />
@ -55,6 +55,7 @@
v-for="exp in recentExperiences" v-for="exp in recentExperiences"
:key="exp.id" :key="exp.id"
class="experience-item" class="experience-item"
@click="openDrawer('experience-detail', exp)"
> >
<div class="experience-item__dot" :class="`experience-item__dot--${exp.outcome}`" /> <div class="experience-item__dot" :class="`experience-item__dot--${exp.outcome}`" />
<div class="experience-item__info"> <div class="experience-item__info">
@ -71,7 +72,7 @@
<div class="overview-section"> <div class="overview-section">
<div class="overview-section__header"> <div class="overview-section__header">
<h3>避坑预警</h3> <h3>避坑预警</h3>
<a-button type="link" size="small" @click="navigate('pitfalls')">查看全部</a-button> <a-button type="link" size="small" @click="openDrawer('pitfalls')">查看全部</a-button>
</div> </div>
<div v-if="store.pitfalls.length === 0" class="overview-section__empty"> <div v-if="store.pitfalls.length === 0" class="overview-section__empty">
<a-empty description="暂无预警信息" :image-style="{ height: '40px' }" /> <a-empty description="暂无预警信息" :image-style="{ height: '40px' }" />
@ -81,6 +82,7 @@
v-for="(warning, index) in store.pitfalls.slice(0, 5)" v-for="(warning, index) in store.pitfalls.slice(0, 5)"
:key="index" :key="index"
class="pitfall-item" class="pitfall-item"
@click="openDrawer('pitfall-detail', warning)"
> >
<a-tag :color="riskColor(warning.risk_level)" size="small"> <a-tag :color="riskColor(warning.risk_level)" size="small">
{{ riskLabel(warning.risk_level) }} {{ riskLabel(warning.risk_level) }}
@ -91,13 +93,167 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Detail Drawer -->
<a-drawer
v-model:open="drawerVisible"
:title="drawerTitle"
placement="right"
:width="520"
:destroy-on-close="true"
>
<div class="drawer-content">
<!-- Task list -->
<template v-if="drawerType === 'tasks'">
<div v-if="store.experiences.length === 0" class="drawer-empty">
<a-empty description="暂无任务记录" />
</div>
<div v-for="exp in store.experiences" :key="exp.id" class="drawer-task-item">
<div class="drawer-task-item__header">
<span class="drawer-task-item__goal">{{ exp.goal || exp.task_type }}</span>
<a-tag :color="exp.outcome === 'success' ? 'success' : 'error'" size="small">
{{ exp.outcome === 'success' ? '完成' : '失败' }}
</a-tag>
</div>
<div class="drawer-task-item__meta">
<span>{{ exp.task_type }}</span>
<span>{{ formatDuration(exp.duration) }}</span>
<span>{{ formatTime(exp.created_at) }}</span>
</div>
</div>
</template>
<!-- Experience list -->
<template v-if="drawerType === 'experiences'">
<div v-if="store.experiences.length === 0" class="drawer-empty">
<a-empty description="暂无经验记录" />
</div>
<div v-for="exp in store.experiences" :key="exp.id" class="drawer-exp-item" @click="openDrawer('experience-detail', exp)">
<div class="drawer-exp-item__dot" :class="`drawer-exp-item__dot--${exp.outcome}`" />
<div class="drawer-exp-item__body">
<div class="drawer-exp-item__header">
<span class="drawer-exp-item__goal">{{ exp.goal || exp.task_type }}</span>
<a-tag :color="exp.outcome === 'success' ? 'success' : 'error'" size="small">
{{ exp.outcome === 'success' ? '成功' : '失败' }}
</a-tag>
</div>
<div class="drawer-exp-item__meta">{{ exp.task_type }} · {{ formatDuration(exp.duration) }} · {{ formatTime(exp.created_at) }}</div>
</div>
</div>
</template>
<!-- Experience detail -->
<template v-if="drawerType === 'experience-detail' && experienceData">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="目标">{{ experienceData.goal || experienceData.task_type }}</a-descriptions-item>
<a-descriptions-item label="类型">{{ experienceData.task_type }}</a-descriptions-item>
<a-descriptions-item label="结果">
<a-tag :color="experienceData.outcome === 'success' ? 'success' : 'error'">
{{ experienceData.outcome === 'success' ? '成功' : '失败' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="耗时">{{ formatDuration(experienceData.duration) }}</a-descriptions-item>
<a-descriptions-item label="时间">{{ formatTime(experienceData.created_at) }}</a-descriptions-item>
</a-descriptions>
<div v-if="experienceData.steps_summary" class="detail-block">
<div class="detail-block__title">步骤摘要</div>
<div class="detail-block__text">{{ experienceData.steps_summary }}</div>
</div>
<div v-if="experienceData.failure_reasons?.length" class="detail-block">
<div class="detail-block__title">失败原因</div>
<ul class="detail-block__list">
<li v-for="(reason, i) in experienceData.failure_reasons" :key="i">{{ reason }}</li>
</ul>
</div>
<div v-if="experienceData.optimization_tips?.length" class="detail-block">
<div class="detail-block__title">优化建议</div>
<ul class="detail-block__list">
<li v-for="(tip, i) in experienceData.optimization_tips" :key="i">{{ tip }}</li>
</ul>
</div>
</template>
<!-- Pitfall list -->
<template v-if="drawerType === 'pitfalls'">
<div v-if="store.pitfalls.length === 0" class="drawer-empty">
<a-empty description="暂无预警信息" />
</div>
<div v-for="(warning, index) in store.pitfalls" :key="index" class="drawer-pitfall-item" @click="openDrawer('pitfall-detail', warning)">
<a-tag :color="riskColor(warning.risk_level)" size="small">{{ riskLabel(warning.risk_level) }}</a-tag>
<span>{{ warning.step }}</span>
</div>
</template>
<!-- Pitfall detail -->
<template v-if="drawerType === 'pitfall-detail' && pitfallData">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="步骤">{{ pitfallData.step }}</a-descriptions-item>
<a-descriptions-item label="风险等级">
<a-tag :color="riskColor(pitfallData.risk_level)">{{ riskLabel(pitfallData.risk_level) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="历史失败率">{{ (pitfallData.historical_failure_rate * 100).toFixed(1) }}%</a-descriptions-item>
</a-descriptions>
<div v-if="pitfallData.reason" class="detail-block">
<div class="detail-block__title">原因</div>
<div class="detail-block__text">{{ pitfallData.reason }}</div>
</div>
<div v-if="pitfallData.suggestion" class="detail-block">
<div class="detail-block__title">建议</div>
<div class="detail-block__text">{{ pitfallData.suggestion }}</div>
</div>
</template>
<!-- Usage -->
<template v-if="drawerType === 'usage'">
<UsagePanel />
</template>
<!-- Metrics -->
<template v-if="drawerType === 'metrics'">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="总任务数">{{ metrics?.total_tasks ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="成功率">{{ metrics ? (metrics.success_rate * 100).toFixed(1) : '0.0' }}%</a-descriptions-item>
<a-descriptions-item label="平均耗时">{{ metrics ? formatDuration(metrics.avg_duration) : '-' }}</a-descriptions-item>
</a-descriptions>
</template>
<!-- Agents -->
<template v-if="drawerType === 'agents'">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="活跃 Agent 数">{{ activeAgentCount }}</a-descriptions-item>
</a-descriptions>
<div v-if="store.experiences.length === 0" class="drawer-empty">
<a-empty description="暂无 Agent 活动记录" />
</div>
<div v-else>
<div class="detail-block" style="margin-top: var(--space-4)">
<div class="detail-block__title">按任务类型分布</div>
</div>
<div v-for="type in agentTypeStats" :key="type.name" class="drawer-exp-item">
<div class="drawer-exp-item__body">
<span class="drawer-exp-item__goal">{{ type.name }}</span>
<span class="drawer-exp-item__meta">{{ type.count }} 次任务 · 成功率 {{ type.successRate }}%</span>
</div>
</div>
</div>
</template>
</div>
</a-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import {
import { Card as ACard, Statistic as AStatistic, Tag as ATag, Button as AButton, Empty as AEmpty } from 'ant-design-vue' Card as ACard,
Statistic as AStatistic,
Tag as ATag,
Button as AButton,
Empty as AEmpty,
Drawer as ADrawer,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
} from 'ant-design-vue'
import { import {
CheckCircleOutlined, CheckCircleOutlined,
TeamOutlined, TeamOutlined,
@ -107,16 +263,63 @@ import {
import { useEvolutionStore } from '@/stores/evolution' import { useEvolutionStore } from '@/stores/evolution'
import { evolutionApi } from '@/api/evolution' import { evolutionApi } from '@/api/evolution'
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import UsagePanel from './UsagePanel.vue'
import type { Experience, PitfallWarning } from '@/api/evolution'
const router = useRouter()
const store = useEvolutionStore() const store = useEvolutionStore()
const metrics = computed(() => store.metrics) const metrics = computed(() => store.metrics)
const recentExperiences = computed(() => store.experiences.slice(0, 5)) const recentExperiences = computed(() => store.experiences.slice(0, 5))
const agentTypeStats = computed(() => {
const typeMap = new Map<string, { total: number; success: number }>()
for (const exp of store.experiences) {
const entry = typeMap.get(exp.task_type) || { total: 0, success: 0 }
entry.total++
if (exp.outcome === 'success') entry.success++
typeMap.set(exp.task_type, entry)
}
return Array.from(typeMap.entries()).map(([name, { total, success }]) => ({
name,
count: total,
successRate: total > 0 ? ((success / total) * 100).toFixed(1) : '0.0',
}))
})
const activeAgentCount = ref(0) const activeAgentCount = ref(0)
const usageSummary = ref({ total_tokens: 0, total_requests: 0 }) const usageSummary = ref({ total_tokens: 0, total_requests: 0 })
// Drawer state
const drawerVisible = ref(false)
const drawerType = ref('')
const drawerData = ref<Experience | PitfallWarning | null>(null)
// Type-narrowed computed for template access without type guards
const experienceData = computed(() =>
drawerData.value && 'id' in drawerData.value ? drawerData.value as Experience : null
)
const pitfallData = computed(() =>
drawerData.value && 'step' in drawerData.value ? drawerData.value as PitfallWarning : null
)
const drawerTitleMap: Record<string, string> = {
tasks: '任务列表',
experiences: '经验记录',
'experience-detail': '经验详情',
pitfalls: '避坑预警',
'pitfall-detail': '预警详情',
usage: 'LLM 用量',
metrics: '质量指标',
agents: 'Agent 概览',
}
const drawerTitle = computed(() => drawerTitleMap[drawerType.value] || '')
function openDrawer(type: string, data?: Experience | PitfallWarning) {
drawerType.value = type
drawerData.value = data || null
drawerVisible.value = true
}
onMounted(async () => { onMounted(async () => {
try { try {
const usageData = await evolutionApi.getUsage({ period: '7d' }) const usageData = await evolutionApi.getUsage({ period: '7d' })
@ -148,16 +351,19 @@ onMounted(async () => {
} }
}) })
function navigate(key: string) {
router.push(`/evolution/${key}`)
}
function formatTime(isoStr: string): string { function formatTime(isoStr: string): string {
if (!isoStr) return '' if (!isoStr) return ''
const d = new Date(isoStr) const d = new Date(isoStr)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
} }
function formatDuration(seconds: number): string {
if (!seconds) return '-'
if (seconds < 60) return `${seconds.toFixed(1)}`
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}分钟`
return `${(seconds / 3600).toFixed(1)}小时`
}
function riskColor(level: string): string { function riskColor(level: string): string {
switch (level) { switch (level) {
case 'high': return 'red' case 'high': return 'red'
@ -314,4 +520,131 @@ function riskLabel(level: string): string {
color: var(--color-error); color: var(--color-error);
font-weight: 600; font-weight: 600;
} }
/* Drawer styles */
.drawer-content {
padding: var(--space-2) 0;
}
.drawer-empty {
padding: var(--space-8) 0;
text-align: center;
}
.drawer-task-item {
padding: var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-color-split);
margin-bottom: var(--space-2);
}
.drawer-task-item__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-1);
}
.drawer-task-item__goal {
font-weight: 500;
color: var(--text-primary);
}
.drawer-task-item__meta {
display: flex;
gap: var(--space-3);
font-size: 12px;
color: var(--text-tertiary);
}
.drawer-exp-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.drawer-exp-item:hover {
background: var(--bg-tertiary);
}
.drawer-exp-item__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 6px;
}
.drawer-exp-item__dot--success { background: var(--color-success); }
.drawer-exp-item__dot--failure { background: var(--color-error); }
.drawer-exp-item__body {
flex: 1;
min-width: 0;
}
.drawer-exp-item__header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.drawer-exp-item__goal {
font-size: var(--font-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.drawer-exp-item__meta {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.drawer-pitfall-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-sm);
transition: background var(--transition-fast);
}
.drawer-pitfall-item:hover {
background: var(--bg-tertiary);
}
.detail-block {
margin-top: var(--space-4);
}
.detail-block__title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.detail-block__text {
font-size: var(--font-sm);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
.detail-block__list {
margin: 0;
padding-left: 20px;
font-size: var(--font-sm);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style> </style>

View File

@ -24,7 +24,7 @@
:class="{ 'timeline-item--left': index % 2 === 0, 'timeline-item--right': index % 2 !== 0 }" :class="{ 'timeline-item--left': index % 2 === 0, 'timeline-item--right': index % 2 !== 0 }"
> >
<div class="timeline-dot" :class="`timeline-dot--${exp.outcome}`" /> <div class="timeline-dot" :class="`timeline-dot--${exp.outcome}`" />
<div class="timeline-card" @click="toggleExpand(exp.id)"> <div class="timeline-card" @click="openDetail(exp)">
<div class="timeline-card__header"> <div class="timeline-card__header">
<span class="timeline-card__goal">{{ exp.goal || exp.task_type }}</span> <span class="timeline-card__goal">{{ exp.goal || exp.task_type }}</span>
<a-tag :color="exp.outcome === 'success' ? 'success' : 'error'" size="small"> <a-tag :color="exp.outcome === 'success' ? 'success' : 'error'" size="small">
@ -36,33 +36,62 @@
<span class="timeline-card__duration">{{ formatDuration(exp.duration) }}</span> <span class="timeline-card__duration">{{ formatDuration(exp.duration) }}</span>
<span class="timeline-card__time">{{ formatTime(exp.created_at) }}</span> <span class="timeline-card__time">{{ formatTime(exp.created_at) }}</span>
</div> </div>
<div v-if="expandedId === exp.id" class="timeline-card__detail">
<div v-if="exp.steps_summary" class="detail-section">
<div class="detail-label">步骤摘要</div>
<div class="detail-text">{{ exp.steps_summary }}</div>
</div> </div>
<div v-if="exp.failure_reasons?.length" class="detail-section"> </div>
<div class="detail-label">失败原因</div> </div>
<ul class="detail-list">
<li v-for="(reason, i) in exp.failure_reasons" :key="i">{{ reason }}</li> <!-- Detail Drawer -->
<a-drawer
v-model:open="drawerVisible"
:title="selectedExp ? (selectedExp.goal || selectedExp.task_type) : '经验详情'"
placement="right"
:width="520"
:destroy-on-close="true"
>
<template v-if="selectedExp">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="目标">{{ selectedExp.goal || selectedExp.task_type }}</a-descriptions-item>
<a-descriptions-item label="类型">{{ selectedExp.task_type }}</a-descriptions-item>
<a-descriptions-item label="结果">
<a-tag :color="selectedExp.outcome === 'success' ? 'success' : 'error'">
{{ selectedExp.outcome === 'success' ? '成功' : '失败' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="耗时">{{ formatDuration(selectedExp.duration) }}</a-descriptions-item>
<a-descriptions-item label="时间">{{ formatTime(selectedExp.created_at) }}</a-descriptions-item>
</a-descriptions>
<div v-if="selectedExp.steps_summary" class="detail-block">
<div class="detail-block__title">步骤摘要</div>
<div class="detail-block__text">{{ selectedExp.steps_summary }}</div>
</div>
<div v-if="selectedExp.failure_reasons?.length" class="detail-block">
<div class="detail-block__title">失败原因</div>
<ul class="detail-block__list">
<li v-for="(reason, i) in selectedExp.failure_reasons" :key="i">{{ reason }}</li>
</ul> </ul>
</div> </div>
<div v-if="exp.optimization_tips?.length" class="detail-section"> <div v-if="selectedExp.optimization_tips?.length" class="detail-block">
<div class="detail-label">优化建议</div> <div class="detail-block__title">优化建议</div>
<ul class="detail-list"> <ul class="detail-block__list">
<li v-for="(tip, i) in exp.optimization_tips" :key="i">{{ tip }}</li> <li v-for="(tip, i) in selectedExp.optimization_tips" :key="i">{{ tip }}</li>
</ul> </ul>
</div> </div>
</div> </template>
</div> </a-drawer>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Select as ASelect, SelectOption as ASelectOption, Tag as ATag, Empty as AEmpty } from 'ant-design-vue' import {
Select as ASelect,
SelectOption as ASelectOption,
Tag as ATag,
Empty as AEmpty,
Drawer as ADrawer,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
} from 'ant-design-vue'
import type { Experience } from '@/api/evolution' import type { Experience } from '@/api/evolution'
defineProps<{ defineProps<{
@ -73,11 +102,13 @@ const emit = defineEmits<{
(e: 'filter', outcome: string): void (e: 'filter', outcome: string): void
}>() }>()
const expandedId = ref<string | null>(null)
const filterOutcome = ref('') const filterOutcome = ref('')
const drawerVisible = ref(false)
const selectedExp = ref<Experience | null>(null)
function toggleExpand(id: string) { function openDetail(exp: Experience) {
expandedId.value = expandedId.value === id ? null : id selectedExp.value = exp
drawerVisible.value = true
} }
function onFilterChange() { function onFilterChange() {
@ -85,6 +116,7 @@ function onFilterChange() {
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
if (!seconds) return '-'
if (seconds < 60) return `${seconds.toFixed(1)}` if (seconds < 60) return `${seconds.toFixed(1)}`
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}分钟` if (seconds < 3600) return `${(seconds / 60).toFixed(1)}分钟`
return `${(seconds / 3600).toFixed(1)}小时` return `${(seconds / 3600).toFixed(1)}小时`
@ -236,4 +268,29 @@ function formatTime(isoStr: string): string {
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: 1.6; line-height: 1.6;
} }
.detail-block {
margin-top: var(--space-4);
}
.detail-block__title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.detail-block__text {
font-size: var(--font-sm);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
.detail-block__list {
margin: 0;
padding-left: 20px;
font-size: var(--font-sm);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style> </style>

View File

@ -23,6 +23,7 @@
v-for="(warning, index) in warnings" v-for="(warning, index) in warnings"
:key="index" :key="index"
class="pitfall-item" class="pitfall-item"
@click="openDetail(warning)"
> >
<div class="pitfall-item__header"> <div class="pitfall-item__header">
<a-tag :color="riskColor(warning.risk_level)" size="small"> <a-tag :color="riskColor(warning.risk_level)" size="small">
@ -33,20 +34,49 @@
<div class="pitfall-item__rate"> <div class="pitfall-item__rate">
历史失败率: <strong>{{ (warning.historical_failure_rate * 100).toFixed(1) }}%</strong> 历史失败率: <strong>{{ (warning.historical_failure_rate * 100).toFixed(1) }}%</strong>
</div> </div>
<div v-if="warning.reason" class="pitfall-item__reason">
{{ warning.reason }}
</div>
<div v-if="warning.suggestion && warning.suggestion !== warning.reason" class="pitfall-item__suggestion">
<span class="suggestion-icon">💡</span> {{ warning.suggestion }}
</div> </div>
</div> </div>
<!-- Detail Drawer -->
<a-drawer
v-model:open="drawerVisible"
:title="selectedWarning ? selectedWarning.step : '预警详情'"
placement="right"
:width="520"
:destroy-on-close="true"
>
<template v-if="selectedWarning">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="步骤">{{ selectedWarning.step }}</a-descriptions-item>
<a-descriptions-item label="风险等级">
<a-tag :color="riskColor(selectedWarning.risk_level)">{{ riskLabel(selectedWarning.risk_level) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="历史失败率">{{ (selectedWarning.historical_failure_rate * 100).toFixed(1) }}%</a-descriptions-item>
</a-descriptions>
<div v-if="selectedWarning.reason" class="detail-block">
<div class="detail-block__title">原因</div>
<div class="detail-block__text">{{ selectedWarning.reason }}</div>
</div> </div>
<div v-if="selectedWarning.suggestion" class="detail-block">
<div class="detail-block__title">建议</div>
<div class="detail-block__text">{{ selectedWarning.suggestion }}</div>
</div>
</template>
</a-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Input as AInput, Button as AButton, Tag as ATag, Empty as AEmpty } from 'ant-design-vue' import {
Input as AInput,
Button as AButton,
Tag as ATag,
Empty as AEmpty,
Drawer as ADrawer,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
} from 'ant-design-vue'
import type { PitfallWarning } from '@/api/evolution' import type { PitfallWarning } from '@/api/evolution'
defineProps<{ defineProps<{
@ -59,6 +89,13 @@ const emit = defineEmits<{
}>() }>()
const taskTypeInput = ref('') const taskTypeInput = ref('')
const drawerVisible = ref(false)
const selectedWarning = ref<PitfallWarning | null>(null)
function openDetail(warning: PitfallWarning) {
selectedWarning.value = warning
drawerVisible.value = true
}
function onCheck() { function onCheck() {
if (taskTypeInput.value.trim()) { if (taskTypeInput.value.trim()) {
@ -129,6 +166,12 @@ function riskLabel(level: string): string {
border-radius: 6px; border-radius: 6px;
padding: 10px 12px; padding: 10px 12px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer;
transition: box-shadow var(--transition-fast);
}
.pitfall-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
} }
.pitfall-item__header { .pitfall-item__header {
@ -165,4 +208,21 @@ function riskLabel(level: string): string {
.suggestion-icon { .suggestion-icon {
font-size: 12px; font-size: 12px;
} }
.detail-block {
margin-top: var(--space-4);
}
.detail-block__title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.detail-block__text {
font-size: var(--font-sm);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style> </style>

View File

@ -0,0 +1,150 @@
<template>
<nav
class="icon-nav"
:class="{ 'icon-nav--collapsed': collapsed }"
role="navigation"
aria-label="功能导航"
>
<div class="icon-nav__items">
<a-tooltip
v-for="item in navItems"
:key="item.key"
:title="item.label"
placement="right"
>
<button
class="icon-nav__item"
:class="{ 'icon-nav__item--active': activeKey === item.key }"
:aria-label="item.label"
:aria-current="activeKey === item.key ? 'page' : undefined"
@click="handleClick(item)"
>
<component :is="item.icon" class="icon-nav__icon" />
</button>
</a-tooltip>
</div>
</nav>
</template>
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { Tooltip as ATooltip } from 'ant-design-vue'
import {
MessageOutlined,
ApartmentOutlined,
BookOutlined,
AppstoreOutlined,
DashboardOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
interface NavItem {
key: string
label: string
icon: Component
panel: 'chat' | 'tr' | 'br'
tab?: string
}
withDefaults(defineProps<{
collapsed?: boolean
}>(), {
collapsed: false,
})
const emit = defineEmits<{
navigate: [panel: string, tab?: string]
}>()
const navItems: NavItem[] = [
{ key: 'chat', label: '对话', icon: MessageOutlined as Component, panel: 'chat' },
{ key: 'workflow', label: '工作流', icon: ApartmentOutlined as Component, panel: 'tr', tab: 'workflow' },
{ key: 'knowledge', label: '知识库', icon: BookOutlined as Component, panel: 'tr', tab: 'knowledge' },
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component, panel: 'br', tab: 'skills' },
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component, panel: 'br', tab: 'monitor' },
{ key: 'settings', label: '设置', icon: SettingOutlined as Component, panel: 'br', tab: 'settings' },
]
const savedActive = localStorage.getItem('icon-nav-active') || 'chat'
const activeKey = ref(savedActive)
function handleClick(item: NavItem) {
activeKey.value = item.key
localStorage.setItem('icon-nav-active', item.key)
emit('navigate', item.panel, item.tab)
}
</script>
<style scoped>
.icon-nav {
display: flex;
flex-direction: column;
width: 52px;
background: var(--bg-primary);
border-right: 1px solid var(--border-color-split);
flex-shrink: 0;
transition: width var(--transition-normal), opacity var(--transition-fast);
overflow: hidden;
}
.icon-nav--collapsed {
width: 0;
border-right: none;
}
.icon-nav__items {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3) 0;
gap: var(--space-1);
}
.icon-nav__item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.icon-nav__item:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.icon-nav__item--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
/* Active indicator bar — slides in from left */
.icon-nav__item--active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--color-primary);
border-radius: 0 var(--radius-full) var(--radius-full) 0;
animation: indicatorSlideIn 0.2s ease-out;
}
@keyframes indicatorSlideIn {
from { height: 0; opacity: 0; }
to { height: 20px; opacity: 1; }
}
.icon-nav__icon {
font-size: 18px;
}
</style>

View File

@ -114,8 +114,13 @@ defineExpose({ setActiveTab })
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border-color-split);
transition: border-color var(--transition-normal);
}
.quadrant-panel:hover {
border-color: var(--border-color);
} }
.quadrant-panel--collapsed { .quadrant-panel--collapsed {
@ -126,16 +131,16 @@ defineExpose({ setActiveTab })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 36px; height: 38px;
padding: 0 var(--space-2); padding: 0 var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color-split);
background: var(--bg-secondary); background: var(--bg-secondary);
flex-shrink: 0; flex-shrink: 0;
} }
.quadrant-panel__tabs { .quadrant-panel__tabs {
display: flex; display: flex;
gap: var(--space-1); gap: 2px;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
} }
@ -148,15 +153,16 @@ defineExpose({ setActiveTab })
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-3);
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: var(--font-xs); font-size: var(--font-xs);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm); border-radius: var(--radius-md);
white-space: nowrap; white-space: nowrap;
transition: all var(--transition-fast); transition: all var(--transition-fast);
position: relative;
} }
.quadrant-panel__tab:hover { .quadrant-panel__tab:hover {
@ -178,13 +184,13 @@ defineExpose({ setActiveTab })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 26px;
height: 24px; height: 26px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-tertiary); color: var(--text-placeholder);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm); border-radius: var(--radius-md);
flex-shrink: 0; flex-shrink: 0;
transition: all var(--transition-fast); transition: all var(--transition-fast);
} }

View File

@ -190,20 +190,20 @@ function onHandleKeydown(e: KeyboardEvent) {
} }
.split-pane--horizontal > .split-pane__handle { .split-pane--horizontal > .split-pane__handle {
width: 6px; width: 8px;
cursor: col-resize; cursor: col-resize;
flex-direction: column; flex-direction: column;
} }
.split-pane--vertical > .split-pane__handle { .split-pane--vertical > .split-pane__handle {
height: 6px; height: 8px;
cursor: row-resize; cursor: row-resize;
flex-direction: row; flex-direction: row;
} }
.split-pane__handle:hover, .split-pane__handle:hover,
.split-pane--dragging .split-pane__handle { .split-pane--dragging .split-pane__handle {
background-color: var(--color-primary); background-color: var(--color-primary-light);
} }
.split-pane__handle-line { .split-pane__handle-line {
@ -214,17 +214,17 @@ function onHandleKeydown(e: KeyboardEvent) {
.split-pane--horizontal > .split-pane__handle > .split-pane__handle-line { .split-pane--horizontal > .split-pane__handle > .split-pane__handle-line {
width: 2px; width: 2px;
height: 24px; height: 28px;
} }
.split-pane--vertical > .split-pane__handle > .split-pane__handle-line { .split-pane--vertical > .split-pane__handle > .split-pane__handle-line {
height: 2px; height: 2px;
width: 24px; width: 28px;
} }
.split-pane__handle:hover .split-pane__handle-line, .split-pane__handle:hover .split-pane__handle-line,
.split-pane--dragging .split-pane__handle-line { .split-pane--dragging .split-pane__handle-line {
background-color: transparent; background-color: var(--color-primary);
} }
.split-pane--dragging { .split-pane--dragging {

View File

@ -51,6 +51,11 @@ defineEmits<{
<style scoped> <style scoped>
.skill-card { .skill-card {
cursor: pointer; cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.skill-card:hover {
transform: translateY(-1px);
} }
.skill-card__title { .skill-card__title {
@ -64,10 +69,10 @@ defineEmits<{
} }
.skill-card__desc { .skill-card__desc {
font-size: 13px; font-size: var(--font-sm);
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: var(--space-2);
line-height: 1.5; line-height: var(--leading-normal);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@ -78,7 +83,7 @@ defineEmits<{
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
margin-bottom: 8px; margin-bottom: var(--space-2);
} }
.skill-card__deps { .skill-card__deps {
@ -89,23 +94,24 @@ defineEmits<{
} }
.skill-card__deps-label { .skill-card__deps-label {
font-size: 12px; font-size: var(--font-xs);
color: var(--text-placeholder); color: var(--text-placeholder);
} }
.skill-card__more { .skill-card__more {
font-size: 12px; font-size: var(--font-xs);
color: var(--text-placeholder); color: var(--text-placeholder);
} }
.skill-card__footer { .skill-card__footer {
margin-top: 8px; margin-top: var(--space-2);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.skill-card__version { .skill-card__version {
font-size: 12px; font-size: var(--font-xs);
color: var(--text-placeholder); color: var(--text-placeholder);
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
} }
</style> </style>

View File

@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
// Agent-First 四象限布局 (新) // Agent-First 左对话+右双栏布局
{ {
path: '/agent', path: '/agent',
name: 'agent', name: 'agent',
@ -16,25 +16,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'chat', path: 'chat',
name: 'agent-chat', name: 'agent-chat',
meta: { title: '对话', quadrant: 'tl', tab: 'chat' }, meta: { title: '对话', panel: 'left' },
component: () => import('@/views/ChatView.vue'), component: () => import('@/views/ChatView.vue'),
}, },
{ {
path: 'code', path: 'code',
name: 'agent-code', name: 'agent-code',
meta: { title: '代码', quadrant: 'tr', tab: 'code' }, meta: { title: '代码', panel: 'tr', tab: 'code' },
component: () => import('@/views/WorkflowView.vue'), component: () => import('@/views/WorkflowView.vue'),
}, },
{
path: 'terminal',
name: 'agent-terminal',
meta: { title: '终端', quadrant: 'bl', tab: 'terminal' },
component: () => import('@/views/TerminalView.vue'),
},
{ {
path: 'monitor', path: 'monitor',
name: 'agent-monitor', name: 'agent-monitor',
meta: { title: '监控', quadrant: 'br', tab: 'monitor' }, meta: { title: '监控', panel: 'br', tab: 'monitor' },
component: () => import('@/views/EvolutionView.vue'), component: () => import('@/views/EvolutionView.vue'),
}, },
], ],
@ -69,7 +63,7 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
path: '/terminal', path: '/terminal',
redirect: '/agent/terminal', redirect: '/legacy/terminal',
}, },
// Computer Use (保留独立路由,显示"即将推出") // Computer Use (保留独立路由,显示"即将推出")

View File

@ -36,7 +36,15 @@ export const useChatStore = defineStore('chat', () => {
async function loadConversations(): Promise<void> { async function loadConversations(): Promise<void> {
try { try {
const data = await apiClient.getConversations() const data = await apiClient.getConversations()
conversations.value = data // Normalize server response: backend returns {id, created_at, updated_at, message_count}
// but frontend IConversation expects {id, title, messages, created_at, updated_at}
conversations.value = data.map((conv: any) => ({
id: conv.id,
title: conv.title || '对话',
messages: Array.isArray(conv.messages) ? conv.messages : [],
created_at: conv.created_at,
updated_at: conv.updated_at,
}))
} catch (error) { } catch (error) {
console.error('Failed to load conversations:', error) console.error('Failed to load conversations:', error)
} }
@ -169,6 +177,12 @@ export const useChatStore = defineStore('chat', () => {
} }
ws.value.send(JSON.stringify(wsMessage)) ws.value.send(JSON.stringify(wsMessage))
// Update conversation title from first user message
const conv = conversations.value.find((c) => c.id === conversationId)
if (conv && conv.title === '新对话') {
conv.title = message.length > 20 ? `${message.substring(0, 20)}...` : message
}
} }
/** Connect to WebSocket for real-time streaming */ /** Connect to WebSocket for real-time streaming */
@ -225,22 +239,34 @@ export const useChatStore = defineStore('chat', () => {
// --- Internal helpers --- // --- Internal helpers ---
function handleWsMessage(data: Record<string, any>): void { function handleWsMessage(data: Record<string, any>): void {
const conversationId = currentConversationId.value
if (!conversationId) return
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) return
const lastAssistantMsg = [...conv.messages]
.reverse()
.find((m) => m.role === 'assistant')
// Backend sends nested data: {type, data: {...}} // Backend sends nested data: {type, data: {...}}
// Flatten for easier access // Flatten for easier access
const payload = data.data ?? data const payload = data.data ?? data
switch (data.type) { switch (data.type) {
case 'routing': case 'connected': {
// Backend confirms conversation — update local ID if backend assigned a different one
const serverConvId = data.conversation_id || payload.conversation_id
if (serverConvId && serverConvId !== currentConversationId.value) {
// Rename the local conversation to match the server ID
const localId = currentConversationId.value
const conv = conversations.value.find((c) => c.id === localId)
if (conv) {
conv.id = serverConvId
currentConversationId.value = serverConvId
}
}
break
}
case 'routing': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const lastAssistantMsg = [...conv.messages]
.reverse()
.find((m) => m.role === 'assistant')
if (lastAssistantMsg) { if (lastAssistantMsg) {
updateMessage(conversationId, lastAssistantMsg.id, { updateMessage(conversationId, lastAssistantMsg.id, {
matched_skill: data.skill, matched_skill: data.skill,
@ -250,9 +276,16 @@ export const useChatStore = defineStore('chat', () => {
} }
streamingSteps.value.push(`路由至: ${data.skill} (置信度: ${(data.confidence * 100).toFixed(1)}%)`) streamingSteps.value.push(`路由至: ${data.skill} (置信度: ${(data.confidence * 100).toFixed(1)}%)`)
break break
}
case 'step': { case 'step': {
// Backend sends: {type: "step", data: {event_type, step, data, timestamp}} const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const lastAssistantMsg = [...conv.messages]
.reverse()
.find((m) => m.role === 'assistant')
const stepInfo = payload const stepInfo = payload
const desc = stepInfo.event_type === 'final_answer' const desc = stepInfo.event_type === 'final_answer'
? '生成最终回答' ? '生成最终回答'
@ -262,6 +295,44 @@ export const useChatStore = defineStore('chat', () => {
? '思考中...' ? '思考中...'
: `步骤 ${stepInfo.step || ''}: ${stepInfo.event_type || ''}` : `步骤 ${stepInfo.step || ''}: ${stepInfo.event_type || ''}`
streamingSteps.value.push(desc) streamingSteps.value.push(desc)
// Track tool calls for ToolCallCard rendering
if (lastAssistantMsg) {
const toolCalls = lastAssistantMsg.tool_calls || []
if (stepInfo.event_type === 'tool_call') {
const tcId = `tc-${stepInfo.step || toolCalls.length}`
const toolName = stepInfo.data?.tool_name || stepInfo.data?.name || 'unknown'
const params = stepInfo.data?.arguments
? (typeof stepInfo.data.arguments === 'string'
? stepInfo.data.arguments
: JSON.stringify(stepInfo.data.arguments, null, 2))
: undefined
toolCalls.push({
id: tcId,
name: toolName,
status: 'running',
params: params ? (params.length > 500 ? params.substring(0, 500) + '...' : params) : undefined,
})
updateMessage(conversationId, lastAssistantMsg.id, { tool_calls: [...toolCalls] })
} else if (stepInfo.event_type === 'tool_result') {
// Find the last running tool call and update it
const lastRunning = [...toolCalls].reverse().find(tc => tc.status === 'running')
if (lastRunning) {
const resultStr = stepInfo.data?.output
? (typeof stepInfo.data.output === 'string'
? stepInfo.data.output
: JSON.stringify(stepInfo.data.output, null, 2))
: ''
lastRunning.status = stepInfo.data?.error ? 'error' : 'completed'
lastRunning.result = resultStr.length > 2000 ? resultStr.substring(0, 2000) + '...' : resultStr
lastRunning.error = stepInfo.data?.error
lastRunning.duration = stepInfo.data?.duration
updateMessage(conversationId, lastAssistantMsg.id, { tool_calls: [...toolCalls] })
}
}
}
// Accumulate final_answer content for streaming display // Accumulate final_answer content for streaming display
if (stepInfo.event_type === 'final_answer' && lastAssistantMsg) { if (stepInfo.event_type === 'final_answer' && lastAssistantMsg) {
const chunk = stepInfo.data?.output || '' const chunk = stepInfo.data?.output || ''
@ -275,6 +346,13 @@ export const useChatStore = defineStore('chat', () => {
} }
case 'result': { case 'result': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const lastAssistantMsg = [...conv.messages]
.reverse()
.find((m) => m.role === 'assistant')
// Backend sends: {type: "result", data: {message: "..."}} or {data: {status, content}} // Backend sends: {type: "result", data: {message: "..."}} or {data: {status, content}}
const content = payload.message || payload.content || '' const content = payload.message || payload.content || ''
if (lastAssistantMsg) { if (lastAssistantMsg) {
@ -285,12 +363,26 @@ export const useChatStore = defineStore('chat', () => {
status: 'completed', status: 'completed',
}) })
} }
// Update conversation title from first user message
const firstUserMsg = conv.messages.find((m) => m.role === 'user')
if (firstUserMsg && conv.title === '新对话') {
conv.title = firstUserMsg.content.length > 20
? `${firstUserMsg.content.substring(0, 20)}...`
: firstUserMsg.content
}
isLoading.value = false isLoading.value = false
streamingSteps.value = [] streamingSteps.value = []
break break
} }
case 'error': case 'error': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const lastAssistantMsg = [...conv.messages]
.reverse()
.find((m) => m.role === 'assistant')
if (lastAssistantMsg) { if (lastAssistantMsg) {
updateMessage(conversationId, lastAssistantMsg.id, { updateMessage(conversationId, lastAssistantMsg.id, {
content: `错误: ${payload.message || '未知错误'}`, content: `错误: ${payload.message || '未知错误'}`,
@ -302,10 +394,14 @@ export const useChatStore = defineStore('chat', () => {
break break
} }
} }
}
function appendMessage(conversationId: string, message: IChatMessage): void { function appendMessage(conversationId: string, message: IChatMessage): void {
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (conv) { if (conv) {
if (!Array.isArray(conv.messages)) {
conv.messages = []
}
conv.messages.push(message) conv.messages.push(message)
conv.updated_at = new Date().toISOString() conv.updated_at = new Date().toISOString()
} }

View File

@ -8,12 +8,15 @@
import { ref, computed, watchEffect } from 'vue' import { ref, computed, watchEffect } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { theme } from 'ant-design-vue'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context' import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
export type ThemeMode = 'light' | 'dark' | 'system' export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'agentkit-theme-mode' const STORAGE_KEY = 'agentkit-theme-mode'
const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system']
function readToken(varName: string, fallback: string): string { function readToken(varName: string, fallback: string): string {
if (typeof document === 'undefined') return fallback if (typeof document === 'undefined') return fallback
const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
@ -26,8 +29,13 @@ function getSystemPreference(): 'light' | 'dark' {
} }
function getStoredMode(): ThemeMode { function getStoredMode(): ThemeMode {
try {
if (typeof localStorage === 'undefined') return 'system' if (typeof localStorage === 'undefined') return 'system'
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system' const raw = localStorage.getItem(STORAGE_KEY)
return VALID_MODES.includes(raw as ThemeMode) ? (raw as ThemeMode) : 'system'
} catch {
return 'system'
}
} }
function applyTheme(resolved: 'light' | 'dark') { function applyTheme(resolved: 'light' | 'dark') {
@ -38,14 +46,22 @@ function applyTheme(resolved: 'light' | 'dark') {
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>(getStoredMode()) const mode = ref<ThemeMode>(getStoredMode())
// Separate reactive ref for system preference — allows matchMedia changes
// to trigger Vue reactivity in resolvedMode computed
const systemPreference = ref<'light' | 'dark'>(getSystemPreference())
const resolvedMode = computed<'light' | 'dark'>(() => { const resolvedMode = computed<'light' | 'dark'>(() => {
if (mode.value === 'system') return getSystemPreference() if (mode.value === 'system') return systemPreference.value
return mode.value return mode.value
}) })
function setMode(newMode: ThemeMode) { function setMode(newMode: ThemeMode) {
mode.value = newMode mode.value = newMode
try {
localStorage.setItem(STORAGE_KEY, newMode) localStorage.setItem(STORAGE_KEY, newMode)
} catch {
// localStorage unavailable (private browsing, quota exceeded) — ignore
}
} }
function toggle() { function toggle() {
@ -61,13 +77,10 @@ export const useThemeStore = defineStore('theme', () => {
applyTheme(resolvedMode.value) applyTheme(resolvedMode.value)
}) })
// Listen for system preference changes // Listen for system preference changes — update reactive ref
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Force re-evaluation when system preference changes systemPreference.value = e.matches ? 'dark' : 'light'
if (mode.value === 'system') {
applyTheme(getSystemPreference())
}
}) })
} }
@ -76,7 +89,7 @@ export const useThemeStore = defineStore('theme', () => {
const isDark = resolvedMode.value === 'dark' const isDark = resolvedMode.value === 'dark'
return { return {
algorithm: isDark ? undefined : undefined, // Ant Design 4.x uses token-based theming algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: { token: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'), colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'), colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),

View File

@ -1,32 +1,32 @@
/** /**
* Fischer AgentKit Responsive Breakpoints * Fischer AgentKit Responsive Breakpoints Left Chat + Right Dual-Column
* *
* 1440px: Four quadrants fully visible * 1440px: Full layout, both right panels visible
* 1280-1440px: Bottom-right quadrant auto-collapsed * 1024-1439px: Bottom-right panel auto-collapsed
* <1280px: Prompt to use larger screen * <1024px: Prompt to use larger screen
*/ */
/* ── Full four-quadrant layout ── */ /* ── Full layout ── */
@media (min-width: 1440px) { @media (min-width: 1440px) {
.agent-layout__body { .agent-layout__body {
display: flex; display: flex;
} }
} }
/* ── Compact: bottom-right quadrant collapsed ── */ /* ── Compact: bottom-right panel collapsed ── */
@media (min-width: 1280px) and (max-width: 1439px) { @media (min-width: 1024px) and (max-width: 1439px) {
.agent-layout__body { .agent-layout__body {
display: flex; display: flex;
} }
/* Auto-collapse bottom-right quadrant at medium widths */ /* Auto-collapse bottom-right panel at medium widths */
.agent-layout__body .split-pane--horizontal > .split-pane__second .split-pane--vertical > .split-pane__second .quadrant-panel { .agent-layout__body .split-pane--horizontal > .split-pane__second .split-pane--vertical > .split-pane__second .quadrant-panel {
height: auto !important; height: auto !important;
} }
} }
/* ── Too small: show prompt ── */ /* ── Too small: show prompt ── */
@media (max-width: 1279px) { @media (max-width: 1023px) {
.agent-layout__body { .agent-layout__body {
display: none; display: none;
} }
@ -52,14 +52,17 @@
.agent-layout__small-screen h2 { .agent-layout__small-screen h2 {
font-size: var(--font-lg); font-size: var(--font-lg);
color: var(--text-primary); color: var(--text-primary);
font-weight: var(--font-weight-semibold);
} }
.agent-layout__small-screen p { .agent-layout__small-screen p {
font-size: var(--font-base); font-size: var(--font-base);
max-width: 400px; max-width: 400px;
color: var(--text-tertiary);
line-height: var(--leading-relaxed);
} }
/* ── Quadrant min-size for readability ── */ /* ── Panel min-size for readability ── */
.quadrant-panel { .quadrant-panel {
min-width: var(--quadrant-min-size); min-width: var(--quadrant-min-size);
min-height: var(--quadrant-min-size); min-height: var(--quadrant-min-size);

View File

@ -1,5 +1,5 @@
/** /**
* Ant Design Vue Theme Token Mapping * Ant Design Vue Theme Token Mapping Notion-Inspired Light Theme
* *
* Reads CSS custom properties at runtime from tokens.css to ensure * Reads CSS custom properties at runtime from tokens.css to ensure
* single source of truth. Falls back to hardcoded values if CSS * single source of truth. Falls back to hardcoded values if CSS
@ -17,28 +17,28 @@ function readToken(varName: string, fallback: string): string {
export const themeConfig: ThemeConfig = { export const themeConfig: ThemeConfig = {
token: { token: {
// Brand — read from CSS variables // Brand — read from CSS variables
colorPrimary: readToken('--color-primary', '#7c3aed'), colorPrimary: readToken('--color-primary', '#6366f1'),
colorInfo: readToken('--color-primary', '#7c3aed'), colorInfo: readToken('--color-primary', '#6366f1'),
// Semantic // Semantic
colorSuccess: readToken('--color-success', '#10b981'), colorSuccess: readToken('--color-success', '#22c55e'),
colorWarning: readToken('--color-warning', '#f59e0b'), colorWarning: readToken('--color-warning', '#f59e0b'),
colorError: readToken('--color-error', '#ef4444'), colorError: readToken('--color-error', '#ef4444'),
// Text // Text
colorText: readToken('--text-primary', '#171717'), colorText: readToken('--text-primary', '#1a1a1a'),
colorTextSecondary: readToken('--text-secondary', '#525252'), colorTextSecondary: readToken('--text-secondary', '#4a4a4a'),
colorTextTertiary: readToken('--text-tertiary', '#737373'), colorTextTertiary: readToken('--text-tertiary', '#6b6b6a'),
colorTextQuaternary: readToken('--text-placeholder', '#a3a3a3'), colorTextQuaternary: readToken('--text-placeholder', '#9b9b9a'),
// Background // Background
colorBgContainer: readToken('--bg-primary', '#ffffff'), colorBgContainer: readToken('--bg-primary', '#ffffff'),
colorBgLayout: readToken('--bg-secondary', '#fafafa'), colorBgLayout: readToken('--bg-secondary', '#fbfbfa'),
colorBgElevated: readToken('--bg-primary', '#ffffff'), colorBgElevated: readToken('--bg-primary', '#ffffff'),
// Border // Border
colorBorder: readToken('--border-color', '#e5e5e5'), colorBorder: readToken('--border-color', '#ededec'),
colorBorderSecondary: readToken('--border-color-split', '#f0f0f0'), colorBorderSecondary: readToken('--border-color-split', '#f2f2f0'),
// Font // Font
fontSize: 14, fontSize: 14,
@ -46,7 +46,7 @@ export const themeConfig: ThemeConfig = {
fontSizeLG: 16, fontSizeLG: 16,
fontSizeXL: 20, fontSizeXL: 20,
// Radius // Radius — Notion-style slightly larger
borderRadius: 8, borderRadius: 8,
borderRadiusSM: 6, borderRadiusSM: 6,
borderRadiusLG: 12, borderRadiusLG: 12,
@ -59,9 +59,9 @@ export const themeConfig: ThemeConfig = {
marginLG: 24, marginLG: 24,
marginXL: 32, marginXL: 32,
// Shadow // Shadow — Notion-style softer shadows
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05)', boxShadowSecondary: '0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
// Control // Control
controlHeight: 32, controlHeight: 32,
@ -70,19 +70,32 @@ export const themeConfig: ThemeConfig = {
}, },
components: { components: {
Menu: { Menu: {
itemSelectedBg: readToken('--color-primary-light', '#ede9fe'), itemSelectedBg: readToken('--color-primary-light', '#eef2ff'),
itemSelectedColor: readToken('--color-primary', '#7c3aed'), itemSelectedColor: readToken('--color-primary', '#6366f1'),
itemHoverBg: '#f5f3ff', itemHoverBg: '#f5f3ff',
itemHoverColor: readToken('--color-primary', '#7c3aed'), itemHoverColor: readToken('--color-primary', '#6366f1'),
itemColor: readToken('--text-secondary', '#525252'), itemColor: readToken('--text-secondary', '#4a4a4a'),
} as Record<string, unknown>, } as Record<string, unknown>,
Tabs: { Tabs: {
itemSelectedColor: readToken('--color-primary', '#7c3aed'), itemSelectedColor: readToken('--color-primary', '#6366f1'),
itemHoverColor: readToken('--color-primary-hover', '#6d28d9'), itemHoverColor: readToken('--color-primary-hover', '#4f46e5'),
} as Record<string, unknown>, } as Record<string, unknown>,
Select: { Select: {
colorPrimary: readToken('--color-primary', '#7c3aed'), colorPrimary: readToken('--color-primary', '#6366f1'),
colorPrimaryHover: readToken('--color-primary-hover', '#6d28d9'), colorPrimaryHover: readToken('--color-primary-hover', '#4f46e5'),
} as Record<string, unknown>,
Button: {
borderRadius: 8,
controlHeight: 32,
} as Record<string, unknown>,
Card: {
borderRadiusLG: 10,
} as Record<string, unknown>,
Input: {
borderRadius: 8,
} as Record<string, unknown>,
Modal: {
borderRadiusLG: 12,
} as Record<string, unknown>, } as Record<string, unknown>,
}, },
} }

View File

@ -1,8 +1,9 @@
/** /**
* Fischer AgentKit Transition Animations * Fischer AgentKit Transition Animations Notion-Inspired
* *
* Unified transition classes for Vue <Transition> components. * Unified transition classes for Vue <Transition> components.
* All durations reference Design Token variables for consistency. * All durations reference Design Token variables for consistency.
* Notion-style: smoother, more subtle transitions.
*/ */
/* ── Fade ── */ /* ── Fade ── */
@ -24,7 +25,7 @@
.slide-up-enter-from, .slide-up-enter-from,
.slide-up-leave-to { .slide-up-leave-to {
transform: translateY(8px); transform: translateY(6px);
opacity: 0; opacity: 0;
} }
@ -36,7 +37,7 @@
.slide-down-enter-from, .slide-down-enter-from,
.slide-down-leave-to { .slide-down-leave-to {
transform: translateY(-8px); transform: translateY(-6px);
opacity: 0; opacity: 0;
} }
@ -48,7 +49,7 @@
.slide-right-enter-from, .slide-right-enter-from,
.slide-right-leave-to { .slide-right-leave-to {
transform: translateX(-8px); transform: translateX(-6px);
opacity: 0; opacity: 0;
} }
@ -79,7 +80,7 @@
.scale-enter-from, .scale-enter-from,
.scale-leave-to { .scale-leave-to {
transform: scale(0.95); transform: scale(0.96);
opacity: 0; opacity: 0;
} }
@ -94,7 +95,7 @@
.stagger-list-enter-from, .stagger-list-enter-from,
.stagger-list-leave-to { .stagger-list-leave-to {
transform: translateY(8px); transform: translateY(6px);
opacity: 0; opacity: 0;
} }
@ -102,19 +103,19 @@
transition: transform var(--transition-normal); transition: transform var(--transition-normal);
} }
/* ── Skeleton pulse ── */ /* ── Skeleton pulse (Notion-style: warmer, subtler) ── */
@keyframes skeleton-pulse { @keyframes skeleton-pulse {
0% { opacity: 1; } 0% { opacity: 1; }
50% { opacity: 0.4; } 50% { opacity: 0.5; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
.skeleton-loading { .skeleton-loading {
animation: skeleton-pulse 1.5s ease-in-out infinite; animation: skeleton-pulse 2s ease-in-out infinite;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--bg-tertiary) 25%, var(--bg-tertiary) 25%,
var(--border-color) 50%, var(--border-color-split) 50%,
var(--bg-tertiary) 75% var(--bg-tertiary) 75%
); );
background-size: 200% 100%; background-size: 200% 100%;
@ -128,5 +129,15 @@
} }
.pulse-dot { .pulse-dot {
animation: pulse-dot 1.5s ease-in-out infinite; animation: pulse-dot 2s ease-in-out infinite;
}
/* ── Gentle bounce for interactive elements ── */
@keyframes gentle-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.gentle-bounce {
animation: gentle-bounce 0.3s ease;
} }

View File

@ -18,9 +18,19 @@
<template v-else> <template v-else>
<div class="chat-view__messages" ref="messagesContainer"> <div class="chat-view__messages" ref="messagesContainer">
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome"> <div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
<div class="chat-view__welcome-inner">
<div class="chat-view__welcome-logo">
<RobotOutlined class="chat-view__welcome-icon" /> <RobotOutlined class="chat-view__welcome-icon" />
<h2>Fischer AgentKit</h2> </div>
<p>企业级 AI 智能体平台输入消息开始对话</p> <h2 class="chat-view__welcome-title">Fischer AgentKit</h2>
<p class="chat-view__welcome-subtitle">企业级 AI 智能体平台</p>
<div class="chat-view__welcome-hints">
<div class="chat-view__hint" v-for="hint in welcomeHints" :key="hint">
<ThunderboltOutlined class="chat-view__hint-icon" />
<span>{{ hint }}</span>
</div>
</div>
</div>
</div> </div>
<ChatMessage <ChatMessage
v-for="msg in chatStore.currentMessages" v-for="msg in chatStore.currentMessages"
@ -55,6 +65,7 @@ import {
RobotOutlined, RobotOutlined,
LoadingOutlined, LoadingOutlined,
RightOutlined, RightOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import ChatSidebar from '@/components/chat/ChatSidebar.vue' import ChatSidebar from '@/components/chat/ChatSidebar.vue'
@ -66,6 +77,12 @@ const ATypographyText = ATypography.Text
const chatStore = useChatStore() const chatStore = useChatStore()
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
const welcomeHints = [
'智能路由 — 自动匹配最优技能',
'工具调用 — 读写文件、执行命令',
'流式响应 — 实时查看推理过程',
]
onMounted(async () => { onMounted(async () => {
await chatStore.loadConversations() await chatStore.loadConversations()
chatStore.connectWebSocket() chatStore.connectWebSocket()
@ -150,32 +167,95 @@ function handleSend(message: string): void {
color: var(--text-placeholder); color: var(--text-placeholder);
} }
.chat-view__welcome-icon { .chat-view__welcome-inner {
font-size: 48px; display: flex;
background: var(--gradient-brand); flex-direction: column;
-webkit-background-clip: text; align-items: center;
-webkit-text-fill-color: transparent; gap: var(--space-3);
background-clip: text; animation: welcomeFadeIn 0.6s ease-out;
margin-bottom: var(--space-4);
} }
.chat-view__welcome h2 { @keyframes welcomeFadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-view__welcome-logo {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-brand);
border-radius: var(--radius-xl);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.25);
animation: welcomeLogoIn 0.5s ease-out;
}
@keyframes welcomeLogoIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.chat-view__welcome-icon {
font-size: 32px;
color: var(--text-inverse) !important;
-webkit-background-clip: unset;
-webkit-text-fill-color: var(--text-inverse) !important;
background-clip: unset;
}
.chat-view__welcome-title {
color: var(--text-primary); color: var(--text-primary);
font-size: var(--font-xl); font-size: var(--font-xl);
margin-bottom: var(--space-2); font-weight: var(--font-weight-bold);
margin: 0;
animation: welcomeFadeIn 0.6s ease-out 0.1s both;
} }
.chat-view__welcome p { .chat-view__welcome-subtitle {
font-size: var(--font-base); font-size: var(--font-base);
color: var(--text-tertiary); color: var(--text-tertiary);
margin: 0;
animation: welcomeFadeIn 0.6s ease-out 0.2s both;
}
.chat-view__welcome-hints {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.chat-view__hint {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
color: var(--text-tertiary);
padding: var(--space-2) var(--space-3);
background: var(--bg-primary);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
animation: welcomeFadeIn 0.5s ease-out both;
}
.chat-view__hint:nth-child(1) { animation-delay: 0.3s; }
.chat-view__hint:nth-child(2) { animation-delay: 0.4s; }
.chat-view__hint:nth-child(3) { animation-delay: 0.5s; }
.chat-view__hint-icon {
font-size: 14px;
color: var(--color-primary);
} }
.chat-view__steps { .chat-view__steps {
padding: var(--space-2) var(--space-4); padding: var(--space-3) var(--space-4);
margin: 0 var(--space-4); margin: 0 var(--space-4);
background: var(--bg-primary); background: var(--bg-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
} }
.chat-view__step { .chat-view__step {

View File

@ -44,12 +44,28 @@ onUnmounted(() => {
.evolution-container { .evolution-container {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding: var(--space-3) var(--space-4); padding: var(--space-4) var(--space-5);
background: var(--bg-primary); background: var(--bg-primary);
} }
.evolution-tabs { .evolution-tabs {
height: 100%; height: 100%;
display: flex;
flex-direction: column;
}
/* Make Ant Design Vue tabs fill available height */
.evolution-tabs :deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
}
.evolution-tabs :deep(.ant-tabs-content) {
height: 100%;
}
.evolution-tabs :deep(.ant-tabs-tabpane) {
height: 100%;
} }
.evolution-panels { .evolution-panels {

View File

@ -157,7 +157,7 @@ async function handleSave(): Promise<void> {
<style scoped> <style scoped>
.settings-view { .settings-view {
height: 100%; height: 100%;
padding: var(--space-4) var(--space-6); padding: var(--space-5) var(--space-6);
overflow-y: auto; overflow-y: auto;
background: var(--bg-primary); background: var(--bg-primary);
} }
@ -168,6 +168,7 @@ async function handleSave(): Promise<void> {
.settings-form { .settings-form {
max-width: 600px; max-width: 600px;
padding-top: var(--space-2);
} }
.settings-form__hint { .settings-form__hint {

View File

@ -142,7 +142,7 @@ async function handleInstall(): Promise<void> {
<style scoped> <style scoped>
.skills-view { .skills-view {
height: 100%; height: 100%;
padding: var(--space-4) var(--space-6); padding: var(--space-5) var(--space-6);
overflow-y: auto; overflow-y: auto;
background: var(--bg-primary); background: var(--bg-primary);
} }
@ -151,7 +151,7 @@ async function handleInstall(): Promise<void> {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--space-4); margin-bottom: var(--space-5);
} }
.skills-view__grid { .skills-view__grid {

View File

@ -351,6 +351,14 @@ function handleBack() {
.list-body--empty { .list-body--empty {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.list-body--empty :deep(.ant-empty) {
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }

View File

@ -13,6 +13,7 @@ from pydantic import BaseModel
from agentkit.core.protocol import TaskMessage from agentkit.core.protocol import TaskMessage
from agentkit.core.react import ReActEngine from agentkit.core.react import ReActEngine
from agentkit.chat.skill_routing import ExecutionMode
from agentkit.router.intent import IntentRouter from agentkit.router.intent import IntentRouter
from agentkit.server.routes.evolution_dashboard import ( from agentkit.server.routes.evolution_dashboard import (
_experiences as _dashboard_experiences, _experiences as _dashboard_experiences,
@ -339,15 +340,20 @@ async def chat_stream(request: ChatRequest, req: Request, _auth: None = Depends(
async def event_generator(): async def event_generator():
react_config = agent.get_react_config() react_config = agent.get_react_config()
# Reuse agent's ReActEngine if available (aligned with chat.py pattern)
react_engine = getattr(agent, "_react_engine", None)
if react_engine is None:
react_engine = ReActEngine( react_engine = ReActEngine(
llm_gateway=req.app.state.llm_gateway, llm_gateway=req.app.state.llm_gateway,
max_steps=react_config["max_steps"], max_steps=react_config["max_steps"],
) )
else:
react_engine.reset()
messages = [{"role": "user", "content": request.message}] messages = [{"role": "user", "content": request.message}]
tools = agent.get_tools() tools = agent.get_tools()
model = agent.get_model() model = agent.get_model()
system_prompt = agent.get_system_prompt() system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
timeout_seconds = react_config["timeout_seconds"] timeout_seconds = react_config["timeout_seconds"]
# Send routing info as first event # Send routing info as first event
@ -439,6 +445,7 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
return [ return [
{ {
"id": c.id, "id": c.id,
"title": _derive_conversation_title(c),
"created_at": c.created_at.isoformat(), "created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(), "updated_at": c.updated_at.isoformat(),
"message_count": len(c.messages), "message_count": len(c.messages),
@ -447,6 +454,14 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
] ]
def _derive_conversation_title(conv: Conversation) -> str:
"""Derive a human-readable title from the first user message."""
for msg in conv.messages:
if msg.role == "user" and msg.content:
return msg.content[:20] + ("..." if len(msg.content) > 20 else "")
return "对话"
@router.get("/portal/conversations/{conversation_id}") @router.get("/portal/conversations/{conversation_id}")
async def get_conversation(conversation_id: str, limit: int = 50, _auth: None = Depends(_verify_api_key)): async def get_conversation(conversation_id: str, limit: int = 50, _auth: None = Depends(_verify_api_key)):
"""Get conversation history.""" """Get conversation history."""
@ -564,14 +579,15 @@ async def portal_websocket(websocket: WebSocket):
default_agent = pool.get_agent("default") default_agent = pool.get_agent("default")
if default_agent is not None: if default_agent is not None:
default_tools = default_agent.get_tools() default_tools = default_agent.get_tools()
default_system_prompt = default_agent.get_system_prompt() # Prefer _system_prompt (memory-injected) over get_system_prompt() (template)
default_system_prompt = getattr(default_agent, "_system_prompt", None) or default_agent.get_system_prompt()
else: else:
# Fallback to first available skill's tools # Fallback to first available skill's tools
for skill in all_skills: for skill in all_skills:
agent = pool.get_agent(skill.name) agent = pool.get_agent(skill.name)
if agent is not None: if agent is not None:
default_tools = agent.get_tools() default_tools = agent.get_tools()
default_system_prompt = agent.get_system_prompt() default_system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
break break
# Route via CostAwareRouter (Layer 0/1/2) # Route via CostAwareRouter (Layer 0/1/2)
@ -594,16 +610,22 @@ async def portal_websocket(websocket: WebSocket):
"confidence": routing_result.match_confidence, "confidence": routing_result.match_confidence,
}) })
# Execute based on routing method # Execute based on routing result's execution_mode
if routing_result.match_method in ("greeting", "chat_mode"): # This is the single source of truth for path selection,
# replacing fragile string-matching on match_method.
if routing_result.execution_mode == ExecutionMode.DIRECT_CHAT:
# Zero-cost path: direct LLM call, no ReAct loop # Zero-cost path: direct LLM call, no ReAct loop
chat_messages = [{"role": "user", "content": message_text}] chat_messages = []
# Inject system prompt (contains SOUL/USER/MEMORY/DAILY) for identity continuity
if routing_result.system_prompt:
chat_messages.append({"role": "system", "content": routing_result.system_prompt})
chat_messages.append({"role": "user", "content": message_text})
# Inject conversation history for context continuity # Inject conversation history for context continuity
try: try:
history = _conversation_store.get_history(conv.id, limit=20) history = _conversation_store.get_history(conv.id, limit=20)
for hist_msg in history[:-1]: # skip the last (current user msg) for hist_msg in history[:-1]: # skip the last (current user msg)
if hist_msg.role in ("user", "assistant"): if hist_msg.role in ("user", "assistant"):
chat_messages.insert(0, {"role": hist_msg.role, "content": hist_msg.content}) chat_messages.insert(-1, {"role": hist_msg.role, "content": hist_msg.content})
except Exception: except Exception:
pass pass
response = await llm_gateway.chat( response = await llm_gateway.chat(
@ -619,18 +641,25 @@ async def portal_websocket(websocket: WebSocket):
await _record_experience("chat", message_text, "success", (datetime.now(timezone.utc) - start_time).total_seconds()) await _record_experience("chat", message_text, "success", (datetime.now(timezone.utc) - start_time).total_seconds())
continue continue
# General path: agent execution # REACT or SKILL_REACT: agent execution
agent_name = routing_result.agent_name or "default" agent_name = routing_result.agent_name or "default"
agent = pool.get_agent(agent_name) agent = pool.get_agent(agent_name)
if agent is None: if agent is None:
if not all_skills: # Agent not in pool — fall back to direct chat.
# No skills registered — fallback to direct chat # This handles the case where routing returned an agent_name
chat_messages = [{"role": "user", "content": message_text}] # that doesn't exist in the pool (e.g. "default" or a
# skill that hasn't been instantiated yet).
logger.info(f"Session {conv.id}: agent '{agent_name}' not in pool, falling back to direct chat")
chat_messages = []
# Inject system prompt (contains SOUL/USER/MEMORY/DAILY) for identity continuity
if routing_result.system_prompt:
chat_messages.append({"role": "system", "content": routing_result.system_prompt})
chat_messages.append({"role": "user", "content": message_text})
try: try:
history = _conversation_store.get_history(conv.id, limit=20) history = _conversation_store.get_history(conv.id, limit=20)
for hist_msg in history[:-1]: for hist_msg in history[:-1]:
if hist_msg.role in ("user", "assistant"): if hist_msg.role in ("user", "assistant"):
chat_messages.insert(0, {"role": hist_msg.role, "content": hist_msg.content}) chat_messages.insert(-1, {"role": hist_msg.role, "content": hist_msg.content})
except Exception: except Exception:
pass pass
response = await llm_gateway.chat( response = await llm_gateway.chat(
@ -645,14 +674,18 @@ async def portal_websocket(websocket: WebSocket):
}) })
await _record_experience("chat", message_text, "success", (datetime.now(timezone.utc) - start_time).total_seconds()) await _record_experience("chat", message_text, "success", (datetime.now(timezone.utc) - start_time).total_seconds())
continue continue
agent = await pool.create_agent_from_skill(agent_name)
# Execute via ReAct stream # Execute via ReAct stream
react_config = agent.get_react_config() react_config = agent.get_react_config()
# Reuse agent's ReActEngine if available (aligned with chat.py pattern)
react_engine = getattr(agent, "_react_engine", None)
if react_engine is None:
react_engine = ReActEngine( react_engine = ReActEngine(
llm_gateway=llm_gateway, llm_gateway=llm_gateway,
max_steps=react_config["max_steps"], max_steps=react_config["max_steps"],
) )
else:
react_engine.reset()
messages = [{"role": "user", "content": message_text}] messages = [{"role": "user", "content": message_text}]
# Inject conversation history for context continuity # Inject conversation history for context continuity
@ -666,7 +699,7 @@ async def portal_websocket(websocket: WebSocket):
pass pass
tools = agent.get_tools() tools = agent.get_tools()
model = agent.get_model() model = agent.get_model()
system_prompt = agent.get_system_prompt() system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
timeout_seconds = react_config["timeout_seconds"] timeout_seconds = react_config["timeout_seconds"]
logger.info( logger.info(
f"[portal] agent='{agent_name}' tools={len(tools)} " f"[portal] agent='{agent_name}' tools={len(tools)} "

View File

@ -167,9 +167,10 @@ async def mention_suggest(q: str = "", req: Request = None):
Returns up to 8 skills matching the query string, with name and description Returns up to 8 skills matching the query string, with name and description
for the MentionDropdown component. for the MentionDropdown component.
""" """
# Limit query length to prevent abuse
query = q[:100].lower()
skill_registry = req.app.state.skill_registry skill_registry = req.app.state.skill_registry
skills = skill_registry.list_skills() skills = skill_registry.list_skills()
query = q.lower()
if query: if query:
skills = [ skills = [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{c as i,I as u}from"./index-CMOUF6MJ.js";var l={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z"}}]},name:"appstore",theme:"outlined"};function a(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(c){return Object.getOwnPropertyDescriptor(e,c).enumerable}))),n.forEach(function(c){p(r,c,e[c])})}return r}function p(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=a({},t,e.attrs);return i(u,a({},n,{icon:l}),null)};o.displayName="AppstoreOutlined";o.inheritAttrs=!1;export{o as A};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{d as E,r as f,i as V,z as o,R,c as h,J as W,P as z}from"./index-CMOUF6MJ.js";import{i as F}from"./_plugin-vue_export-helper-BXCjjis4.js";var I=function(t,l){var c={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&l.indexOf(n)<0&&(c[n]=t[n]);if(t!=null&&typeof Object.getOwnPropertySymbols=="function")for(var a=0,n=Object.getOwnPropertySymbols(t);a<n.length;a++)l.indexOf(n[a])<0&&Object.prototype.propertyIsEnumerable.call(t,n[a])&&(c[n[a]]=t[n[a]]);return c};const J={prefixCls:String,name:String,id:String,type:String,defaultChecked:{type:[Boolean,Number],default:void 0},checked:{type:[Boolean,Number],default:void 0},disabled:Boolean,tabindex:{type:[Number,String]},readonly:Boolean,autofocus:Boolean,value:z.any,required:Boolean},G=E({compatConfig:{MODE:3},name:"Checkbox",inheritAttrs:!1,props:F(J,{prefixCls:"rc-checkbox",type:"checkbox",defaultChecked:!1}),emits:["click","change"],setup(t,l){let{attrs:c,emit:n,expose:a}=l;const d=f(t.checked===void 0?t.defaultChecked:t.checked),s=f();V(()=>t.checked,()=>{d.value=t.checked}),a({focus(){var e;(e=s.value)===null||e===void 0||e.focus()},blur(){var e;(e=s.value)===null||e===void 0||e.blur()}});const i=f(),y=e=>{if(t.disabled)return;t.checked===void 0&&(d.value=e.target.checked),e.shiftKey=i.value;const u={target:o(o({},t),{checked:e.target.checked}),stopPropagation(){e.stopPropagation()},preventDefault(){e.preventDefault()},nativeEvent:e};t.checked!==void 0&&(s.value.checked=!!t.checked),n("change",u),i.value=!1},k=e=>{n("click",e),i.value=e.shiftKey};return()=>{const{prefixCls:e,name:u,id:g,type:m,disabled:b,readonly:x,tabindex:C,autofocus:O,value:P,required:S}=t,_=I(t,["prefixCls","name","id","type","disabled","readonly","tabindex","autofocus","value","required"]),{class:j,onFocus:B,onBlur:K,onKeydown:N,onKeypress:w,onKeyup:D}=c,v=o(o({},_),c),$=Object.keys(v).reduce((p,r)=>((r.startsWith("data-")||r.startsWith("aria-")||r==="role")&&(p[r]=v[r]),p),{}),q=R(e,j,{[`${e}-checked`]:d.value,[`${e}-disabled`]:b}),A=o(o({name:u,id:g,type:m,readonly:x,disabled:b,tabindex:C,class:`${e}-input`,checked:!!d.value,autofocus:O,value:P},$),{onChange:y,onClick:k,onFocus:B,onBlur:K,onKeydown:N,onKeypress:w,onKeyup:D,required:S});return h("span",{class:q},[h("input",W({ref:s},A),null),h("span",{class:`${e}-inner`},null)])}}});export{G as V};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{c as i,I as u}from"./index-CMOUF6MJ.js";var l={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M928 140H96c-17.7 0-32 14.3-32 32v496c0 17.7 14.3 32 32 32h380v112H304c-8.8 0-16 7.2-16 16v48c0 4.4 3.6 8 8 8h432c4.4 0 8-3.6 8-8v-48c0-8.8-7.2-16-16-16H548V700h380c17.7 0 32-14.3 32-32V172c0-17.7-14.3-32-32-32zm-40 488H136V212h752v416z"}}]},name:"desktop",theme:"outlined"};function c(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){s(r,a,e[a])})}return r}function s(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=c({},t,e.attrs);return i(u,c({},n,{icon:l}),null)};o.displayName="DesktopOutlined";o.inheritAttrs=!1;export{o as D};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{u}from"./responsiveObserve-Dor1RzIw.js";import{E as s,a2 as i,K as c,L as f,c as p,I as v}from"./index-CMOUF6MJ.js";const h=e=>({color:e.colorLink,textDecoration:"none",outline:"none",cursor:"pointer",transition:`color ${e.motionDurationSlow}`,"&:focus, &:hover":{color:e.colorLinkHover},"&:active":{color:e.colorLinkActive}});function g(){const e=c({});let t=null;const r=u();return s(()=>{t=r.value.subscribe(n=>{e.value=n})}),i(()=>{r.value.unsubscribe(t)}),e}function H(e){const t=c();return f(()=>{t.value=e()},{flush:"sync"}),t}var d={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M928 444H820V330.4c0-17.7-14.3-32-32-32H473L355.7 186.2a8.15 8.15 0 00-5.5-2.2H96c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h698c13 0 24.8-7.9 29.7-20l134-332c1.5-3.8 2.3-7.9 2.3-12 0-17.7-14.3-32-32-32zM136 256h188.5l119.6 114.4H748V444H238c-13 0-24.8 7.9-29.7 20L136 643.2V256zm635.3 512H159l103.3-256h612.4L771.3 768z"}}]},name:"folder-open",theme:"outlined"};function a(e){for(var t=1;t<arguments.length;t++){var r=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(r);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(r).filter(function(o){return Object.getOwnPropertyDescriptor(r,o).enumerable}))),n.forEach(function(o){O(e,o,r[o])})}return e}function O(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var l=function(t,r){var n=a({},t,r.attrs);return p(v,a({},n,{icon:d}),null)};l.displayName="FolderOpenOutlined";l.inheritAttrs=!1;export{l as F,H as e,h as o,g as u};

View File

@ -1 +0,0 @@
import{z as I,T as p,B as i,d as c,ab as C,i as f,f as F,r as x,C as a}from"./index-CMOUF6MJ.js";import{e as y}from"./index-kndtlfwZ.js";function w(n,o){const e=I({},n);for(let t=0;t<o.length;t+=1){const l=o[t];delete e[l]}return e}const r=Symbol("ContextProps"),s=Symbol("InternalContextProps"),S=function(n){let o=arguments.length>1&&arguments[1]!==void 0?arguments[1]:F(()=>!0);const e=x(new Map),t=(m,v)=>{e.value.set(m,v),e.value=new Map(e.value)},l=m=>{e.value.delete(m),e.value=new Map(e.value)};f([o,e],()=>{}),a(r,n),a(s,{addFormItemField:t,removeFormItemField:l})},d={id:F(()=>{}),onFieldBlur:()=>{},onFieldChange:()=>{},clearValidate:()=>{}},u={addFormItemField:()=>{},removeFormItemField:()=>{}},_=()=>{const n=i(s,u),o=Symbol("FormItemFieldKey"),e=C();return n.addFormItemField(o,e.type),p(()=>{n.removeFormItemField(o)}),a(s,u),a(r,d),i(r,d)},K=c({compatConfig:{MODE:3},name:"AFormItemRest",setup(n,o){let{slots:e}=o;return a(s,u),a(r,d),()=>{var t;return(t=e.default)===null||t===void 0?void 0:t.call(e)}}}),g=y({}),M=c({name:"NoFormStatus",setup(n,o){let{slots:e}=o;return g.useProvide({}),()=>{var t;return(t=e.default)===null||t===void 0?void 0:t.call(e)}}});export{g as F,M as N,S as a,K as b,w as o,_ as u};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{c,I as u}from"./index-CMOUF6MJ.js";var s={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"}}]},name:"right",theme:"outlined"};function i(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){O(r,a,e[a])})}return r}function O(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var f=function(t,e){var n=i({},t,e.attrs);return c(u,i({},n,{icon:s}),null)};f.displayName="RightOutlined";f.inheritAttrs=!1;var g={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"}}]},name:"left",theme:"outlined"};function l(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){p(r,a,e[a])})}return r}function p(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=l({},t,e.attrs);return c(u,l({},n,{icon:g}),null)};o.displayName="LeftOutlined";o.inheritAttrs=!1;export{o as L,f as R};

View File

@ -1 +0,0 @@
import{c as i,I as c}from"./index-CMOUF6MJ.js";var o={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z"}},{tag:"path",attrs:{d:"M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z"}}]},name:"plus",theme:"outlined"};function u(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){s(r,a,e[a])})}return r}function s(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var l=function(t,e){var n=u({},t,e.attrs);return i(c,u({},n,{icon:o}),null)};l.displayName="PlusOutlined";l.inheritAttrs=!1;export{l as P};

View File

@ -1 +0,0 @@
import{c as l,I as c}from"./index-CMOUF6MJ.js";var p={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M908 640H804V488c0-4.4-3.6-8-8-8H548v-96h108c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16H368c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h108v96H228c-4.4 0-8 3.6-8 8v152H116c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16H292v-88h440v88H620c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16zm-564 76v168H176V716h168zm84-408V140h168v168H428zm420 576H680V716h168v168z"}}]},name:"apartment",theme:"outlined"};function i(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){v(r,a,e[a])})}return r}function v(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var f=function(t,e){var n=i({},t,e.attrs);return l(c,i({},n,{icon:p}),null)};f.displayName="ApartmentOutlined";f.inheritAttrs=!1;var O={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z"}}]},name:"book",theme:"outlined"};function u(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){g(r,a,e[a])})}return r}function g(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var s=function(t,e){var n=u({},t,e.attrs);return l(c,u({},n,{icon:O}),null)};s.displayName="BookOutlined";s.inheritAttrs=!1;var b={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56a32.03 32.03 0 009.3-35.2l-.9-2.6a443.74 443.74 0 00-79.7-137.9l-1.8-2.1a32.12 32.12 0 00-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85a32.05 32.05 0 00-25.8-25.7l-2.7-.5c-52.1-9.4-106.9-9.4-159 0l-2.7.5a32.05 32.05 0 00-25.8 25.7l-15.8 85.4a351.86 351.86 0 00-99 57.4l-81.9-29.1a32 32 0 00-35.1 9.5l-1.8 2.1a446.02 446.02 0 00-79.7 137.9l-.9 2.6c-4.5 12.5-.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5a32.03 32.03 0 00-9.3 35.2l.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1a32.12 32.12 0 0035.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4a32.05 32.05 0 0025.8 25.7l2.7.5a449.4 449.4 0 00159 0l2.7-.5a32.05 32.05 0 0025.8-25.7l15.7-85a350 350 0 0099.7-57.6l81.3 28.9a32 32 0 0035.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l.9-2.6c4.5-12.3.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9a370.03 370.03 0 01-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97a377.5 377.5 0 01-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9zM512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm79.2 255.2A111.6 111.6 0 01512 614c-29.9 0-58-11.7-79.2-32.8A111.6 111.6 0 01400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8A111.6 111.6 0 01624 502c0 29.9-11.7 58-32.8 79.2z"}}]},name:"setting",theme:"outlined"};function o(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){d(r,a,e[a])})}return r}function d(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var m=function(t,e){var n=o({},t,e.attrs);return l(c,o({},n,{icon:b}),null)};m.displayName="SettingOutlined";m.inheritAttrs=!1;export{f as A,s as B,m as S};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
.settings-view[data-v-69defdaa]{height:100%;padding:var(--space-4) var(--space-6);overflow-y:auto;background:var(--bg-primary)}.settings-tabs[data-v-69defdaa]{height:100%}.settings-form[data-v-69defdaa]{max-width:600px}.settings-form__hint[data-v-69defdaa]{margin-left:var(--space-2);color:var(--text-tertiary);font-size:var(--font-sm)}.settings-view__alert[data-v-69defdaa]{margin-top:var(--space-3);max-width:600px}

View File

@ -1 +0,0 @@
.skill-card[data-v-f7a7f9c2]{cursor:pointer}.skill-card__title[data-v-f7a7f9c2]{display:flex;align-items:center;gap:6px}.skill-card__icon[data-v-f7a7f9c2]{color:var(--color-primary)}.skill-card__desc[data-v-f7a7f9c2]{font-size:13px;color:var(--text-secondary);margin-bottom:8px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.skill-card__tags[data-v-f7a7f9c2]{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}.skill-card__deps[data-v-f7a7f9c2]{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.skill-card__deps-label[data-v-f7a7f9c2],.skill-card__more[data-v-f7a7f9c2]{font-size:12px;color:var(--text-placeholder)}.skill-card__footer[data-v-f7a7f9c2]{margin-top:8px;display:flex;justify-content:flex-end}.skill-card__version[data-v-f7a7f9c2]{font-size:12px;color:var(--text-placeholder)}.skill-detail__tags[data-v-3ddcc970]{display:flex;flex-wrap:wrap;gap:6px}.skill-detail__empty[data-v-3ddcc970]{color:var(--text-placeholder);font-size:13px}.skill-detail__config[data-v-3ddcc970]{background:var(--bg-tertiary);border-radius:6px;padding:12px;overflow-x:auto}.skill-detail__config pre[data-v-3ddcc970]{margin:0;font-size:12px;line-height:1.5}.skill-detail__actions[data-v-3ddcc970]{margin-top:24px;display:flex;gap:12px}.skills-view[data-v-50d34770]{height:100%;padding:var(--space-4) var(--space-6);overflow-y:auto;background:var(--bg-primary)}.skills-view__header[data-v-50d34770]{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4)}.skills-view__grid[data-v-50d34770]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{c as u,I as i}from"./index-CMOUF6MJ.js";var s={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7zM378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211L378.2 732.5z"}}]},name:"thunderbolt",theme:"outlined"};function c(r){for(var e=1;e<arguments.length;e++){var t=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(t);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(t).filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable}))),n.forEach(function(a){d(r,a,t[a])})}return r}function d(r,e,t){return e in r?Object.defineProperty(r,e,{value:t,enumerable:!0,configurable:!0,writable:!0}):r[e]=t,r}var o=function(e,t){var n=c({},e,t.attrs);return u(i,c({},n,{icon:s}),null)};o.displayName="ThunderboltOutlined";o.inheritAttrs=!1;var b={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M858.5 763.6a374 374 0 00-80.6-119.5 375.63 375.63 0 00-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 00-80.6 119.5A371.7 371.7 0 00136 901.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 008-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"}}]},name:"user",theme:"outlined"};function l(r){for(var e=1;e<arguments.length;e++){var t=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(t);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(t).filter(function(a){return Object.getOwnPropertyDescriptor(t,a).enumerable}))),n.forEach(function(a){O(r,a,t[a])})}return r}function O(r,e,t){return e in r?Object.defineProperty(r,e,{value:t,enumerable:!0,configurable:!0,writable:!0}):r[e]=t,r}var f=function(e,t){var n=l({},e,t.attrs);return u(i,l({},n,{icon:b}),null)};f.displayName="UserOutlined";f.inheritAttrs=!1;export{o as T,f as U};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{B as t}from"./base-Cow5GIMt.js";const s="/api/v1/portal";class r extends t{constructor(e=s){super(e)}async chat(e){return this.request("/chat",{method:"POST",body:JSON.stringify(e)})}async getCapabilities(){return this.request("/capabilities")}async getConversations(){return this.request("/conversations")}async getConversation(e){return this.request(`/conversations/${e}`)}createWebSocket(){return super.createWebSocket("/ws")}}const i=new r;export{i as a};

View File

@ -1 +0,0 @@
import{x as _,y as E,z as P,A as O,bn as j,d as z,D as F,c as d,J as b,f as m,R as D,L as X,F as U,a1 as J,P as B,aY as K,K as V}from"./index-CMOUF6MJ.js";import{W as Y}from"./index-kndtlfwZ.js";import{e as q,i as G,h as Q}from"./base-Cow5GIMt.js";const h=(o,t,l)=>{const r=j(l);return{[`${o.componentCls}-${t}`]:{color:o[`color${l}`],background:o[`color${r}Bg`],borderColor:o[`color${r}Border`],[`&${o.componentCls}-borderless`]:{borderColor:"transparent"}}}},Z=o=>q(o,(t,l)=>{let{textColor:r,lightBorderColor:a,lightColor:e,darkColor:c}=l;return{[`${o.componentCls}-${t}`]:{color:r,background:e,borderColor:a,"&-inverse":{color:o.colorTextLightSolid,background:c,borderColor:c},[`&${o.componentCls}-borderless`]:{borderColor:"transparent"}}}}),oo=o=>{const{paddingXXS:t,lineWidth:l,tagPaddingHorizontal:r,componentCls:a}=o,e=r-l,c=t-l;return{[a]:P(P({},O(o)),{display:"inline-block",height:"auto",marginInlineEnd:o.marginXS,paddingInline:e,fontSize:o.tagFontSize,lineHeight:`${o.tagLineHeight}px`,whiteSpace:"nowrap",background:o.tagDefaultBg,border:`${o.lineWidth}px ${o.lineType} ${o.colorBorder}`,borderRadius:o.borderRadiusSM,opacity:1,transition:`all ${o.motionDurationMid}`,textAlign:"start",[`&${a}-rtl`]:{direction:"rtl"},"&, a, a:hover":{color:o.tagDefaultColor},[`${a}-close-icon`]:{marginInlineStart:c,color:o.colorTextDescription,fontSize:o.tagIconSize,cursor:"pointer",transition:`all ${o.motionDurationMid}`,"&:hover":{color:o.colorTextHeading}},[`&${a}-has-color`]:{borderColor:"transparent",[`&, a, a:hover, ${o.iconCls}-close, ${o.iconCls}-close:hover`]:{color:o.colorTextLightSolid}},"&-checkable":{backgroundColor:"transparent",borderColor:"transparent",cursor:"pointer",[`&:not(${a}-checkable-checked):hover`]:{color:o.colorPrimary,backgroundColor:o.colorFillSecondary},"&:active, &-checked":{color:o.colorTextLightSolid},"&-checked":{backgroundColor:o.colorPrimary,"&:hover":{backgroundColor:o.colorPrimaryHover}},"&:active":{backgroundColor:o.colorPrimaryActive}},"&-hidden":{display:"none"},[`> ${o.iconCls} + span, > span + ${o.iconCls}`]:{marginInlineStart:e}}),[`${a}-borderless`]:{borderColor:"transparent",background:o.tagBorderlessBg}}},H=_("Tag",o=>{const{fontSize:t,lineHeight:l,lineWidth:r,fontSizeIcon:a}=o,e=Math.round(t*l),c=o.fontSizeSM,g=e-r*2,C=o.colorFillAlter,i=o.colorText,n=E(o,{tagFontSize:c,tagLineHeight:g,tagDefaultBg:C,tagDefaultColor:i,tagIconSize:a-2*r,tagPaddingHorizontal:8,tagBorderlessBg:o.colorFillTertiary});return[oo(n),Z(n),h(n,"success","Success"),h(n,"processing","Info"),h(n,"error","Error"),h(n,"warning","Warning")]}),eo=()=>({prefixCls:String,checked:{type:Boolean,default:void 0},onChange:{type:Function},onClick:{type:Function},"onUpdate:checked":Function}),S=z({compatConfig:{MODE:3},name:"ACheckableTag",inheritAttrs:!1,props:eo(),setup(o,t){let{slots:l,emit:r,attrs:a}=t;const{prefixCls:e}=F("tag",o),[c,g]=H(e),C=n=>{const{checked:u}=o;r("update:checked",!u),r("change",!u),r("click",n)},i=m(()=>D(e.value,g.value,{[`${e.value}-checkable`]:!0,[`${e.value}-checkable-checked`]:o.checked}));return()=>{var n;return c(d("span",b(b({},a),{},{class:[i.value,a.class],onClick:C}),[(n=l.default)===null||n===void 0?void 0:n.call(l)]))}}}),lo=()=>({prefixCls:String,color:{type:String},closable:{type:Boolean,default:!1},closeIcon:B.any,visible:{type:Boolean,default:void 0},onClose:{type:Function},onClick:K(),"onUpdate:visible":Function,icon:B.any,bordered:{type:Boolean,default:!0}}),v=z({compatConfig:{MODE:3},name:"ATag",inheritAttrs:!1,props:lo(),slots:Object,setup(o,t){let{slots:l,emit:r,attrs:a}=t;const{prefixCls:e,direction:c}=F("tag",o),[g,C]=H(e),i=V(!0);X(()=>{o.visible!==void 0&&(i.value=o.visible)});const n=s=>{s.stopPropagation(),r("update:visible",!1),r("close",s),!s.defaultPrevented&&o.visible===void 0&&(i.value=!1)},u=m(()=>G(o.color)||Q(o.color)),A=m(()=>D(e.value,C.value,{[`${e.value}-${o.color}`]:u.value,[`${e.value}-has-color`]:o.color&&!u.value,[`${e.value}-hidden`]:!i.value,[`${e.value}-rtl`]:c.value==="rtl",[`${e.value}-borderless`]:!o.bordered})),M=s=>{r("click",s)};return()=>{var s,p,f;const{icon:w=(s=l.icon)===null||s===void 0?void 0:s.call(l),color:$,closeIcon:y=(p=l.closeIcon)===null||p===void 0?void 0:p.call(l),closable:W=!1}=o,k=()=>W?y?d("span",{class:`${e.value}-close-icon`,onClick:n},[y]):d(J,{class:`${e.value}-close-icon`,onClick:n},null):null,L={backgroundColor:$&&!u.value?$:void 0},T=w||null,x=(f=l.default)===null||f===void 0?void 0:f.call(l),N=T?d(U,null,[T,d("span",null,[x])]):x,R=o.onClick!==void 0,I=d("span",b(b({},a),{},{onClick:M,class:[A.value,a.class],style:[L,a.style]}),[N,k()]);return g(R?d(Y,null,{default:()=>[I]}):I)}}});v.CheckableTag=S;v.install=function(o){return o.component(v.name,v),o.component(S.name,S),o};export{v as T};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{d as p,E as w,ay as C,a2 as x,i as M,V as H,z as l,ab as W,Q as E}from"./index-CMOUF6MJ.js";import{z as y}from"./base-Cow5GIMt.js";const U=p({compatConfig:{MODE:3},name:"ResizeObserver",props:{disabled:Boolean,onResize:Function},emits:["resize"],setup(i,v){let{slots:c}=v;const n=E({width:0,height:0,offsetHeight:0,offsetWidth:0});let h=null,s=null;const r=()=>{s&&(s.disconnect(),s=null)},b=e=>{const{onResize:t}=i,o=e[0].target,{width:O,height:R}=o.getBoundingClientRect(),{offsetWidth:d,offsetHeight:f}=o,g=Math.floor(O),u=Math.floor(R);if(n.width!==g||n.height!==u||n.offsetWidth!==d||n.offsetHeight!==f){const m={width:g,height:u,offsetWidth:d,offsetHeight:f};l(n,m),t&&Promise.resolve().then(()=>{t(l(l({},m),{offsetWidth:d,offsetHeight:f}),o)})}},z=W(),a=()=>{const{disabled:e}=i;if(e){r();return}const t=H(z);t!==h&&(r(),h=t),!s&&t&&(s=new y(b),s.observe(t))};return w(()=>{a()}),C(()=>{a()}),x(()=>{r()}),M(()=>i.disabled,()=>{a()},{flush:"post"}),()=>{var e;return(e=c.default)===null||e===void 0?void 0:e.call(c)[0]}}});export{U as R};

View File

@ -1 +0,0 @@
import{d as k,D as J,i as L,aR as M,c as m,z as p,F as W,J as F,r as G,f as s,at as q,P as I,aA as R,R as H}from"./index-CMOUF6MJ.js";import{u as K}from"./index-Ck9xsg_d.js";import{a as Q,C as z}from"./index-kndtlfwZ.js";const U={small:8,middle:16,large:24},X=()=>({prefixCls:String,size:{type:[String,Number,Array]},direction:I.oneOf(R("horizontal","vertical")).def("horizontal"),align:I.oneOf(R("start","end","center","baseline")),wrap:q()});function Y(e){return typeof e=="string"?U[e]:e||0}const d=k({compatConfig:{MODE:3},name:"ASpace",inheritAttrs:!1,props:X(),slots:Object,setup(e,j){let{slots:o,attrs:f}=j;const{prefixCls:l,space:g,direction:x}=J("space",e),[B,D]=Q(l),h=K(),n=s(()=>{var a,t,i;return(i=(a=e.size)!==null&&a!==void 0?a:(t=g==null?void 0:g.value)===null||t===void 0?void 0:t.size)!==null&&i!==void 0?i:"small"}),y=G(),r=G();L(n,()=>{[y.value,r.value]=(Array.isArray(n.value)?n.value:[n.value,n.value]).map(a=>Y(a))},{immediate:!0});const C=s(()=>e.align===void 0&&e.direction==="horizontal"?"center":e.align),P=s(()=>H(l.value,D.value,`${l.value}-${e.direction}`,{[`${l.value}-rtl`]:x.value==="rtl",[`${l.value}-align-${C.value}`]:C.value})),E=s(()=>x.value==="rtl"?"marginLeft":"marginRight"),T=s(()=>{const a={};return h.value&&(a.columnGap=`${y.value}px`,a.rowGap=`${r.value}px`),p(p({},a),e.wrap&&{flexWrap:"wrap",marginBottom:`${-r.value}px`})});return()=>{var a,t;const{wrap:i,direction:V="horizontal"}=e,b=(a=o.default)===null||a===void 0?void 0:a.call(o),w=M(b),A=w.length;if(A===0)return null;const c=(t=o.split)===null||t===void 0?void 0:t.call(o),_=`${l.value}-item`,N=y.value,S=A-1;return m("div",F(F({},f),{},{class:[P.value,f.class],style:[T.value,f.style]}),[w.map((O,u)=>{let $=b.indexOf(O);$===-1&&($=`$$space-${u}`);let v={};return h.value||(V==="vertical"?u<S&&(v={marginBottom:`${N/(c?2:1)}px`}):v=p(p({},u<S&&{[E.value]:`${N/(c?2:1)}px`}),i&&{paddingBottom:`${r.value}px`})),B(m(W,{key:$},[m("div",{class:_,style:v},[O]),u<S&&c&&m("span",{class:`${_}-split`,style:v},[c])]))})])}}});d.Compact=z;d.install=function(e){return e.component(d.name,d),e.component(z.name,z),e};export{d as S};

View File

@ -1 +0,0 @@
import{x as b,y as w,z as s,A as z,aH as y,d as M,D as C,M as B,c as f,J as u,f as d}from"./index-CMOUF6MJ.js";const H=t=>{const{componentCls:e,sizePaddingEdgeHorizontal:o,colorSplit:r,lineWidth:i}=t;return{[e]:s(s({},z(t)),{borderBlockStart:`${i}px solid ${r}`,"&-vertical":{position:"relative",top:"-0.06em",display:"inline-block",height:"0.9em",margin:`0 ${t.dividerVerticalGutterMargin}px`,verticalAlign:"middle",borderTop:0,borderInlineStart:`${i}px solid ${r}`},"&-horizontal":{display:"flex",clear:"both",width:"100%",minWidth:"100%",margin:`${t.dividerHorizontalGutterMargin}px 0`},[`&-horizontal${e}-with-text`]:{display:"flex",alignItems:"center",margin:`${t.dividerHorizontalWithTextGutterMargin}px 0`,color:t.colorTextHeading,fontWeight:500,fontSize:t.fontSizeLG,whiteSpace:"nowrap",textAlign:"center",borderBlockStart:`0 ${r}`,"&::before, &::after":{position:"relative",width:"50%",borderBlockStart:`${i}px solid transparent`,borderBlockStartColor:"inherit",borderBlockEnd:0,transform:"translateY(50%)",content:"''"}},[`&-horizontal${e}-with-text-left`]:{"&::before":{width:"5%"},"&::after":{width:"95%"}},[`&-horizontal${e}-with-text-right`]:{"&::before":{width:"95%"},"&::after":{width:"5%"}},[`${e}-inner-text`]:{display:"inline-block",padding:"0 1em"},"&-dashed":{background:"none",borderColor:r,borderStyle:"dashed",borderWidth:`${i}px 0 0`},[`&-horizontal${e}-with-text${e}-dashed`]:{"&::before, &::after":{borderStyle:"dashed none none"}},[`&-vertical${e}-dashed`]:{borderInlineStartWidth:i,borderInlineEnd:0,borderBlockStart:0,borderBlockEnd:0},[`&-plain${e}-with-text`]:{color:t.colorText,fontWeight:"normal",fontSize:t.fontSize},[`&-horizontal${e}-with-text-left${e}-no-default-orientation-margin-left`]:{"&::before":{width:0},"&::after":{width:"100%"},[`${e}-inner-text`]:{paddingInlineStart:o}},[`&-horizontal${e}-with-text-right${e}-no-default-orientation-margin-right`]:{"&::before":{width:"100%"},"&::after":{width:0},[`${e}-inner-text`]:{paddingInlineEnd:o}}})}},I=b("Divider",t=>{const e=w(t,{dividerVerticalGutterMargin:t.marginXS,dividerHorizontalWithTextGutterMargin:t.margin,dividerHorizontalGutterMargin:t.marginLG});return[H(e)]},{sizePaddingEdgeHorizontal:0}),G=()=>({prefixCls:String,type:{type:String,default:"horizontal"},dashed:{type:Boolean,default:!1},orientation:{type:String,default:"center"},plain:{type:Boolean,default:!1},orientationMargin:[String,Number]}),W=M({name:"ADivider",inheritAttrs:!1,compatConfig:{MODE:3},props:G(),setup(t,e){let{slots:o,attrs:r}=e;const{prefixCls:i,direction:m}=C("divider",t),[v,h]=I(i),g=d(()=>t.orientation==="left"&&t.orientationMargin!=null),c=d(()=>t.orientation==="right"&&t.orientationMargin!=null),x=d(()=>{const{type:n,dashed:l,plain:S}=t,a=i.value;return{[a]:!0,[h.value]:!!h.value,[`${a}-${n}`]:!0,[`${a}-dashed`]:!!l,[`${a}-plain`]:!!S,[`${a}-rtl`]:m.value==="rtl",[`${a}-no-default-orientation-margin-left`]:g.value,[`${a}-no-default-orientation-margin-right`]:c.value}}),$=d(()=>{const n=typeof t.orientationMargin=="number"?`${t.orientationMargin}px`:t.orientationMargin;return s(s({},g.value&&{marginLeft:n}),c.value&&{marginRight:n})}),p=d(()=>t.orientation.length>0?"-"+t.orientation:t.orientation);return()=>{var n;const l=B((n=o.default)===null||n===void 0?void 0:n.call(o));return v(f("div",u(u({},r),{},{class:[x.value,l.length?`${i.value}-with-text ${i.value}-with-text${p.value}`:"",r.class],role:"separator"}),[l.length?f("span",{class:`${i.value}-inner-text`,style:$.value},[l]):null]))}}}),E=y(W);export{E as _};

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More