mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-04 11:37:57 +02:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbcf9b8418 | |||
| b3767b2deb | |||
| 7e764df0e8 | |||
| a1ffb20d6e | |||
| 125685f08f | |||
| b804635fa8 | |||
| c9fb5d11d3 | |||
| 926491b746 | |||
| 4e17691717 | |||
| 2e2a6dedd4 | |||
| b1323896c8 | |||
| 595074b7b0 | |||
| 2e063dd857 | |||
| a110d233e1 | |||
| 2f58d0a457 | |||
| 5b7f157802 | |||
| 09890db635 | |||
| c0171ef60a | |||
| 4eb73fb638 | |||
| d1b49cb20d | |||
| 930eb47013 | |||
| 9964e13197 | |||
| 4f7b21cb7e | |||
| 9fae9db906 | |||
| 7ecd8c61e8 | |||
| bdb0326e47 | |||
| 8dccc6aa06 | |||
| fd4bbe8d76 | |||
| d80651e4d8 | |||
| f920ff0a5d | |||
| ce8b57501d | |||
| ecb38a3959 | |||
| e69fdb71ca | |||
| 6aa1631748 | |||
| 52de3b0f41 | |||
| e537e55198 | |||
| dc20b4804e | |||
| 6245d69364 | |||
| ede32951bf | |||
| 866a8ebccf | |||
| 276b3f7ef5 | |||
| 81e461db54 | |||
| 02cd488a3d | |||
| b4b2f55665 | |||
| 7aa0ebea6d | |||
| 63ef4399f8 | |||
| 553d0ed6bf | |||
| d92bbbea07 | |||
| f89ad1b42d | |||
| bbe14c1861 | |||
| 2fc37fefd1 | |||
| ded8ac5a3f | |||
| bf44cf58d3 | |||
| 6d390e80d5 | |||
| cfc49ba16f | |||
| d03f2fcf2b | |||
| 6e67684bba | |||
| 8f9d2f381a | |||
| 89c275269f | |||
| cb4900c61d | |||
| 5c192cd308 | |||
| 8571e41138 | |||
| e1a74b29b1 | |||
| 39f1c72755 | |||
| dd3621e89d | |||
| 0bcb16e021 |
@@ -35,7 +35,18 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
|
|
||||||
### System Dashboard Overview
|
### System Dashboard Overview
|
||||||
|
|
||||||
<img src="./images/dashboard.png" alt="System Dashboard" width="100%">
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<strong>Light Mode</strong><br/>
|
||||||
|
<img src="./images/dashboard.png" alt="System Dashboard (Light)" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<strong>Dark Mode</strong><br/>
|
||||||
|
<img src="./images/dark.png" alt="System Dashboard (Dark)" width="100%">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
*The dashboard provides a comprehensive overview of system runtime status, security vulnerabilities, tool usage, and knowledge base, helping users quickly understand the platform's core features and current state.*
|
*The dashboard provides a comprehensive overview of system runtime status, security vulnerabilities, tool usage, and knowledge base, helping users quickly understand the platform's core features and current state.*
|
||||||
|
|
||||||
@@ -110,7 +121,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
- 📄 Large-result pagination, compression, and searchable archives
|
- 📄 Large-result pagination, compression, and searchable archives
|
||||||
- 🔗 Attack-chain graph, risk scoring, and step-by-step replay
|
- 🔗 Attack-chain graph, risk scoring, and step-by-step replay
|
||||||
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
||||||
- 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks
|
- 📚 Knowledge base (RAG): **Eino MultiQuery** query rewrite + multi-path vector retrieval + **HTTP rerank** (DashScope `gte-rerank` / Cohere-compatible) + post-processing (dedupe, budget); **Eino Compose** indexing pipeline
|
||||||
- 📁 Conversation grouping with pinning, rename, and batch management
|
- 📁 Conversation grouping with pinning, rename, and batch management
|
||||||
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
||||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||||
@@ -455,9 +466,10 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
|||||||
|
|
||||||
### Knowledge Base
|
### Knowledge Base
|
||||||
- **Vector search** – AI agent can automatically search the knowledge base for relevant security knowledge during conversations using the `search_knowledge_base` tool.
|
- **Vector search** – AI agent can automatically search the knowledge base for relevant security knowledge during conversations using the `search_knowledge_base` tool.
|
||||||
- **Vector retrieval** – cosine similarity over stored embeddings, aligned with Eino `retriever.Retriever` usage.
|
- **RAG pipeline (always on)** – **MultiQuery** (LLM query rewrite) → vector prefetch & fusion → **HTTP rerank** (DashScope `gte-rerank` or Cohere-compatible `/v1/rerank`) → post-processing (normalized dedupe, char/token budget, final top_k). Rerank failures degrade to fusion order without breaking search.
|
||||||
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings.
|
- **Vector retrieval** – cosine similarity over stored embeddings with configurable threshold, aligned with Eino `retriever.Retriever` usage.
|
||||||
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization.
|
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings (Markdown header split + recursive chunking via Eino).
|
||||||
|
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization; settings page exposes MultiQuery / rerank / prefetch options.
|
||||||
- **Retrieval logs** – tracks all knowledge retrieval operations for audit and debugging.
|
- **Retrieval logs** – tracks all knowledge retrieval operations for audit and debugging.
|
||||||
|
|
||||||
**Quick Start (Using Pre-built Knowledge Base):**
|
**Quick Start (Using Pre-built Knowledge Base):**
|
||||||
@@ -479,6 +491,17 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
|||||||
retrieval:
|
retrieval:
|
||||||
top_k: 5
|
top_k: 5
|
||||||
similarity_threshold: 0.7
|
similarity_threshold: 0.7
|
||||||
|
multi_query:
|
||||||
|
max_queries: 4 # LLM rewrite variants (always on)
|
||||||
|
rerank: # always on; empty fields inherit openai/embedding credentials
|
||||||
|
provider: "" # auto: dashscope | cohere from base_url
|
||||||
|
model: "" # empty: gte-rerank (DashScope) or rerank-multilingual-v3.0 (Cohere)
|
||||||
|
base_url: ""
|
||||||
|
api_key: ""
|
||||||
|
post_retrieve:
|
||||||
|
prefetch_top_k: 20 # vector candidates per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||||
|
max_context_chars: 0
|
||||||
|
max_context_tokens: 0
|
||||||
```
|
```
|
||||||
2. **Add knowledge files** – place Markdown files in `knowledge_base/` directory, organized by category (e.g., `knowledge_base/SQL Injection/README.md`).
|
2. **Add knowledge files** – place Markdown files in `knowledge_base/` directory, organized by category (e.g., `knowledge_base/SQL Injection/README.md`).
|
||||||
3. **Scan and index** – use the web UI to scan the knowledge base directory, which will automatically import files and build vector embeddings.
|
3. **Scan and index** – use the web UI to scan the knowledge base directory, which will automatically import files and build vector embeddings.
|
||||||
@@ -539,6 +562,17 @@ knowledge:
|
|||||||
retrieval:
|
retrieval:
|
||||||
top_k: 5 # Number of top results to return
|
top_k: 5 # Number of top results to return
|
||||||
similarity_threshold: 0.7 # Minimum cosine similarity (0-1)
|
similarity_threshold: 0.7 # Minimum cosine similarity (0-1)
|
||||||
|
multi_query:
|
||||||
|
max_queries: 4 # MultiQuery rewrite variants (always on)
|
||||||
|
rerank: # HTTP rerank (always on); empty fields inherit openai/embedding credentials
|
||||||
|
provider: ""
|
||||||
|
model: ""
|
||||||
|
base_url: ""
|
||||||
|
api_key: ""
|
||||||
|
post_retrieve:
|
||||||
|
prefetch_top_k: 20 # per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||||
|
max_context_chars: 0
|
||||||
|
max_context_tokens: 0
|
||||||
roles_dir: "roles" # Role configuration directory (relative to config file)
|
roles_dir: "roles" # Role configuration directory (relative to config file)
|
||||||
skills_dir: "skills" # Skills directory (relative to config file)
|
skills_dir: "skills" # Skills directory (relative to config file)
|
||||||
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
||||||
|
|||||||
+39
-5
@@ -34,7 +34,18 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
|
|
||||||
### 系统仪表盘概览
|
### 系统仪表盘概览
|
||||||
|
|
||||||
<img src="./images/dashboard.png" alt="系统仪表盘" width="100%">
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<strong>浅色模式</strong><br/>
|
||||||
|
<img src="./images/dashboard.png" alt="系统仪表盘(浅色)" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<strong>深色模式</strong><br/>
|
||||||
|
<img src="./images/dark.png" alt="系统仪表盘(深色)" width="100%">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
*仪表盘提供系统运行状态、安全漏洞、工具使用情况和知识库的全面概览,帮助用户快速了解平台核心功能和当前状态。*
|
*仪表盘提供系统运行状态、安全漏洞、工具使用情况和知识库的全面概览,帮助用户快速了解平台核心功能和当前状态。*
|
||||||
|
|
||||||
@@ -109,7 +120,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
- 📄 大结果分页、压缩与全文检索
|
- 📄 大结果分页、压缩与全文检索
|
||||||
- 🔗 攻击链可视化、风险打分与步骤回放
|
- 🔗 攻击链可视化、风险打分与步骤回放
|
||||||
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
||||||
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
- 📚 知识库(RAG):**Eino MultiQuery** 查询改写 + 多路向量检索 + **HTTP 精排**(DashScope `gte-rerank` / Cohere 兼容)+ 后处理(去重、预算);索引侧为 **Eino Compose** 流水线
|
||||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||||
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||||
@@ -453,9 +464,10 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
|||||||
|
|
||||||
### 知识库功能
|
### 知识库功能
|
||||||
- **向量检索**:AI 智能体在对话过程中可自动调用 `search_knowledge_base` 工具搜索知识库中的安全知识。
|
- **向量检索**:AI 智能体在对话过程中可自动调用 `search_knowledge_base` 工具搜索知识库中的安全知识。
|
||||||
- **向量检索**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
- **RAG 管线(始终启用)**:**MultiQuery**(LLM 查询改写)→ 向量预取与融合 → **HTTP 精排**(DashScope `gte-rerank` 或 Cohere 兼容 `/v1/rerank`)→ 后处理(规范化去重、字符/token 预算、最终 top_k)。精排失败时自动降级为融合排序,检索仍可用。
|
||||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引。
|
- **向量相似度**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理。
|
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引(Eino Markdown 标题切分 + 递归分块)。
|
||||||
|
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理;设置页可配置 MultiQuery / 精排 / 预取候选数。
|
||||||
- **检索日志**:记录所有知识检索操作,便于审计与调试。
|
- **检索日志**:记录所有知识检索操作,便于审计与调试。
|
||||||
|
|
||||||
**快速开始(使用预构建知识库):**
|
**快速开始(使用预构建知识库):**
|
||||||
@@ -477,6 +489,17 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
|||||||
retrieval:
|
retrieval:
|
||||||
top_k: 5
|
top_k: 5
|
||||||
similarity_threshold: 0.7
|
similarity_threshold: 0.7
|
||||||
|
multi_query:
|
||||||
|
max_queries: 4 # LLM 改写变体上限(始终启用)
|
||||||
|
rerank: # 精排始终启用;留空则继承 openai/embedding 凭据
|
||||||
|
provider: "" # 空=按 base_url 推断 dashscope | cohere
|
||||||
|
model: "" # 空=DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0
|
||||||
|
base_url: ""
|
||||||
|
api_key: ""
|
||||||
|
post_retrieve:
|
||||||
|
prefetch_top_k: 20 # 每条 MultiQuery 变体的向量候选数;0=max(top_k×4, 20)
|
||||||
|
max_context_chars: 0
|
||||||
|
max_context_tokens: 0
|
||||||
```
|
```
|
||||||
2. **添加知识文件**:将 Markdown 文件放入 `knowledge_base/` 目录,按分类组织(如 `knowledge_base/SQL注入/README.md`)。
|
2. **添加知识文件**:将 Markdown 文件放入 `knowledge_base/` 目录,按分类组织(如 `knowledge_base/SQL注入/README.md`)。
|
||||||
3. **扫描索引**:在 Web 界面中点击"扫描知识库",系统会自动导入文件并构建向量索引。
|
3. **扫描索引**:在 Web 界面中点击"扫描知识库",系统会自动导入文件并构建向量索引。
|
||||||
@@ -537,6 +560,17 @@ knowledge:
|
|||||||
retrieval:
|
retrieval:
|
||||||
top_k: 5 # 检索返回的 Top-K 结果数量
|
top_k: 5 # 检索返回的 Top-K 结果数量
|
||||||
similarity_threshold: 0.7 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
similarity_threshold: 0.7 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
||||||
|
multi_query:
|
||||||
|
max_queries: 4 # MultiQuery 改写变体上限(始终启用)
|
||||||
|
rerank: # HTTP 精排(始终启用);留空则继承 openai/embedding 凭据
|
||||||
|
provider: ""
|
||||||
|
model: ""
|
||||||
|
base_url: ""
|
||||||
|
api_key: ""
|
||||||
|
post_retrieve:
|
||||||
|
prefetch_top_k: 20 # 每条 MultiQuery 变体;0=max(top_k×4, 20)
|
||||||
|
max_context_chars: 0
|
||||||
|
max_context_tokens: 0
|
||||||
roles_dir: "roles" # 角色配置文件目录(相对于配置文件所在目录)
|
roles_dir: "roles" # 角色配置文件目录(相对于配置文件所在目录)
|
||||||
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
||||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||||
|
|||||||
+75
-6
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.47"
|
version: "v1.6.49"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -102,9 +102,69 @@ agent:
|
|||||||
|
|
||||||
system_prompt_path: ""
|
system_prompt_path: ""
|
||||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||||
|
# 非白名单工具在审批方=审计 Agent 时,按会话 HITL 模式选用提示词:
|
||||||
|
# approval → audit_agent_prompt
|
||||||
|
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
|
||||||
hitl:
|
hitl:
|
||||||
|
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||||
|
retention_days: 90
|
||||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep]
|
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||||
|
# audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
|
||||||
|
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
|
||||||
|
|
||||||
|
audit_agent_prompt: |-
|
||||||
|
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||||
|
|
||||||
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
裁决基调(默认放行):
|
||||||
|
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||||
|
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||||
|
- 仅在「可能对系统造成实质影响」时 → reject
|
||||||
|
|
||||||
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
|
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||||
|
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||||
|
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||||
|
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||||
|
|
||||||
|
不应单独作为 reject 理由的情形:
|
||||||
|
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||||
|
- 参数略显宽泛但无明确破坏意图
|
||||||
|
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
||||||
|
|
||||||
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
|
{"decision":"approve"|"reject","comment":"简要理由"}
|
||||||
|
audit_agent_prompt_review_edit: |-
|
||||||
|
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||||
|
|
||||||
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
裁决基调(默认放行):
|
||||||
|
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||||
|
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||||
|
- 仅在「可能对系统造成实质影响」时 → reject;参数可安全收窄时优先 approve + editedArguments
|
||||||
|
|
||||||
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
|
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||||
|
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||||
|
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||||
|
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||||
|
|
||||||
|
不应单独作为 reject 理由的情形:
|
||||||
|
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||||
|
- 参数略显宽泛但无明确破坏意图(应收窄参数后 approve)
|
||||||
|
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
||||||
|
|
||||||
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
|
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
||||||
|
|
||||||
|
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||||
|
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||||
|
- 只做最小必要修改以收窄范围、消除风险(如限制 path、去掉危险 flag)
|
||||||
|
- 禁止扩大攻击面:不得扩大目标范围、提升权限或引入破坏性参数
|
||||||
|
- 无法安全改参且存在上述高危情形时应 reject,不要勉强 approve
|
||||||
# 多代理与 Eino 单代理(CloudWeGo Eino ADK;单代理入口 /api/eino-agent*,多代理 /api/multi-agent*)
|
# 多代理与 Eino 单代理(CloudWeGo Eino ADK;单代理入口 /api/eino-agent*,多代理 /api/multi-agent*)
|
||||||
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
# Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode
|
# Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode
|
||||||
@@ -115,7 +175,6 @@ multi_agent:
|
|||||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||||
plan_execute_loop_max_iterations: 0
|
plan_execute_loop_max_iterations: 0
|
||||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
||||||
user_verbatim_anchor_max_runes: 0 # 主代理 system 中逐轮保留用户原文(压缩后刷新);0=不截断(默认),>0=总字符上限,负数=禁用
|
|
||||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||||
without_write_todos: false
|
without_write_todos: false
|
||||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||||
@@ -126,7 +185,8 @@ multi_agent:
|
|||||||
disable: false # true:不注册 skill 渐进式披露中间件,也不挂本机 FS/Shell 工具;false:按下方开关加载
|
disable: false # true:不注册 skill 渐进式披露中间件,也不挂本机 FS/Shell 工具;false:按下方开关加载
|
||||||
filesystem_tools: true # true:注册 read_file/glob/grep/write/edit/execute(授权环境慎用);false:仅 skill,不暴露本机读写与 Shell
|
filesystem_tools: true # true:注册 read_file/glob/grep/write/edit/execute(授权环境慎用);false:仅 skill,不暴露本机读写与 Shell
|
||||||
skill_tool_name: skill # 模型侧可调用的「加载技能」工具名,一般保持 skill;与技能包文档中的调用名一致即可
|
skill_tool_name: skill # 模型侧可调用的「加载技能」工具名,一般保持 skill;与技能包文档中的调用名一致即可
|
||||||
# Eino ADK 中间件与 Deep/Supervisor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
||||||
|
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
|
||||||
eino_middleware:
|
eino_middleware:
|
||||||
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
||||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||||
@@ -150,6 +210,7 @@ multi_agent:
|
|||||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||||
run_retry_max_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
|
run_retry_max_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
|
||||||
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
||||||
|
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
|
||||||
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
||||||
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
|
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
|
||||||
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
||||||
@@ -222,9 +283,17 @@ knowledge:
|
|||||||
retrieval:
|
retrieval:
|
||||||
top_k: 5 # 检索返回的Top-K结果数量
|
top_k: 5 # 检索返回的Top-K结果数量
|
||||||
similarity_threshold: 0.4 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
similarity_threshold: 0.4 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
||||||
# 检索后处理:固定正文规范化去重;上下文预算;可选代码注入 DocumentReranker 做重排
|
# Eino MultiQuery:LLM 改写查询后多路向量检索再融合(始终启用)
|
||||||
|
multi_query:
|
||||||
|
max_queries: 4 # 改写变体上限(含语义覆盖);建议 3~4
|
||||||
|
# 精排(始终启用):dashscope 用 gte-rerank;其他 OpenAI 兼容端点走 /v1/rerank
|
||||||
|
rerank:
|
||||||
|
provider: "" # 空=按 base_url 推断:dashscope | cohere
|
||||||
|
model: "" # 空=dashscope→gte-rerank,cohere→rerank-multilingual-v3.0
|
||||||
|
base_url: "" # 留空则用 embedding / openai 的 base_url
|
||||||
|
api_key: "" # 留空则用 embedding / openai 的 api_key
|
||||||
post_retrieve:
|
post_retrieve:
|
||||||
prefetch_top_k: 0 # 0 与 top_k 相同;可设为 15~30 以便去重后仍填满 top_k
|
prefetch_top_k: 20 # 每条 MultiQuery 变体的向量候选数;0=内置 max(top_k*4,20)
|
||||||
max_context_chars: 0 # 0 不限制;否则返回的正文总 Unicode 字符上限(整段 chunk)
|
max_context_chars: 0 # 0 不限制;否则返回的正文总 Unicode 字符上限(整段 chunk)
|
||||||
max_context_tokens: 0 # 0 不限制;tiktoken 总 token 上限
|
max_context_tokens: 0 # 0 不限制;tiktoken 总 token 上限
|
||||||
sub_index_filter: ""
|
sub_index_filter: ""
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||||
| 机器人 | `ProcessMessageForRobot` 按 `robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent` 或 `RunDeepAgent`。 |
|
| 机器人 | `ProcessMessageForRobot` 按 `robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent` 或 `RunDeepAgent`。 |
|
||||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
||||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。`plan_execute` 的 Executor 无 Handlers:仅继承 **ToolsConfig** 侧效果(如 `tool_search` 列表拆分),不挂载 patch/plantask/reduction 中间件。 |
|
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。**`plan_execute`**:`runner.go` 将 `prependEinoMiddlewares(einoMWMain)` 产物作为 `ExecPreMiddlewares` 挂到 **Executor**(与 Deep/Supervisor 主代理同序:patch → reduction → toolsearch → plantask → filesystem → skill → summarization tail);Planner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。 |
|
||||||
|
|
||||||
## 进行中 / 待办( backlog )
|
## 进行中 / 待办( backlog )
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@
|
|||||||
|
|
||||||
## 关键文件索引
|
## 关键文件索引
|
||||||
|
|
||||||
- `internal/multiagent/runner.go` — DeepAgent 组装与事件循环
|
- `internal/multiagent/runner.go` — DeepAgent / plan_execute / supervisor 组装与事件循环
|
||||||
|
- `internal/multiagent/eino_orchestration.go` — PlanExecute 根节点与 Executor 中间件栈(`buildPlanExecuteExecutorHandlers`)
|
||||||
- `internal/handler/multi_agent.go` — SSE 与(同步)HTTP
|
- `internal/handler/multi_agent.go` — SSE 与(同步)HTTP
|
||||||
- `internal/handler/multi_agent_prepare.go` — 会话准备(含 WebShell)
|
- `internal/handler/multi_agent_prepare.go` — 会话准备(含 WebShell)
|
||||||
- `internal/einomcp/` — MCP → Eino Tool
|
- `internal/einomcp/` — MCP → Eino Tool
|
||||||
@@ -59,4 +60,5 @@
|
|||||||
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
||||||
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
||||||
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
||||||
|
| 2026-07-02 | **plan_execute Executor 中间件对齐**:`ExecPreMiddlewares` 与 Deep 主代理同源;`buildPlanExecuteExecutorHandlers` + 回归测试;文档更正。 |
|
||||||
| 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
| 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 265 KiB |
+23
-14
@@ -21,6 +21,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/einoobserve"
|
"cyberstrike-ai/internal/einoobserve"
|
||||||
"cyberstrike-ai/internal/handler"
|
"cyberstrike-ai/internal/handler"
|
||||||
|
"cyberstrike-ai/internal/hitl"
|
||||||
"cyberstrike-ai/internal/knowledge"
|
"cyberstrike-ai/internal/knowledge"
|
||||||
"cyberstrike-ai/internal/logger"
|
"cyberstrike-ai/internal/logger"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
@@ -109,6 +110,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
monitorRetention.PurgeExpired()
|
monitorRetention.PurgeExpired()
|
||||||
monitor.StartRetentionLoop(monitorRetention, log.Logger)
|
monitor.StartRetentionLoop(monitorRetention, log.Logger)
|
||||||
|
|
||||||
|
hitlRetention := hitl.NewService(db, cfg, log.Logger)
|
||||||
|
hitlRetention.PurgeExpired()
|
||||||
|
hitl.StartRetentionLoop(hitlRetention, log.Logger)
|
||||||
|
|
||||||
// 创建MCP服务器(带数据库持久化)
|
// 创建MCP服务器(带数据库持久化)
|
||||||
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
|
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
|
||||||
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
|
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
|
||||||
@@ -202,14 +207,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建检索器
|
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||||
retrievalConfig := &knowledge.RetrievalConfig{
|
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
|
||||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
|
||||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
|
||||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
|
||||||
}
|
|
||||||
knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger)
|
knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger)
|
||||||
|
if err := knowledge.WireRetrieverPipeline(context.Background(), knowledgeRetriever, &cfg.OpenAI); err != nil {
|
||||||
|
return nil, fmt.Errorf("初始化知识库检索流水线失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建索引器(Eino Compose 链)
|
// 创建索引器(Eino Compose 链)
|
||||||
knowledgeIndexer, err = knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, log.Logger, &cfg.Knowledge)
|
knowledgeIndexer, err = knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, log.Logger, &cfg.Knowledge)
|
||||||
@@ -363,6 +366,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||||
configHandler.SetAudit(auditSvc)
|
configHandler.SetAudit(auditSvc)
|
||||||
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
||||||
|
agentHandler.SetHitlAuditStrategySaver(configHandler)
|
||||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||||
externalMCPHandler.SetAudit(auditSvc)
|
externalMCPHandler.SetAudit(auditSvc)
|
||||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||||
@@ -812,11 +816,18 @@ func setupRoutes(
|
|||||||
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
||||||
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
||||||
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
|
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
|
||||||
|
protected.GET("/hitl/logs", agentHandler.ListHITLLogs)
|
||||||
|
protected.DELETE("/hitl/logs", agentHandler.DeleteHITLLogs)
|
||||||
|
protected.GET("/hitl/logs/:id", agentHandler.GetHITLLog)
|
||||||
protected.POST("/hitl/decision", agentHandler.DecideHITLInterrupt)
|
protected.POST("/hitl/decision", agentHandler.DecideHITLInterrupt)
|
||||||
protected.POST("/hitl/dismiss", agentHandler.DismissHITLInterrupt)
|
protected.POST("/hitl/dismiss", agentHandler.DismissHITLInterrupt)
|
||||||
protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig)
|
protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig)
|
||||||
protected.PUT("/hitl/config", agentHandler.UpsertHITLConversationConfig)
|
protected.PUT("/hitl/config", agentHandler.UpsertHITLConversationConfig)
|
||||||
|
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
|
||||||
|
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
|
||||||
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
||||||
|
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
|
||||||
|
protected.PUT("/hitl/audit-strategy", agentHandler.UpdateHITLAuditStrategy)
|
||||||
// Agent Loop 取消与任务列表
|
// Agent Loop 取消与任务列表
|
||||||
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
|
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
|
||||||
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
|
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
|
||||||
@@ -1787,14 +1798,12 @@ func initializeKnowledge(
|
|||||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建检索器
|
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||||
retrievalConfig := &knowledge.RetrievalConfig{
|
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
|
||||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
|
||||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
|
||||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
|
||||||
}
|
|
||||||
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
|
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
|
||||||
|
if err := knowledge.WireRetrieverPipeline(context.Background(), knowledgeRetriever, &cfg.OpenAI); err != nil {
|
||||||
|
return nil, fmt.Errorf("初始化知识库检索流水线失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建索引器(Eino Compose 链)
|
// 创建索引器(Eino Compose 链)
|
||||||
knowledgeIndexer, err := knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, logger, &cfg.Knowledge)
|
knowledgeIndexer, err := knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, logger, &cfg.Knowledge)
|
||||||
|
|||||||
+239
-37
@@ -99,9 +99,6 @@ type MultiAgentConfig struct {
|
|||||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||||
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
||||||
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
||||||
// UserVerbatimAnchorMaxRunes injects all user turns verbatim into system prompt (survives summarization refresh).
|
|
||||||
// 0 (default) = no cap; >0 = total rune cap; negative disables anchor injection.
|
|
||||||
UserVerbatimAnchorMaxRunes int `yaml:"user_verbatim_anchor_max_runes,omitempty" json:"user_verbatim_anchor_max_runes,omitempty"`
|
|
||||||
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
||||||
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||||
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
||||||
@@ -110,11 +107,6 @@ type MultiAgentConfig struct {
|
|||||||
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserVerbatimAnchorMaxRunesEffective returns max runes for user verbatim anchor; 0 = unlimited; negative = disabled.
|
|
||||||
func (c MultiAgentConfig) UserVerbatimAnchorMaxRunesEffective() int {
|
|
||||||
return c.UserVerbatimAnchorMaxRunes
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
||||||
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
||||||
return c.SubAgentUserContextMaxRunes
|
return c.SubAgentUserContextMaxRunes
|
||||||
@@ -283,6 +275,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
||||||
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
||||||
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
||||||
|
// EmptyResponseContinueMaxAttempts Run 成功但未捕获助手正文时 Handler 层退避续跑次数;0=默认 5。
|
||||||
|
EmptyResponseContinueMaxAttempts int `yaml:"empty_response_continue_max_attempts,omitempty" json:"empty_response_continue_max_attempts,omitempty"`
|
||||||
// TaskToolDescriptionPrefix when non-empty sets deep.Config TaskToolDescriptionGenerator (sub-agent names appended).
|
// TaskToolDescriptionPrefix when non-empty sets deep.Config TaskToolDescriptionGenerator (sub-agent names appended).
|
||||||
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -503,6 +497,17 @@ type RobotWecomConfig struct {
|
|||||||
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
|
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateWecomConfig 校验企业微信机器人配置;启用时必须配置 token,否则回调无法防伪造。
|
||||||
|
func ValidateWecomConfig(w RobotWecomConfig) error {
|
||||||
|
if !w.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(w.Token) == "" {
|
||||||
|
return fmt.Errorf("robots.wecom.enabled 为 true 时必须配置 robots.wecom.token")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RobotDingtalkConfig 钉钉机器人配置
|
// RobotDingtalkConfig 钉钉机器人配置
|
||||||
type RobotDingtalkConfig struct {
|
type RobotDingtalkConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
@@ -627,10 +632,100 @@ type AgentConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。
|
// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。
|
||||||
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效;其他字段若仅改文件仍需重启。
|
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。
|
||||||
|
// audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。
|
||||||
type HitlConfig struct {
|
type HitlConfig struct {
|
||||||
// ToolWhitelist 全局免审批工具名(与每条会话配置的 sensitiveTools 语义相同:白名单内工具不触发 HITL)。
|
// ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。
|
||||||
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"`
|
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"`
|
||||||
|
// AuditAgentPrompt 审批模式(approval)下审计 Agent 系统提示词。
|
||||||
|
AuditAgentPrompt string `yaml:"audit_agent_prompt,omitempty" json:"audit_agent_prompt,omitempty"`
|
||||||
|
// AuditAgentPromptReviewEdit 审查编辑模式(review_edit)下审计 Agent 系统提示词。
|
||||||
|
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
|
||||||
|
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
|
||||||
|
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
|
||||||
|
func (h HitlConfig) RetentionDaysEffective() int {
|
||||||
|
if h.RetentionDays == nil {
|
||||||
|
return 90
|
||||||
|
}
|
||||||
|
if *h.RetentionDays < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *h.RetentionDays
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||||
|
|
||||||
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
裁决基调(默认放行):
|
||||||
|
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||||
|
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||||
|
- 仅在「可能对系统造成实质影响」时 → reject
|
||||||
|
|
||||||
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
|
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||||
|
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||||
|
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||||
|
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||||
|
|
||||||
|
不应单独作为 reject 理由的情形:
|
||||||
|
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||||
|
- 参数略显宽泛但无明确破坏意图(审查编辑模式可收窄参数后 approve)
|
||||||
|
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点`
|
||||||
|
|
||||||
|
const hitlAuditAgentPromptApprovalOutput = `
|
||||||
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
|
{"decision":"approve"|"reject","comment":"简要理由"}`
|
||||||
|
|
||||||
|
const hitlAuditAgentPromptReviewEditOutput = `
|
||||||
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
|
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
||||||
|
|
||||||
|
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||||
|
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||||
|
- 只做最小必要修改以收窄范围、消除风险(如限制 path、去掉危险 flag)
|
||||||
|
- 禁止扩大攻击面:不得扩大目标范围、提升权限或引入破坏性参数
|
||||||
|
- 无法安全改参时应 reject,不要勉强 approve`
|
||||||
|
|
||||||
|
// DefaultHitlAuditAgentPrompt 内置审批模式审计 Agent 提示词。
|
||||||
|
func DefaultHitlAuditAgentPrompt() string {
|
||||||
|
return hitlAuditAgentPromptBase + hitlAuditAgentPromptApprovalOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHitlAuditAgentPromptReviewEdit 内置审查编辑模式审计 Agent 提示词。
|
||||||
|
func DefaultHitlAuditAgentPromptReviewEdit() string {
|
||||||
|
return hitlAuditAgentPromptBase + hitlAuditAgentPromptReviewEditOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveAuditAgentPrompt 返回审批模式生效的审计 Agent 提示词。
|
||||||
|
func (c HitlConfig) EffectiveAuditAgentPrompt() string {
|
||||||
|
return c.EffectiveAuditAgentPromptForMode("approval")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveAuditAgentPromptForMode 按 HITL 模式返回生效的审计 Agent 提示词。
|
||||||
|
func (c HitlConfig) EffectiveAuditAgentPromptForMode(mode string) string {
|
||||||
|
if normalizeHitlModeForPrompt(mode) == "review_edit" {
|
||||||
|
if s := strings.TrimSpace(c.AuditAgentPromptReviewEdit); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return DefaultHitlAuditAgentPromptReviewEdit()
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(c.AuditAgentPrompt); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return DefaultHitlAuditAgentPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHitlModeForPrompt(mode string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "review_edit":
|
||||||
|
return "review_edit"
|
||||||
|
default:
|
||||||
|
return "approval"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -817,33 +912,13 @@ func Load(path string) (*Config, error) {
|
|||||||
|
|
||||||
// 如果配置了工具目录,从目录加载工具配置
|
// 如果配置了工具目录,从目录加载工具配置
|
||||||
if cfg.Security.ToolsDir != "" {
|
if cfg.Security.ToolsDir != "" {
|
||||||
configDir := filepath.Dir(path)
|
inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
|
||||||
toolsDir := cfg.Security.ToolsDir
|
toolsDir := ResolveToolsDir(cfg.Security.ToolsDir, path)
|
||||||
|
merged, err := MergeToolsFromDir(toolsDir, inlineTools)
|
||||||
// 如果是相对路径,相对于配置文件所在目录
|
|
||||||
if !filepath.IsAbs(toolsDir) {
|
|
||||||
toolsDir = filepath.Join(configDir, toolsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
tools, err := LoadToolsFromDir(toolsDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("从工具目录加载工具配置失败: %w", err)
|
return nil, fmt.Errorf("从工具目录加载工具配置失败: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.Security.Tools = merged
|
||||||
// 合并工具配置:目录中的工具优先,主配置中的工具作为补充
|
|
||||||
existingTools := make(map[string]bool)
|
|
||||||
for _, tool := range tools {
|
|
||||||
existingTools[tool.Name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加主配置中不存在于目录中的工具(向后兼容)
|
|
||||||
for _, tool := range cfg.Security.Tools {
|
|
||||||
if !existingTools[tool.Name] {
|
|
||||||
tools = append(tools, tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Security.Tools = tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 外部 MCP:迁移 + 环境变量展开
|
// 外部 MCP:迁移 + 环境变量展开
|
||||||
@@ -887,6 +962,10 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ValidateWecomConfig(cfg.Robots.Wecom); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,6 +1190,75 @@ func PrintMCPConfigJSON(mcp MCPConfig) {
|
|||||||
fmt.Println("----------------------------------------------------------------")
|
fmt.Println("----------------------------------------------------------------")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveToolsDir 将 tools_dir 解析为绝对路径(相对路径相对于 configPath 所在目录)。
|
||||||
|
func ResolveToolsDir(toolsDir, configPath string) string {
|
||||||
|
toolsDir = strings.TrimSpace(toolsDir)
|
||||||
|
if toolsDir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(toolsDir) {
|
||||||
|
return toolsDir
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(configPath), toolsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeToolsFromDir 从目录加载工具并与 inline 列表合并:目录中的工具优先,主配置中的工具作为补充。
|
||||||
|
func MergeToolsFromDir(toolsDir string, inlineTools []ToolConfig) ([]ToolConfig, error) {
|
||||||
|
dirTools, err := LoadToolsFromDir(toolsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing := make(map[string]bool, len(dirTools))
|
||||||
|
for _, tool := range dirTools {
|
||||||
|
existing[tool.Name] = true
|
||||||
|
}
|
||||||
|
merged := append([]ToolConfig(nil), dirTools...)
|
||||||
|
for _, tool := range inlineTools {
|
||||||
|
if !existing[tool.Name] {
|
||||||
|
merged = append(merged, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadInlineSecurityToolsFromYAML 读取 config.yaml 中 security.tools(不含 tools_dir 扫描结果)。
|
||||||
|
func loadInlineSecurityToolsFromYAML(configPath string) ([]ToolConfig, error) {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
var partial struct {
|
||||||
|
Security struct {
|
||||||
|
Tools []ToolConfig `yaml:"tools"`
|
||||||
|
} `yaml:"security"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &partial); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if partial.Security.Tools == nil {
|
||||||
|
return []ToolConfig{}, nil
|
||||||
|
}
|
||||||
|
return partial.Security.Tools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadSecurityToolsFromDir 从 tools_dir 重新加载工具并更新 cfg.Security.Tools(ApplyConfig 热重载用)。
|
||||||
|
func ReloadSecurityToolsFromDir(cfg *Config, configPath string) error {
|
||||||
|
if cfg == nil || strings.TrimSpace(cfg.Security.ToolsDir) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inlineTools, err := loadInlineSecurityToolsFromYAML(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
toolsDir := ResolveToolsDir(cfg.Security.ToolsDir, configPath)
|
||||||
|
merged, err := MergeToolsFromDir(toolsDir, inlineTools)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("从工具目录加载工具配置失败: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Security.Tools = merged
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadToolsFromDir 从目录加载所有工具配置文件
|
// LoadToolsFromDir 从目录加载所有工具配置文件
|
||||||
func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
|
func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
|
||||||
var tools []ToolConfig
|
var tools []ToolConfig
|
||||||
@@ -1329,7 +1477,12 @@ func Default() *Config {
|
|||||||
},
|
},
|
||||||
Retrieval: RetrievalConfig{
|
Retrieval: RetrievalConfig{
|
||||||
TopK: 5,
|
TopK: 5,
|
||||||
SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
|
SimilarityThreshold: 0.65,
|
||||||
|
MultiQuery: MultiQueryConfig{MaxQueries: 4},
|
||||||
|
Rerank: RerankConfig{},
|
||||||
|
PostRetrieve: PostRetrieveConfig{
|
||||||
|
PrefetchTopK: 20,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Indexing: IndexingConfig{
|
Indexing: IndexingConfig{
|
||||||
ChunkStrategy: "markdown_then_recursive",
|
ChunkStrategy: "markdown_then_recursive",
|
||||||
@@ -1425,7 +1578,7 @@ type EmbeddingConfig struct {
|
|||||||
|
|
||||||
// PostRetrieveConfig 检索后处理:固定对正文做规范化去重(最佳实践)、上下文预算截断;PrefetchTopK 用于多取候选再收敛到 top_k。
|
// PostRetrieveConfig 检索后处理:固定对正文做规范化去重(最佳实践)、上下文预算截断;PrefetchTopK 用于多取候选再收敛到 top_k。
|
||||||
type PostRetrieveConfig struct {
|
type PostRetrieveConfig struct {
|
||||||
// PrefetchTopK 向量检索阶段最多保留的候选数(余弦序),应 ≥ top_k,0 表示与 top_k 相同;上限见知识库包内常量。
|
// PrefetchTopK 向量检索阶段每条 MultiQuery 变体最多保留的候选数;0 表示使用内置默认 max(top_k*4, 20)。
|
||||||
PrefetchTopK int `yaml:"prefetch_top_k,omitempty" json:"prefetch_top_k,omitempty"`
|
PrefetchTopK int `yaml:"prefetch_top_k,omitempty" json:"prefetch_top_k,omitempty"`
|
||||||
// MaxContextChars 返回文档内容总 Unicode 字符数上限(整段 chunk,不截断半段);0 表示不限制。
|
// MaxContextChars 返回文档内容总 Unicode 字符数上限(整段 chunk,不截断半段);0 表示不限制。
|
||||||
MaxContextChars int `yaml:"max_context_chars,omitempty" json:"max_context_chars,omitempty"`
|
MaxContextChars int `yaml:"max_context_chars,omitempty" json:"max_context_chars,omitempty"`
|
||||||
@@ -1433,13 +1586,62 @@ type PostRetrieveConfig struct {
|
|||||||
MaxContextTokens int `yaml:"max_context_tokens,omitempty" json:"max_context_tokens,omitempty"`
|
MaxContextTokens int `yaml:"max_context_tokens,omitempty" json:"max_context_tokens,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MultiQueryConfig Eino MultiQuery 查询改写(始终启用,无关闭开关)。
|
||||||
|
type MultiQueryConfig struct {
|
||||||
|
// MaxQueries LLM 生成的检索变体上限(含原问语义覆盖);0 表示默认 4。
|
||||||
|
MaxQueries int `yaml:"max_queries,omitempty" json:"max_queries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MultiQueryConfig) MaxQueriesEffective() int {
|
||||||
|
if c.MaxQueries <= 0 {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
if c.MaxQueries > 8 {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return c.MaxQueries
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankConfig 检索精排(始终启用);支持 dashscope 与 Cohere 兼容 HTTP API。
|
||||||
|
type RerankConfig struct {
|
||||||
|
// Provider: dashscope | cohere;空则按 base_url 自动推断。
|
||||||
|
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||||
|
Model string `yaml:"model,omitempty" json:"model,omitempty"`
|
||||||
|
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||||
|
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RerankConfig) ProviderEffective(baseURL string) string {
|
||||||
|
p := strings.TrimSpace(strings.ToLower(c.Provider))
|
||||||
|
if p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
u := strings.ToLower(baseURL)
|
||||||
|
if strings.Contains(u, "dashscope") {
|
||||||
|
return "dashscope"
|
||||||
|
}
|
||||||
|
return "cohere"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RerankConfig) ModelEffective(provider string) string {
|
||||||
|
if m := strings.TrimSpace(c.Model); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if provider == "dashscope" {
|
||||||
|
return "gte-rerank"
|
||||||
|
}
|
||||||
|
return "rerank-multilingual-v3.0"
|
||||||
|
}
|
||||||
|
|
||||||
// RetrievalConfig 检索配置
|
// RetrievalConfig 检索配置
|
||||||
type RetrievalConfig struct {
|
type RetrievalConfig struct {
|
||||||
TopK int `yaml:"top_k" json:"top_k"` // 检索Top-K
|
TopK int `yaml:"top_k" json:"top_k"` // 检索Top-K
|
||||||
SimilarityThreshold float64 `yaml:"similarity_threshold" json:"similarity_threshold"` // 余弦相似度阈值
|
SimilarityThreshold float64 `yaml:"similarity_threshold" json:"similarity_threshold"` // 余弦相似度阈值
|
||||||
// SubIndexFilter 非空时仅保留 sub_indexes 含该标签(逗号分隔之一)的行;sub_indexes 为空的旧行仍返回。
|
// SubIndexFilter 非空时仅保留 sub_indexes 含该标签(逗号分隔之一)的行;sub_indexes 为空的旧行仍返回。
|
||||||
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
|
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
|
||||||
// PostRetrieve 检索后处理(去重、预算截断);重排通过代码注入 [knowledge.DocumentReranker]。
|
MultiQuery MultiQueryConfig `yaml:"multi_query" json:"multi_query"`
|
||||||
|
Rerank RerankConfig `yaml:"rerank" json:"rerank"`
|
||||||
|
// PostRetrieve 检索后处理(去重、预算截断);精排在 MultiQuery 融合后执行。
|
||||||
PostRetrieve PostRetrieveConfig `yaml:"post_retrieve,omitempty" json:"post_retrieve,omitempty"`
|
PostRetrieve PostRetrieveConfig `yaml:"post_retrieve,omitempty" json:"post_retrieve,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateWecomConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg RobotWecomConfig
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled without token",
|
||||||
|
cfg: RobotWecomConfig{Enabled: false, Token: ""},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled with token",
|
||||||
|
cfg: RobotWecomConfig{Enabled: true, Token: "secret"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled without token",
|
||||||
|
cfg: RobotWecomConfig{Enabled: true, Token: ""},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled with whitespace token",
|
||||||
|
cfg: RobotWecomConfig{Enabled: true, Token: " "},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
err := ValidateWecomConfig(tt.cfg)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("ValidateWecomConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReloadSecurityToolsFromDir(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
toolsDir := filepath.Join(root, "tools")
|
||||||
|
if err := os.MkdirAll(toolsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(root, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`security:
|
||||||
|
tools_dir: tools
|
||||||
|
tools:
|
||||||
|
- name: inline-only
|
||||||
|
command: inline-cmd
|
||||||
|
enabled: true
|
||||||
|
description: inline tool
|
||||||
|
`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTool := func(name, command string) {
|
||||||
|
t.Helper()
|
||||||
|
content := "name: " + name + "\ncommand: " + command + "\nenabled: true\ndescription: test\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(toolsDir, name+".yaml"), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTool("alpha", "alpha-cmd")
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Security: SecurityConfig{
|
||||||
|
ToolsDir: "tools",
|
||||||
|
Tools: []ToolConfig{
|
||||||
|
{Name: "stale", Command: "stale-cmd", Enabled: true, Description: "should be removed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ReloadSecurityToolsFromDir(cfg, configPath); err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Security.Tools) != 2 {
|
||||||
|
t.Fatalf("expected 2 tools, got %d", len(cfg.Security.Tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, tool := range cfg.Security.Tools {
|
||||||
|
names[tool.Name] = tool.Command
|
||||||
|
}
|
||||||
|
if names["alpha"] != "alpha-cmd" {
|
||||||
|
t.Fatalf("alpha tool missing or wrong command: %#v", names)
|
||||||
|
}
|
||||||
|
if names["inline-only"] != "inline-cmd" {
|
||||||
|
t.Fatalf("inline-only tool missing: %#v", names)
|
||||||
|
}
|
||||||
|
if _, ok := names["stale"]; ok {
|
||||||
|
t.Fatal("stale in-memory tool should not survive reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTool("beta", "beta-cmd")
|
||||||
|
if err := ReloadSecurityToolsFromDir(cfg, configPath); err != nil {
|
||||||
|
t.Fatalf("second reload: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Security.Tools) != 3 {
|
||||||
|
t.Fatalf("expected 3 tools after add, got %d", len(cfg.Security.Tools))
|
||||||
|
}
|
||||||
|
foundBeta := false
|
||||||
|
for _, tool := range cfg.Security.Tools {
|
||||||
|
if tool.Name == "beta" {
|
||||||
|
foundBeta = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundBeta {
|
||||||
|
t.Fatal("beta tool not found after second reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeToolsFromDir_DirOverridesInline(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
toolsDir := filepath.Join(root, "tools")
|
||||||
|
if err := os.MkdirAll(toolsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := "name: shared\ncommand: dir-cmd\nenabled: true\ndescription: from dir\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(toolsDir, "shared.yaml"), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline := []ToolConfig{
|
||||||
|
{Name: "shared", Command: "inline-cmd", Enabled: true, Description: "from inline"},
|
||||||
|
}
|
||||||
|
merged, err := MergeToolsFromDir(toolsDir, inline)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(merged) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool, got %d", len(merged))
|
||||||
|
}
|
||||||
|
if merged[0].Command != "dir-cmd" {
|
||||||
|
t.Fatalf("dir tool should win, got command %q", merged[0].Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -577,6 +578,19 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
|||||||
return conversations, rows.Err()
|
return conversations, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConversationTitle 获取对话标题(轻量查询,不加载消息)
|
||||||
|
func (db *DB) GetConversationTitle(id string) (string, error) {
|
||||||
|
var title string
|
||||||
|
err := db.QueryRow("SELECT title FROM conversations WHERE id = ?", id).Scan(&title)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", fmt.Errorf("对话不存在")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("查询对话标题失败: %w", err)
|
||||||
|
}
|
||||||
|
return title, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateConversationTitle 更新对话标题
|
// UpdateConversationTitle 更新对话标题
|
||||||
func (db *DB) UpdateConversationTitle(id, title string) error {
|
func (db *DB) UpdateConversationTitle(id, title string) error {
|
||||||
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
||||||
@@ -1057,6 +1071,77 @@ type ProcessDetail struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTurnUserMessage 返回锚点消息所在轮次中的用户原文(最近一条 user 消息,不含完整历史)。
|
||||||
|
func (db *DB) GetTurnUserMessage(conversationID, anchorMessageID string) (string, error) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
anchorMessageID = strings.TrimSpace(anchorMessageID)
|
||||||
|
if conversationID == "" || anchorMessageID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var content string
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT m.content FROM messages m
|
||||||
|
WHERE m.conversation_id = ? AND m.role = 'user'
|
||||||
|
AND m.created_at <= COALESCE((SELECT created_at FROM messages WHERE id = ? AND conversation_id = ?), m.created_at)
|
||||||
|
ORDER BY m.created_at DESC, m.rowid DESC
|
||||||
|
LIMIT 1`, conversationID, anchorMessageID, conversationID).Scan(&content)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("query turn user message: %w", err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistantCognitionTexts 单条助手消息上的思考/推理/规划文本。
|
||||||
|
type AssistantCognitionTexts struct {
|
||||||
|
Thinking string
|
||||||
|
ReasoningChain string
|
||||||
|
Planning string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssistantCognitionTexts 聚合助手消息在 process_details 中的 thinking / reasoning_chain / planning。
|
||||||
|
func (db *DB) GetAssistantCognitionTexts(assistantMessageID string) (AssistantCognitionTexts, error) {
|
||||||
|
assistantMessageID = strings.TrimSpace(assistantMessageID)
|
||||||
|
if assistantMessageID == "" {
|
||||||
|
return AssistantCognitionTexts{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT event_type, message FROM process_details
|
||||||
|
WHERE message_id = ? AND event_type IN ('thinking', 'reasoning_chain', 'planning')
|
||||||
|
ORDER BY created_at ASC, rowid ASC`, assistantMessageID)
|
||||||
|
if err != nil {
|
||||||
|
return AssistantCognitionTexts{}, fmt.Errorf("query assistant cognition: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var thinkingParts, reasoningParts, planningParts []string
|
||||||
|
for rows.Next() {
|
||||||
|
var eventType, message string
|
||||||
|
if err := rows.Scan(&eventType, &message); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(message)
|
||||||
|
if msg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch eventType {
|
||||||
|
case "thinking":
|
||||||
|
thinkingParts = append(thinkingParts, msg)
|
||||||
|
case "reasoning_chain":
|
||||||
|
reasoningParts = append(reasoningParts, msg)
|
||||||
|
case "planning":
|
||||||
|
planningParts = append(planningParts, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AssistantCognitionTexts{
|
||||||
|
Thinking: strings.Join(thinkingParts, "\n\n"),
|
||||||
|
ReasoningChain: strings.Join(reasoningParts, "\n\n"),
|
||||||
|
Planning: strings.Join(planningParts, "\n\n"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddProcessDetail 添加过程详情事件
|
// AddProcessDetail 添加过程详情事件
|
||||||
func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error {
|
func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteHitlInterruptLogsByIDs deletes decided HITL audit logs by id (pending rows are skipped).
|
||||||
|
func (db *DB) DeleteHitlInterruptLogsByIDs(ids []string) (int64, error) {
|
||||||
|
if db == nil {
|
||||||
|
return 0, fmt.Errorf("database is nil")
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(clean)), ",")
|
||||||
|
q := fmt.Sprintf(`DELETE FROM hitl_interrupts WHERE status != 'pending' AND id IN (%s)`, placeholders)
|
||||||
|
args := make([]interface{}, len(clean))
|
||||||
|
for i, id := range clean {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
res, err := db.Exec(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Error("批量删除人机协同审计日志失败", zap.Error(err), zap.Int("count", len(clean)))
|
||||||
|
return 0, fmt.Errorf("批量删除人机协同审计日志失败: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHitlInterruptLogsMatching deletes decided logs matching whereSQL (e.g. "WHERE 1=1 AND status != 'pending' ...").
|
||||||
|
func (db *DB) DeleteHitlInterruptLogsMatching(whereSQL string, args []interface{}) (int64, error) {
|
||||||
|
if db == nil {
|
||||||
|
return 0, fmt.Errorf("database is nil")
|
||||||
|
}
|
||||||
|
whereSQL = strings.TrimSpace(whereSQL)
|
||||||
|
if whereSQL == "" {
|
||||||
|
return 0, fmt.Errorf("where clause is required")
|
||||||
|
}
|
||||||
|
q := `DELETE FROM hitl_interrupts ` + whereSQL
|
||||||
|
res, err := db.Exec(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Error("清空人机协同审计日志失败", zap.Error(err))
|
||||||
|
return 0, fmt.Errorf("清空人机协同审计日志失败: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeHitlInterruptLogsBefore deletes decided logs with decided/created time before cutoff.
|
||||||
|
func (db *DB) PurgeHitlInterruptLogsBefore(cutoff time.Time) (int64, error) {
|
||||||
|
if db == nil {
|
||||||
|
return 0, fmt.Errorf("database is nil")
|
||||||
|
}
|
||||||
|
res, err := db.Exec(
|
||||||
|
`DELETE FROM hitl_interrupts WHERE status != 'pending' AND datetime(COALESCE(decided_at, created_at)) < datetime(?)`,
|
||||||
|
cutoff.UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Error("清理过期人机协同审计日志失败", zap.Error(err))
|
||||||
|
return 0, fmt.Errorf("清理过期人机协同审计日志失败: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureHitlInterruptsTable(t *testing.T, db *DB) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
decision TEXT,
|
||||||
|
decision_comment TEXT,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
decided_at DATETIME
|
||||||
|
);`); err != nil {
|
||||||
|
t.Fatalf("create hitl_interrupts: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteHitlInterruptLogsByIDs_skipsPending(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ensureHitlInterruptsTable(t, db)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, mode, tool_name, status, created_at)
|
||||||
|
VALUES ('pending-1', 'c1', 'approval', 'exec', 'pending', ?)`, now); err != nil {
|
||||||
|
t.Fatalf("insert pending: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||||
|
VALUES ('done-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, now, now); err != nil {
|
||||||
|
t.Fatalf("insert decided: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := db.DeleteHitlInterruptLogsByIDs([]string{"pending-1", "done-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteHitlInterruptLogsByIDs: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("deleted = %d, want 1", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
if err := db.QueryRow(`SELECT status FROM hitl_interrupts WHERE id = 'pending-1'`).Scan(&status); err != nil {
|
||||||
|
t.Fatalf("pending row missing: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'done-1'`).Scan(new(string)); err == nil {
|
||||||
|
t.Fatal("decided row should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeHitlInterruptLogsBefore(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ensureHitlInterruptsTable(t, db)
|
||||||
|
|
||||||
|
old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
|
||||||
|
recent := time.Now().AddDate(0, 0, -1).UTC().Format(time.RFC3339)
|
||||||
|
for _, row := range []struct{ id, decided string }{
|
||||||
|
{"old-1", old},
|
||||||
|
{"new-1", recent},
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||||
|
VALUES (?, 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, row.id, row.decided, row.decided); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", row.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -90)
|
||||||
|
deleted, err := db.PurgeHitlInterruptLogsBefore(cutoff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PurgeHitlInterruptLogsBefore: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("deleted = %d, want 1", deleted)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err == nil {
|
||||||
|
t.Fatal("old row should be purged")
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'new-1'`).Scan(new(string)); err != nil {
|
||||||
|
t.Fatalf("new row should remain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,19 +111,43 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountProjects 统计项目数量。
|
func projectListSearchPattern(q string) string {
|
||||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
q = strings.TrimSpace(q)
|
||||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
if q == "" {
|
||||||
args := []interface{}{}
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('%')
|
||||||
|
for _, r := range q {
|
||||||
|
switch r {
|
||||||
|
case '%', '_', '\\':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('%')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendProjectListFilters(query string, args []interface{}, status, search string) (string, []interface{}) {
|
||||||
if s := strings.TrimSpace(status); s != "" {
|
if s := strings.TrimSpace(status); s != "" {
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
args = append(args, s)
|
args = append(args, s)
|
||||||
}
|
}
|
||||||
if q := strings.TrimSpace(search); q != "" {
|
if pattern := projectListSearchPattern(search); pattern != "" {
|
||||||
pattern := "%" + q + "%"
|
query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')`
|
||||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
args = append(args, pattern, pattern, pattern)
|
||||||
args = append(args, pattern, pattern)
|
|
||||||
}
|
}
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountProjects 统计项目数量。
|
||||||
|
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||||
|
args := []interface{}{}
|
||||||
|
query, args = appendProjectListFilters(query, args, status, search)
|
||||||
var count int
|
var count int
|
||||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||||
@@ -139,15 +163,7 @@ func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project
|
|||||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||||
FROM projects WHERE 1=1`
|
FROM projects WHERE 1=1`
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
if s := strings.TrimSpace(status); s != "" {
|
query, args = appendProjectListFilters(query, args, status, search)
|
||||||
query += " AND status = ?"
|
|
||||||
args = append(args, s)
|
|
||||||
}
|
|
||||||
if q := strings.TrimSpace(search); q != "" {
|
|
||||||
pattern := "%" + q + "%"
|
|
||||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
|
||||||
args = append(args, pattern, pattern)
|
|
||||||
}
|
|
||||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListProjectsSearchCaseInsensitive(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "projects-search.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p1, err := db.CreateProject(&Project{Name: "Alpha Security Review", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p2, err := db.CreateProject(&Project{Name: "beta-scan", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.CreateProject(&Project{Name: "Other", Status: "archived"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
search string
|
||||||
|
status string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "case insensitive name", search: "alpha", status: "active", want: []string{p1.ID}},
|
||||||
|
{name: "upper query", search: "BETA", status: "active", want: []string{p2.ID}},
|
||||||
|
{name: "search by id substring", search: p1.ID[:8], status: "", want: []string{p1.ID}},
|
||||||
|
{name: "status filter", search: "alpha", status: "archived", want: nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
list, err := db.ListProjects(tc.status, tc.search, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := make([]string, 0, len(list))
|
||||||
|
for _, p := range list {
|
||||||
|
got = append(got, p.ID)
|
||||||
|
}
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("got %v want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("got %v want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectListSearchPatternEscapesWildcards(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "projects-like.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&Project{Name: "100% coverage", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := db.ListProjects("active", "100%", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != p.ID {
|
||||||
|
t.Fatalf("expected exact match for literal %% query, got %#v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,13 @@ type responsePlanAgg struct {
|
|||||||
b strings.Builder
|
b strings.Builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
||||||
|
type thinkingBuf struct {
|
||||||
|
b strings.Builder
|
||||||
|
meta map[string]interface{}
|
||||||
|
persistAs string // "thinking" | "reasoning_chain"
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeProcessDetailText(s string) string {
|
func normalizeProcessDetailText(s string) string {
|
||||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
s = strings.ReplaceAll(s, "\r", "\n")
|
s = strings.ReplaceAll(s, "\r", "\n")
|
||||||
@@ -179,6 +186,8 @@ type AgentHandler struct {
|
|||||||
batchCronParser cron.Parser
|
batchCronParser cron.Parser
|
||||||
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
||||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||||
|
hitlStrategySaver HitlAuditStrategySaver
|
||||||
|
auditLLM *openai.Client
|
||||||
audit *audit.Service
|
audit *audit.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +227,10 @@ func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
|
// HitlToolWhitelistSaver 合并/设置 HITL 免审批工具到全局配置并落盘
|
||||||
type HitlToolWhitelistSaver interface {
|
type HitlToolWhitelistSaver interface {
|
||||||
MergeHitlToolWhitelistIntoConfig(add []string) error
|
MergeHitlToolWhitelistIntoConfig(add []string) error
|
||||||
|
SetHitlToolWhitelist(tools []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentHandler 创建新的Agent处理器
|
// NewAgentHandler 创建新的Agent处理器
|
||||||
@@ -236,6 +246,11 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
|||||||
bus := NewTaskEventBus()
|
bus := NewTaskEventBus()
|
||||||
tm := NewAgentTaskManager()
|
tm := NewAgentTaskManager()
|
||||||
tm.SetTaskEventBus(bus)
|
tm.SetTaskEventBus(bus)
|
||||||
|
llmHTTP := &http.Client{Timeout: 2 * time.Minute}
|
||||||
|
var llmCfg *config.OpenAIConfig
|
||||||
|
if cfg != nil {
|
||||||
|
llmCfg = &cfg.OpenAI
|
||||||
|
}
|
||||||
handler := &AgentHandler{
|
handler := &AgentHandler{
|
||||||
agent: agent,
|
agent: agent,
|
||||||
db: db,
|
db: db,
|
||||||
@@ -246,6 +261,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
hitlManager: NewHITLManager(db, logger),
|
hitlManager: NewHITLManager(db, logger),
|
||||||
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
||||||
|
auditLLM: openai.NewClient(llmCfg, llmHTTP, logger),
|
||||||
}
|
}
|
||||||
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
|
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
|
||||||
if err := handler.hitlManager.EnsureSchema(); err != nil {
|
if err := handler.hitlManager.EnsureSchema(); err != nil {
|
||||||
@@ -320,6 +336,7 @@ func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientInten
|
|||||||
type HITLRequest struct {
|
type HITLRequest struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Reviewer string `json:"reviewer,omitempty"` // human | audit_agent
|
||||||
SensitiveTools []string `json:"sensitiveTools,omitempty"`
|
SensitiveTools []string `json:"sensitiveTools,omitempty"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -849,11 +866,6 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
|
|
||||||
// thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*(Eino ReasoningContent):
|
// thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*(Eino ReasoningContent):
|
||||||
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。
|
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。
|
||||||
type thinkingBuf struct {
|
|
||||||
b strings.Builder
|
|
||||||
meta map[string]interface{}
|
|
||||||
persistAs string // "thinking" | "reasoning_chain"
|
|
||||||
}
|
|
||||||
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
||||||
flushedThinking := make(map[string]bool) // streamId -> flushed
|
flushedThinking := make(map[string]bool) // streamId -> flushed
|
||||||
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
||||||
@@ -866,6 +878,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
||||||
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||||
var respPlan responsePlanAgg
|
var respPlan responsePlanAgg
|
||||||
|
if assistantMessageID != "" {
|
||||||
|
h.tasks.SetHitlAssistantMessageID(conversationID, assistantMessageID)
|
||||||
|
}
|
||||||
|
syncHitlCognition := func() {
|
||||||
|
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
||||||
|
}
|
||||||
flushResponsePlan := func() {
|
flushResponsePlan := func() {
|
||||||
if assistantMessageID == "" {
|
if assistantMessageID == "" {
|
||||||
return
|
return
|
||||||
@@ -885,6 +903,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||||
}
|
}
|
||||||
|
syncHitlCognition()
|
||||||
respPlan.meta = nil
|
respPlan.meta = nil
|
||||||
respPlan.b.Reset()
|
respPlan.b.Reset()
|
||||||
}
|
}
|
||||||
@@ -921,6 +940,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
flushedThinking[sid] = true
|
flushedThinking[sid] = true
|
||||||
}
|
}
|
||||||
|
syncHitlCognition()
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(eventType, message string, data interface{}) {
|
return func(eventType, message string, data interface{}) {
|
||||||
@@ -981,6 +1001,25 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if eventType == "tool_result" {
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
toolName, _ := dataMap["toolName"].(string)
|
||||||
|
toolCallID, _ := dataMap["toolCallId"].(string)
|
||||||
|
success := true
|
||||||
|
if v, ok := dataMap["success"].(bool); ok {
|
||||||
|
success = v
|
||||||
|
}
|
||||||
|
resultText := ""
|
||||||
|
if r, ok := dataMap["result"].(string); ok {
|
||||||
|
resultText = r
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(resultText) == "" {
|
||||||
|
resultText = message
|
||||||
|
}
|
||||||
|
h.recordHitlToolExecutionResult(conversationID, toolCallID, toolName, success, resultText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理知识检索日志记录
|
// 处理知识检索日志记录
|
||||||
if eventType == "tool_result" && h.knowledgeManager != nil {
|
if eventType == "tool_result" && h.knowledgeManager != nil {
|
||||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
@@ -1188,6 +1227,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
respPlan.meta[k] = v
|
respPlan.meta[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncHitlCognition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if eventType == "response" {
|
if eventType == "response" {
|
||||||
@@ -1257,6 +1297,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncHitlCognition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,17 +1530,51 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichAgentTasksWithConversationTitles 为任务列表附加当前会话标题(供顶栏/任务页展示,重命名后自动同步)
|
||||||
|
func (h *AgentHandler) enrichAgentTasksWithConversationTitles(tasks []*AgentTask) {
|
||||||
|
if h == nil || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task == nil || strings.TrimSpace(task.ConversationID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if title, err := h.db.GetConversationTitle(task.ConversationID); err == nil {
|
||||||
|
task.Title = strings.TrimSpace(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichCompletedTasksWithConversationTitles 为已完成任务附加当前会话标题
|
||||||
|
func (h *AgentHandler) enrichCompletedTasksWithConversationTitles(tasks []*CompletedTask) {
|
||||||
|
if h == nil || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task == nil || strings.TrimSpace(task.ConversationID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if title, err := h.db.GetConversationTitle(task.ConversationID); err == nil {
|
||||||
|
task.Title = strings.TrimSpace(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListAgentTasks 列出所有运行中的任务
|
// ListAgentTasks 列出所有运行中的任务
|
||||||
func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
|
func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
|
||||||
|
tasks := h.tasks.GetActiveTasks()
|
||||||
|
h.enrichAgentTasksWithConversationTitles(tasks)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"tasks": h.tasks.GetActiveTasks(),
|
"tasks": tasks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCompletedTasks 列出最近完成的任务历史
|
// ListCompletedTasks 列出最近完成的任务历史
|
||||||
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
|
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
|
||||||
|
tasks := h.tasks.GetCompletedTasks()
|
||||||
|
h.enrichCompletedTasksWithConversationTitles(tasks)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"tasks": h.tasks.GetCompletedTasks(),
|
"tasks": tasks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -798,6 +798,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新机器人配置
|
// 更新机器人配置
|
||||||
if req.Robots != nil {
|
if req.Robots != nil {
|
||||||
|
if err := config.ValidateWecomConfig(req.Robots.Wecom); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
h.config.Robots = *req.Robots
|
h.config.Robots = *req.Robots
|
||||||
h.logger.Info("更新机器人配置",
|
h.logger.Info("更新机器人配置",
|
||||||
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
|
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
|
||||||
@@ -1329,6 +1333,17 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
h.logger.Info("已更新嵌入模型配置记录")
|
h.logger.Info("已更新嵌入模型配置记录")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 tools 目录重新加载工具配置(新增/修改/删除 yaml 后无需重启)
|
||||||
|
if err := config.ReloadSecurityToolsFromDir(h.config, h.configPath); err != nil {
|
||||||
|
h.logger.Error("重新加载工具配置失败", zap.Error(err))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordFail(c, "config", "apply", "应用配置失败:重新加载工具", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "重新加载工具配置失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("已从 tools 目录重新加载工具配置", zap.Int("tools_count", len(h.config.Security.Tools)))
|
||||||
|
|
||||||
// 重新注册工具(根据新的启用状态)
|
// 重新注册工具(根据新的启用状态)
|
||||||
h.logger.Info("重新注册工具")
|
h.logger.Info("重新注册工具")
|
||||||
|
|
||||||
@@ -1417,12 +1432,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新检索器配置(如果知识库启用)
|
// 更新检索器配置(如果知识库启用)
|
||||||
if h.config.Knowledge.Enabled && h.retrieverUpdater != nil {
|
if h.config.Knowledge.Enabled && h.retrieverUpdater != nil {
|
||||||
retrievalConfig := &knowledge.RetrievalConfig{
|
retrievalConfig := knowledge.RetrievalConfigFromYAML(h.config.Knowledge.Retrieval)
|
||||||
TopK: h.config.Knowledge.Retrieval.TopK,
|
|
||||||
SimilarityThreshold: h.config.Knowledge.Retrieval.SimilarityThreshold,
|
|
||||||
SubIndexFilter: h.config.Knowledge.Retrieval.SubIndexFilter,
|
|
||||||
PostRetrieve: h.config.Knowledge.Retrieval.PostRetrieve,
|
|
||||||
}
|
|
||||||
h.retrieverUpdater.UpdateConfig(retrievalConfig)
|
h.retrieverUpdater.UpdateConfig(retrievalConfig)
|
||||||
h.logger.Info("检索器配置已更新",
|
h.logger.Info("检索器配置已更新",
|
||||||
zap.Int("top_k", retrievalConfig.TopK),
|
zap.Int("top_k", retrievalConfig.TopK),
|
||||||
@@ -1705,6 +1715,13 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
|||||||
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
||||||
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
||||||
setStringInMap(retrievalNode, "sub_index_filter", cfg.Retrieval.SubIndexFilter)
|
setStringInMap(retrievalNode, "sub_index_filter", cfg.Retrieval.SubIndexFilter)
|
||||||
|
mqNode := ensureMap(retrievalNode, "multi_query")
|
||||||
|
setIntInMap(mqNode, "max_queries", cfg.Retrieval.MultiQuery.MaxQueries)
|
||||||
|
rerankNode := ensureMap(retrievalNode, "rerank")
|
||||||
|
setStringInMap(rerankNode, "provider", cfg.Retrieval.Rerank.Provider)
|
||||||
|
setStringInMap(rerankNode, "model", cfg.Retrieval.Rerank.Model)
|
||||||
|
setStringInMap(rerankNode, "base_url", cfg.Retrieval.Rerank.BaseURL)
|
||||||
|
setStringInMap(rerankNode, "api_key", cfg.Retrieval.Rerank.APIKey)
|
||||||
postNode := ensureMap(retrievalNode, "post_retrieve")
|
postNode := ensureMap(retrievalNode, "post_retrieve")
|
||||||
setIntInMap(postNode, "prefetch_top_k", cfg.Retrieval.PostRetrieve.PrefetchTopK)
|
setIntInMap(postNode, "prefetch_top_k", cfg.Retrieval.PostRetrieve.PrefetchTopK)
|
||||||
setIntInMap(postNode, "max_context_chars", cfg.Retrieval.PostRetrieve.MaxContextChars)
|
setIntInMap(postNode, "max_context_chars", cfg.Retrieval.PostRetrieve.MaxContextChars)
|
||||||
@@ -1751,6 +1768,20 @@ func mergeHitlToolWhitelistSlice(existing, add []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHitlToolWhitelist 将全局免审批工具白名单整表写入 config.yaml(替换,非合并)。
|
||||||
|
func (h *ConfigHandler) SetHitlToolWhitelist(tools []string) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, tools)
|
||||||
|
if err := h.saveConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.logger.Info("HITL 全局工具白名单已写入配置文件",
|
||||||
|
zap.Int("count", len(h.config.Hitl.ToolWhitelist)),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MergeHitlToolWhitelistIntoConfig 将会话侧栏提交的免审批工具名合并进内存配置并写入 config.yaml(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。
|
// MergeHitlToolWhitelistIntoConfig 将会话侧栏提交的免审批工具名合并进内存配置并写入 config.yaml(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。
|
||||||
func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
|
func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
@@ -1771,6 +1802,21 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
|
|||||||
hitlNode := ensureMap(root, "hitl")
|
hitlNode := ensureMap(root, "hitl")
|
||||||
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
||||||
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
||||||
|
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
|
||||||
|
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateHitlAuditAgentStrategy 更新审批/审查编辑两套审计 Agent 提示词并写入 config.yaml。
|
||||||
|
func (h *ConfigHandler) UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(approvalPrompt)
|
||||||
|
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(reviewEditPrompt)
|
||||||
|
if err := h.saveConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.logger.Info("HITL 审计 Agent 提示词已写入配置文件")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rebindEinoRunningTask 中断并继续 / 空正文续跑:重建 cancel 链与超时 ctx,保持任务 running。
|
||||||
|
func (h *AgentHandler) rebindEinoRunningTask(conversationID string, timeoutCancel context.CancelFunc) (context.Context, context.CancelCauseFunc, context.Context, context.CancelFunc) {
|
||||||
|
if timeoutCancel != nil {
|
||||||
|
timeoutCancel()
|
||||||
|
}
|
||||||
|
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, newTimeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
return baseCtx, cancelWithCause, taskCtx, newTimeoutCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryContinueOnEinoEmptyResponse Run 成功但 Response 为 emptyHint 时退避续跑;true 表示已准备下一段 Run。
|
||||||
|
func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
||||||
|
taskCtx context.Context,
|
||||||
|
mw *config.MultiAgentEinoMiddlewareConfig,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
attempt *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) bool {
|
||||||
|
if result == nil || !multiagent.IsEinoEmptyResponseResult(result) || !multiagent.HasEinoResumeTrace(result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
maxAttempts := multiagent.EmptyResponseContinueMaxAttemptsFromConfig(mw)
|
||||||
|
if *attempt >= maxAttempts {
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Warn("eino empty response continue exhausted",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Int("maxAttempts", maxAttempts))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*attempt++
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
|
||||||
|
backoff := multiagent.EmptyResponseContinueBackoff(*attempt-1, mw)
|
||||||
|
waitMsg := fmt.Sprintf("会话已结束但未捕获到助手正文,%d 秒后第 %d/%d 次自动续跑…",
|
||||||
|
int(backoff.Seconds()), *attempt, maxAttempts)
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("eino_empty_response_continue", waitMsg, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": maxAttempts,
|
||||||
|
"backoffSec": int(backoff.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
|
||||||
|
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
||||||
|
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": maxAttempts,
|
||||||
|
"contextSource": "empty_response_continue",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -239,6 +240,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-89
@@ -23,6 +23,7 @@ import (
|
|||||||
type hitlRuntimeConfig struct {
|
type hitlRuntimeConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Mode string
|
Mode string
|
||||||
|
Reviewer string
|
||||||
SensitiveTools map[string]struct{}
|
SensitiveTools map[string]struct{}
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,8 @@ type HITLManager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
runtime map[string]hitlRuntimeConfig
|
runtime map[string]hitlRuntimeConfig
|
||||||
pending map[string]*pendingInterrupt
|
pending map[string]*pendingInterrupt
|
||||||
|
// approvedExec 审批通过、待回写 tool_result 的队列(按会话 FIFO)
|
||||||
|
approvedExec map[string][]hitlApprovedExecTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager {
|
func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager {
|
||||||
@@ -90,6 +93,7 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
m.migrateHitlSchemaColumns()
|
||||||
|
|
||||||
// On startup, cancel all orphaned pending interrupts from previous process.
|
// On startup, cancel all orphaned pending interrupts from previous process.
|
||||||
// Their in-memory channels are gone, so they can never be resolved.
|
// Their in-memory channels are gone, so they can never be resolved.
|
||||||
@@ -141,6 +145,7 @@ func (m *HITLManager) ActivateConversation(conversationID string, req *HITLReque
|
|||||||
m.runtime[conversationID] = hitlRuntimeConfig{
|
m.runtime[conversationID] = hitlRuntimeConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Mode: normalizeHitlMode(req.Mode),
|
Mode: normalizeHitlMode(req.Mode),
|
||||||
|
Reviewer: normalizeHitlReviewer(req.Reviewer),
|
||||||
SensitiveTools: tools,
|
SensitiveTools: tools,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
@@ -153,17 +158,14 @@ func (m *HITLManager) DeactivateConversation(conversationID string) {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空)。
|
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空),并合并内置元工具免审批项。
|
||||||
func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
||||||
if h == nil || h.config == nil {
|
if h == nil || h.config == nil {
|
||||||
return nil
|
return multiagent.MergeHitlExemptMetaTools(nil)
|
||||||
}
|
}
|
||||||
raw := h.config.Hitl.ToolWhitelist
|
raw := h.config.Hitl.ToolWhitelist
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
out := make([]string, 0, len(raw))
|
out := make([]string, 0, len(raw)+len(multiagent.HitlExemptMetaTools))
|
||||||
for _, t := range raw {
|
for _, t := range raw {
|
||||||
n := strings.ToLower(strings.TrimSpace(t))
|
n := strings.ToLower(strings.TrimSpace(t))
|
||||||
if n == "" {
|
if n == "" {
|
||||||
@@ -175,44 +177,35 @@ func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
|||||||
seen[n] = struct{}{}
|
seen[n] = struct{}{}
|
||||||
out = append(out, strings.TrimSpace(t))
|
out = append(out, strings.TrimSpace(t))
|
||||||
}
|
}
|
||||||
return out
|
return multiagent.MergeHitlExemptMetaTools(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hitlRequestWithMergedConfigWhitelist 将会话/API 中的白名单与 config.yaml 全局白名单合并(并集),仅用于运行时 Activate;不写入数据库。
|
// hitlRequestWithMergedConfigWhitelist 将会话/API 中的白名单与 config.yaml 全局白名单及内置元工具免审批项合并(并集),仅用于运行时 Activate;不写入数据库。
|
||||||
func (h *AgentHandler) hitlRequestWithMergedConfigWhitelist(req *HITLRequest) *HITLRequest {
|
func (h *AgentHandler) hitlRequestWithMergedConfigWhitelist(req *HITLRequest) *HITLRequest {
|
||||||
gw := h.hitlConfigGlobalToolWhitelist()
|
|
||||||
if len(gw) == 0 {
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
union := make([]string, 0, len(gw)+len(req.SensitiveTools))
|
union := make([]string, 0, len(req.SensitiveTools)+16)
|
||||||
for _, t := range gw {
|
add := func(t string) {
|
||||||
n := strings.ToLower(strings.TrimSpace(t))
|
n := strings.ToLower(strings.TrimSpace(t))
|
||||||
if n == "" {
|
if n == "" {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if _, ok := seen[n]; ok {
|
if _, ok := seen[n]; ok {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
seen[n] = struct{}{}
|
seen[n] = struct{}{}
|
||||||
union = append(union, strings.TrimSpace(t))
|
union = append(union, strings.TrimSpace(t))
|
||||||
}
|
}
|
||||||
|
for _, t := range h.hitlConfigGlobalToolWhitelist() {
|
||||||
|
add(t)
|
||||||
|
}
|
||||||
for _, t := range req.SensitiveTools {
|
for _, t := range req.SensitiveTools {
|
||||||
n := strings.ToLower(strings.TrimSpace(t))
|
add(t)
|
||||||
if n == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[n]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[n] = struct{}{}
|
|
||||||
union = append(union, strings.TrimSpace(t))
|
|
||||||
}
|
}
|
||||||
out := *req
|
out := *req
|
||||||
out.SensitiveTools = union
|
out.SensitiveTools = multiagent.MergeHitlExemptMetaTools(union)
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,22 +355,22 @@ func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLReq
|
|||||||
timeout = 0
|
timeout = 0
|
||||||
}
|
}
|
||||||
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
|
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
|
||||||
(conversation_id, enabled, mode, sensitive_tools, timeout_seconds, updated_at)
|
(conversation_id, enabled, mode, reviewer, sensitive_tools, timeout_seconds, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||||
enabled=excluded.enabled, mode=excluded.mode, sensitive_tools=excluded.sensitive_tools, timeout_seconds=excluded.timeout_seconds, updated_at=excluded.updated_at`,
|
enabled=excluded.enabled, mode=excluded.mode, reviewer=excluded.reviewer, sensitive_tools=excluded.sensitive_tools, timeout_seconds=excluded.timeout_seconds, updated_at=excluded.updated_at`,
|
||||||
conversationID, boolToInt(req.Enabled), mode, string(tools), timeout, time.Now())
|
conversationID, boolToInt(req.Enabled), mode, normalizeHitlReviewer(req.Reviewer), string(tools), timeout, time.Now())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) {
|
func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) {
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var mode, toolsJSON string
|
var mode, reviewer, toolsJSON string
|
||||||
var timeout int
|
var timeout int
|
||||||
err := m.db.QueryRow(`SELECT enabled, mode, sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
err := m.db.QueryRow(`SELECT enabled, mode, COALESCE(reviewer,'human'), sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
||||||
Scan(&enabledInt, &mode, &toolsJSON, &timeout)
|
Scan(&enabledInt, &mode, &reviewer, &toolsJSON, &timeout)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return &HITLRequest{Enabled: false, Mode: "off", SensitiveTools: []string{}, TimeoutSeconds: 0}, nil
|
return &HITLRequest{Enabled: false, Mode: "off", Reviewer: "human", SensitiveTools: []string{}, TimeoutSeconds: 0}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,6 +383,7 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
|
|||||||
return &HITLRequest{
|
return &HITLRequest{
|
||||||
Enabled: enabledInt == 1,
|
Enabled: enabledInt == 1,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
|
Reviewer: normalizeHitlReviewer(reviewer),
|
||||||
SensitiveTools: tools,
|
SensitiveTools: tools,
|
||||||
TimeoutSeconds: timeout,
|
TimeoutSeconds: timeout,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -413,15 +407,16 @@ func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, tim
|
|||||||
if p.Mode != "review_edit" && len(d.EditedArguments) > 0 {
|
if p.Mode != "review_edit" && len(d.EditedArguments) > 0 {
|
||||||
d.EditedArguments = nil
|
d.EditedArguments = nil
|
||||||
}
|
}
|
||||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=? WHERE id=?`,
|
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='human' WHERE id=?`,
|
||||||
d.Decision, d.Comment, time.Now(), p.InterruptID)
|
d.Decision, d.Comment, time.Now(), p.InterruptID)
|
||||||
return d, nil
|
return d, nil
|
||||||
case <-timeoutCh:
|
case <-timeoutCh:
|
||||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='approve', decision_comment='timeout auto approve', decided_at=? WHERE id=?`,
|
comment := "HITL timeout auto-reject for safety"
|
||||||
time.Now(), p.InterruptID)
|
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='reject', decision_comment=?, decided_at=?, decided_by='system' WHERE id=?`,
|
||||||
return hitlDecision{Decision: "approve", Comment: "timeout auto approve"}, nil
|
comment, time.Now(), p.InterruptID)
|
||||||
|
return hitlDecision{Decision: "reject", Comment: comment}, nil
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', decision_comment='task cancelled', decided_at=? WHERE id=?`,
|
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', decision_comment='task cancelled', decided_at=?, decided_by='system' WHERE id=?`,
|
||||||
time.Now(), p.InterruptID)
|
time.Now(), p.InterruptID)
|
||||||
return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err()
|
return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err()
|
||||||
}
|
}
|
||||||
@@ -445,12 +440,57 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
|||||||
if !need {
|
if !need {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
||||||
payloadRaw, _ := json.Marshal(payload)
|
payloadRaw, _ := json.Marshal(payload)
|
||||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Reviewer == "audit_agent" {
|
||||||
|
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
||||||
|
ad.Decision, ad.Comment, now, p.InterruptID)
|
||||||
|
if sendEventFunc != nil {
|
||||||
|
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"decision": ad.Decision,
|
||||||
|
"comment": ad.Comment,
|
||||||
|
"editedArgs": ad.EditedArguments,
|
||||||
|
"decidedBy": "audit_agent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ad.Decision == "reject" {
|
||||||
|
if sendEventFunc != nil {
|
||||||
|
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"comment": ad.Comment,
|
||||||
|
"decidedBy": "audit_agent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &ad, nil
|
||||||
|
}
|
||||||
|
if sendEventFunc != nil {
|
||||||
|
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"comment": ad.Comment,
|
||||||
|
"editedArgs": ad.EditedArguments,
|
||||||
|
"decidedBy": "audit_agent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||||
|
return &ad, nil
|
||||||
|
}
|
||||||
|
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -479,8 +519,12 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
|||||||
return nil, waitErr
|
return nil, waitErr
|
||||||
}
|
}
|
||||||
if d.Decision == "reject" {
|
if d.Decision == "reject" {
|
||||||
|
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
||||||
|
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
|
||||||
|
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
||||||
|
}
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc("hitl_rejected", "人工拒绝本次工具调用,模型将基于反馈继续迭代", map[string]interface{}{
|
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"interruptId": p.InterruptID,
|
"interruptId": p.InterruptID,
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
@@ -498,6 +542,7 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
|||||||
"editedArgs": d.EditedArguments,
|
"editedArgs": d.EditedArguments,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,11 +572,6 @@ func (h *AgentHandler) handleHITLToolCall(runCtx context.Context, cancelRun cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
||||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
|
||||||
status := strings.TrimSpace(c.Query("status"))
|
|
||||||
if status == "" {
|
|
||||||
status = "pending"
|
|
||||||
}
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -539,15 +579,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
|||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, created_at, decided_at FROM hitl_interrupts WHERE 1=1`
|
q, args := h.buildHitlListQuery(false)
|
||||||
args := []interface{}{}
|
q, args = h.appendHitlListFilters(q, args, c)
|
||||||
if conversationID != "" {
|
total, err := h.countHitlQuery(q, args)
|
||||||
q += " AND conversation_id = ?"
|
if err != nil {
|
||||||
args = append(args, conversationID)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if status != "all" {
|
|
||||||
q += " AND status = ?"
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
}
|
||||||
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, pageSize, offset)
|
args = append(args, pageSize, offset)
|
||||||
@@ -557,41 +594,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := make([]map[string]interface{}, 0)
|
items, err := h.scanHitlInterruptRows(rows)
|
||||||
for rows.Next() {
|
if err != nil {
|
||||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus string
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
var messageID sql.NullString
|
return
|
||||||
var decision, comment sql.NullString
|
|
||||||
var createdAt time.Time
|
|
||||||
var decidedAt sql.NullTime
|
|
||||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &createdAt, &decidedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msgID := ""
|
|
||||||
if messageID.Valid {
|
|
||||||
msgID = messageID.String
|
|
||||||
}
|
|
||||||
items = append(items, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"conversationId": cid,
|
|
||||||
"messageId": msgID,
|
|
||||||
"mode": mode,
|
|
||||||
"toolName": toolName,
|
|
||||||
"toolCallId": toolCallID,
|
|
||||||
"payload": payload,
|
|
||||||
"status": rowStatus,
|
|
||||||
"decision": decision.String,
|
|
||||||
"comment": comment.String,
|
|
||||||
"createdAt": createdAt,
|
|
||||||
"decidedAt": func() interface{} {
|
|
||||||
if decidedAt.Valid {
|
|
||||||
return decidedAt.Time
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize})
|
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total})
|
||||||
}
|
}
|
||||||
|
|
||||||
type hitlDecisionReq struct {
|
type hitlDecisionReq struct {
|
||||||
@@ -636,7 +644,7 @@ func (h *AgentHandler) DismissHITLInterrupt(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := h.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
res, err := h.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
||||||
decision_comment='dismissed by user', decided_at=CURRENT_TIMESTAMP
|
decision_comment='dismissed by user', decided_at=CURRENT_TIMESTAMP, decided_by='human'
|
||||||
WHERE id=? AND status='pending'`, req.InterruptID)
|
WHERE id=? AND status='pending'`, req.InterruptID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
@@ -732,6 +740,7 @@ func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Mode = normalizeHitlMode(req.Mode)
|
req.Mode = normalizeHitlMode(req.Mode)
|
||||||
|
req.Reviewer = normalizeHitlReviewer(req.Reviewer)
|
||||||
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
|
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -753,6 +762,44 @@ type mergeHitlGlobalWhitelistReq struct {
|
|||||||
SensitiveTools []string `json:"sensitiveTools"`
|
SensitiveTools []string `json:"sensitiveTools"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setHitlGlobalWhitelistReq struct {
|
||||||
|
ToolWhitelist []string `json:"toolWhitelist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHITLGlobalToolWhitelist 返回 config.yaml 中的全局免审批工具白名单。
|
||||||
|
func (h *AgentHandler) GetHITLGlobalToolWhitelist(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
|
||||||
|
func (h *AgentHandler) SetHITLGlobalToolWhitelist(c *gin.Context) {
|
||||||
|
if h.hitlWhitelistSaver == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req setHitlGlobalWhitelistReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.hitlWhitelistSaver.SetHitlToolWhitelist(req.ToolWhitelist); err != nil {
|
||||||
|
h.logger.Warn("写入 HITL 工具白名单到 config.yaml 失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "hitl", "tool_whitelist_update", "HITL 全局白名单更新", "hitl_config", "tool_whitelist", nil)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||||
|
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||||
|
"hitlGlobalWhitelistMerged": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// MergeHITLGlobalToolWhitelist 无会话 ID 时将侧栏提交的免审批工具合并进 config.yaml(与 PUT /hitl/config 中白名单落盘规则一致)。
|
// MergeHITLGlobalToolWhitelist 无会话 ID 时将侧栏提交的免审批工具合并进 config.yaml(与 PUT /hitl/config 中白名单落盘规则一致)。
|
||||||
func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) {
|
func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) {
|
||||||
if h.hitlWhitelistSaver == nil {
|
if h.hitlWhitelistSaver == nil {
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditAgentReview 在 reviewer=audit_agent 时由 LLM 代行审批。
|
||||||
|
// 白名单工具在 shouldInterrupt 阶段已跳过,到达此处的一律需要裁决。
|
||||||
|
func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName string, payload map[string]interface{}) hitlDecision {
|
||||||
|
if h == nil {
|
||||||
|
return hitlDecision{Decision: "reject", Comment: "audit agent: handler unavailable"}
|
||||||
|
}
|
||||||
|
mode := normalizeHitlMode(hitlMode)
|
||||||
|
prompt := config.DefaultHitlAuditAgentPrompt()
|
||||||
|
if h.config != nil {
|
||||||
|
prompt = h.config.Hitl.EffectiveAuditAgentPromptForMode(mode)
|
||||||
|
}
|
||||||
|
if h.auditLLM == nil {
|
||||||
|
return hitlDecision{Decision: "reject", Comment: "audit agent: LLM 未配置"}
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
userContent := buildAuditAgentReviewInput(mode, toolName, payload)
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": h.auditLLMModel(),
|
||||||
|
"messages": []map[string]interface{}{
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": userContent},
|
||||||
|
},
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_completion_tokens": 1024,
|
||||||
|
// 审计裁决需要结构化 JSON;关闭 thinking 避免 Qwen 等把正文放进 reasoning_content 导致解析失败。
|
||||||
|
"thinking": map[string]interface{}{"type": "disabled"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := h.auditLLM.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
|
||||||
|
h.logger.Warn("审计 Agent LLM 调用失败", zap.Error(err), zap.String("tool", toolName))
|
||||||
|
return hitlDecision{
|
||||||
|
Decision: "reject",
|
||||||
|
Comment: "audit agent: LLM 调用失败,保守拒绝",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
return hitlDecision{Decision: "reject", Comment: "audit agent: LLM 无有效响应,保守拒绝"}
|
||||||
|
}
|
||||||
|
msg := apiResponse.Choices[0].Message
|
||||||
|
raw := strings.TrimSpace(msg.Content)
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(msg.ReasoningContent)
|
||||||
|
}
|
||||||
|
dec, err := parseAuditAgentLLMContent(raw)
|
||||||
|
if err != nil {
|
||||||
|
snippet := raw
|
||||||
|
if len(snippet) > 240 {
|
||||||
|
snippet = snippet[:240] + "..."
|
||||||
|
}
|
||||||
|
h.logger.Warn("审计 Agent 响应解析失败",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("tool", toolName),
|
||||||
|
zap.String("mode", mode),
|
||||||
|
zap.String("snippet", snippet),
|
||||||
|
)
|
||||||
|
return hitlDecision{Decision: "reject", Comment: "audit agent: 响应无法解析,保守拒绝"}
|
||||||
|
}
|
||||||
|
if mode != "review_edit" && len(dec.EditedArguments) > 0 {
|
||||||
|
h.logger.Warn("审计 Agent 在审批模式下返回 editedArguments,已忽略",
|
||||||
|
zap.String("tool", toolName),
|
||||||
|
)
|
||||||
|
dec.EditedArguments = nil
|
||||||
|
}
|
||||||
|
if dec.Comment == "" {
|
||||||
|
dec.Comment = "audit agent: " + dec.Decision
|
||||||
|
} else if !strings.HasPrefix(strings.ToLower(dec.Comment), "audit agent") {
|
||||||
|
dec.Comment = "audit agent: " + dec.Comment
|
||||||
|
}
|
||||||
|
return dec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) auditLLMModel() string {
|
||||||
|
if h.config != nil && strings.TrimSpace(h.config.OpenAI.Model) != "" {
|
||||||
|
return strings.TrimSpace(h.config.OpenAI.Model)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuditAgentReviewInput(hitlMode, toolName string, payload map[string]interface{}) string {
|
||||||
|
review := map[string]interface{}{
|
||||||
|
"hitlMode": normalizeHitlMode(hitlMode),
|
||||||
|
"toolName": strings.TrimSpace(toolName),
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
for _, k := range []string{"arguments", "argumentsObj", "command", hitlPayloadUserMessage, hitlPayloadThinking, hitlPayloadReasoningChain, hitlPayloadPlanning} {
|
||||||
|
if v, ok := payload[k]; ok && v != nil && fmt.Sprint(v) != "" {
|
||||||
|
review[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(review, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf(`{"hitlMode":%q,"toolName":%q}`, normalizeHitlMode(hitlMode), toolName)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuditAgentLLMContent(content string) (hitlDecision, error) {
|
||||||
|
s := strings.TrimSpace(content)
|
||||||
|
if s == "" {
|
||||||
|
return hitlDecision{}, errors.New("empty content")
|
||||||
|
}
|
||||||
|
for _, candidate := range auditAgentJSONCandidates(s) {
|
||||||
|
dec, comment, editedArgs, err := parseAuditAgentDecisionObject(candidate)
|
||||||
|
if err == nil {
|
||||||
|
return hitlDecision{
|
||||||
|
Decision: dec,
|
||||||
|
Comment: comment,
|
||||||
|
EditedArguments: editedArgs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hitlDecision{}, fmt.Errorf("no valid decision json in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditAgentJSONCandidates(s string) []string {
|
||||||
|
out := make([]string, 0, 4)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
add := func(c string) {
|
||||||
|
c = strings.TrimSpace(c)
|
||||||
|
if c == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[c]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[c] = struct{}{}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
add(s)
|
||||||
|
add(stripMarkdownCodeFence(s))
|
||||||
|
if obj := extractFirstJSONObject(s); obj != "" {
|
||||||
|
add(obj)
|
||||||
|
}
|
||||||
|
if obj := extractFirstJSONObject(stripMarkdownCodeFence(s)); obj != "" {
|
||||||
|
add(obj)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripMarkdownCodeFence(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
for _, fence := range []string{"```json", "```JSON", "```"} {
|
||||||
|
if strings.HasPrefix(s, fence) {
|
||||||
|
s = strings.TrimPrefix(s, fence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s = strings.TrimSuffix(s, "```")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFirstJSONObject(s string) string {
|
||||||
|
start := strings.Index(s, "{")
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
inStr := false
|
||||||
|
esc := false
|
||||||
|
for i := start; i < len(s); i++ {
|
||||||
|
ch := s[i]
|
||||||
|
if inStr {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
inStr = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case '"':
|
||||||
|
inStr = true
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return s[start : i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuditAgentDecisionObject(jsonText string) (decision, comment string, editedArgs map[string]interface{}, err error) {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
rawDecision := auditAgentPickString(parsed, "decision", "Decision", "result", "action", "verdict", "决策", "决定")
|
||||||
|
decision = normalizeAuditAgentDecision(rawDecision)
|
||||||
|
if decision == "" {
|
||||||
|
return "", "", nil, fmt.Errorf("missing decision")
|
||||||
|
}
|
||||||
|
comment = auditAgentPickString(parsed, "comment", "Comment", "reason", "message", "rationale", "备注", "理由", "说明")
|
||||||
|
editedArgs = auditAgentPickObject(parsed, "editedArguments", "edited_arguments", "editedArgs")
|
||||||
|
return decision, strings.TrimSpace(comment), editedArgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditAgentPickString(m map[string]interface{}, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k]; ok && v != nil {
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
if s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditAgentPickObject(m map[string]interface{}, keys ...string) map[string]interface{} {
|
||||||
|
for _, k := range keys {
|
||||||
|
v, ok := m[k]
|
||||||
|
if !ok || v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
if len(t) > 0 {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(t)
|
||||||
|
if s == "" || s == "{}" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &obj); err == nil && len(obj) > 0 {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAuditAgentDecision(v string) string {
|
||||||
|
d := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
switch d {
|
||||||
|
case "approve", "approved", "pass", "passed", "allow", "allowed", "yes", "ok", "accept", "accepted":
|
||||||
|
return "approve"
|
||||||
|
case "reject", "rejected", "deny", "denied", "no", "block", "blocked", "refuse", "refused":
|
||||||
|
return "reject"
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(v) {
|
||||||
|
case "通过", "批准", "允许", "同意", "放行":
|
||||||
|
return "approve"
|
||||||
|
case "拒绝", "驳回", "禁止", "否决":
|
||||||
|
return "reject"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type hitlAuditStrategyReq struct {
|
||||||
|
AuditAgentPrompt string `json:"auditAgentPrompt"`
|
||||||
|
AuditAgentPromptReviewEdit string `json:"auditAgentPromptReviewEdit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) GetHITLAuditStrategy(c *gin.Context) {
|
||||||
|
approvalPrompt := config.DefaultHitlAuditAgentPrompt()
|
||||||
|
reviewEditPrompt := config.DefaultHitlAuditAgentPromptReviewEdit()
|
||||||
|
approvalCustom := false
|
||||||
|
reviewEditCustom := false
|
||||||
|
if h.config != nil {
|
||||||
|
approvalPrompt = h.config.Hitl.EffectiveAuditAgentPromptForMode("approval")
|
||||||
|
reviewEditPrompt = h.config.Hitl.EffectiveAuditAgentPromptForMode("review_edit")
|
||||||
|
approvalCustom = strings.TrimSpace(h.config.Hitl.AuditAgentPrompt) != ""
|
||||||
|
reviewEditCustom = strings.TrimSpace(h.config.Hitl.AuditAgentPromptReviewEdit) != ""
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"auditAgentPrompt": approvalPrompt,
|
||||||
|
"auditAgentPromptCustom": approvalCustom,
|
||||||
|
"auditAgentPromptReviewEdit": reviewEditPrompt,
|
||||||
|
"auditAgentPromptReviewEditCustom": reviewEditCustom,
|
||||||
|
"defaultAuditAgentPrompt": config.DefaultHitlAuditAgentPrompt(),
|
||||||
|
"defaultAuditAgentPromptReviewEdit": config.DefaultHitlAuditAgentPromptReviewEdit(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) UpdateHITLAuditStrategy(c *gin.Context) {
|
||||||
|
if h.hitlStrategySaver == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 策略持久化不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req hitlAuditStrategyReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
approvalPrompt := strings.TrimSpace(req.AuditAgentPrompt)
|
||||||
|
reviewEditPrompt := strings.TrimSpace(req.AuditAgentPromptReviewEdit)
|
||||||
|
if err := h.hitlStrategySaver.UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt); err != nil {
|
||||||
|
h.logger.Warn("保存审计 Agent 提示词失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "hitl", "audit_strategy_update", "HITL 审计策略更新", "hitl_config", "audit_agent_prompt", nil)
|
||||||
|
}
|
||||||
|
if h.config != nil {
|
||||||
|
h.config.Hitl.AuditAgentPrompt = approvalPrompt
|
||||||
|
h.config.Hitl.AuditAgentPromptReviewEdit = reviewEditPrompt
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"auditAgentPrompt": config.HitlConfig{AuditAgentPrompt: approvalPrompt}.EffectiveAuditAgentPromptForMode("approval"),
|
||||||
|
"auditAgentPromptCustom": approvalPrompt != "",
|
||||||
|
"auditAgentPromptReviewEdit": config.HitlConfig{AuditAgentPromptReviewEdit: reviewEditPrompt}.EffectiveAuditAgentPromptForMode("review_edit"),
|
||||||
|
"auditAgentPromptReviewEditCustom": reviewEditPrompt != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HitlAuditStrategySaver 持久化审计 Agent 提示词到 config.yaml。
|
||||||
|
type HitlAuditStrategySaver interface {
|
||||||
|
UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHitlAuditStrategySaver 设置审计策略落盘。
|
||||||
|
func (h *AgentHandler) SetHitlAuditStrategySaver(s HitlAuditStrategySaver) {
|
||||||
|
h.hitlStrategySaver = s
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentApprove(t *testing.T) {
|
||||||
|
d, err := parseAuditAgentLLMContent(`{"decision":"approve","comment":"与任务一致"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Decision != "approve" || d.Comment != "与任务一致" {
|
||||||
|
t.Fatalf("unexpected %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentReject(t *testing.T) {
|
||||||
|
d, err := parseAuditAgentLLMContent("```json\n{\"decision\":\"reject\",\"comment\":\"风险过高\"}\n```")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Decision != "reject" {
|
||||||
|
t.Fatalf("expected reject, got %s", d.Decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentInvalid(t *testing.T) {
|
||||||
|
_, err := parseAuditAgentLLMContent(`{"decision":"maybe"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid decision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentProseWrapped(t *testing.T) {
|
||||||
|
d, err := parseAuditAgentLLMContent("好的,裁决如下:\n```json\n{\"decision\":\"approve\",\"comment\":\"只读 ls\"}\n```\n以上。")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Decision != "approve" {
|
||||||
|
t.Fatalf("expected approve, got %s", d.Decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentChineseDecision(t *testing.T) {
|
||||||
|
d, err := parseAuditAgentLLMContent(`{"decision":"通过","comment":"风险低"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Decision != "approve" {
|
||||||
|
t.Fatalf("expected approve, got %s", d.Decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuditAgentLLMContentWithEditedArguments(t *testing.T) {
|
||||||
|
d, err := parseAuditAgentLLMContent(`{"decision":"approve","comment":"收窄路径","editedArguments":{"path":"/safe"}}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Decision != "approve" {
|
||||||
|
t.Fatalf("expected approve, got %s", d.Decision)
|
||||||
|
}
|
||||||
|
if d.EditedArguments == nil || d.EditedArguments["path"] != "/safe" {
|
||||||
|
t.Fatalf("unexpected edited args: %+v", d.EditedArguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAuditAgentReviewInputIncludesMode(t *testing.T) {
|
||||||
|
s := buildAuditAgentReviewInput("review_edit", "execute", map[string]interface{}{
|
||||||
|
"arguments": `{"command":"pwd"}`,
|
||||||
|
})
|
||||||
|
if !strings.Contains(s, "review_edit") || !strings.Contains(s, "execute") {
|
||||||
|
t.Fatalf("unexpected input: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAuditAgentReviewInput(t *testing.T) {
|
||||||
|
s := buildAuditAgentReviewInput("approval", "nmap", map[string]interface{}{
|
||||||
|
"arguments": `{"target":"10.0.0.1"}`,
|
||||||
|
"userMessage": "扫描内网",
|
||||||
|
})
|
||||||
|
if s == "" {
|
||||||
|
t.Fatal("expected non-empty input")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "nmap") || !strings.Contains(s, "10.0.0.1") || !strings.Contains(s, "扫描内网") {
|
||||||
|
t.Fatalf("unexpected input: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hitlCognitionState struct {
|
||||||
|
AssistantMessageID string
|
||||||
|
UserMessage string
|
||||||
|
Thinking string
|
||||||
|
ReasoningChain string
|
||||||
|
Planning string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHitlCognition 返回当前运行任务上缓存的本轮 HITL 上下文(不含会话历史)。
|
||||||
|
func (m *AgentTaskManager) GetHitlCognition(conversationID string) hitlCognitionFields {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if m == nil || conversationID == "" {
|
||||||
|
return hitlCognitionFields{}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil || t.hitlCognition == nil {
|
||||||
|
return hitlCognitionFields{}
|
||||||
|
}
|
||||||
|
c := t.hitlCognition
|
||||||
|
return hitlCognitionFields{
|
||||||
|
UserMessage: c.UserMessage,
|
||||||
|
Thinking: c.Thinking,
|
||||||
|
ReasoningChain: c.ReasoningChain,
|
||||||
|
Planning: c.Planning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetHitlCognition 新任务开始时重置本轮 HITL 上下文。
|
||||||
|
func (m *AgentTaskManager) ResetHitlCognition(conversationID, userMessage string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if m == nil || conversationID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.hitlCognition = &hitlCognitionState{UserMessage: strings.TrimSpace(userMessage)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHitlAssistantMessageID 记录当前助手消息 ID,供 HITL 与 DB 回退对齐。
|
||||||
|
func (m *AgentTaskManager) SetHitlAssistantMessageID(conversationID, assistantMessageID string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
assistantMessageID = strings.TrimSpace(assistantMessageID)
|
||||||
|
if m == nil || conversationID == "" || assistantMessageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.hitlCognition == nil {
|
||||||
|
t.hitlCognition = &hitlCognitionState{}
|
||||||
|
}
|
||||||
|
t.hitlCognition.AssistantMessageID = assistantMessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateHitlCognitionSnapshot 从进行中的进度流快照更新 thinking / reasoning / planning。
|
||||||
|
func (m *AgentTaskManager) UpdateHitlCognitionSnapshot(conversationID, assistantMessageID, thinking, reasoningChain, planning string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if m == nil || conversationID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.hitlCognition == nil {
|
||||||
|
t.hitlCognition = &hitlCognitionState{}
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(assistantMessageID); id != "" {
|
||||||
|
t.hitlCognition.AssistantMessageID = id
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(thinking); s != "" {
|
||||||
|
t.hitlCognition.Thinking = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(reasoningChain); s != "" {
|
||||||
|
t.hitlCognition.ReasoningChain = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(planning); s != "" {
|
||||||
|
t.hitlCognition.Planning = s
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hitlPayloadUserMessage = "userMessage"
|
||||||
|
hitlPayloadThinking = "thinking"
|
||||||
|
hitlPayloadReasoningChain = "reasoningChain"
|
||||||
|
hitlPayloadPlanning = "planning"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hitlCognitionFields struct {
|
||||||
|
UserMessage string
|
||||||
|
Thinking string
|
||||||
|
ReasoningChain string
|
||||||
|
Planning string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) enrichHitlApprovalPayload(conversationID, assistantMessageID string, payload map[string]interface{}) {
|
||||||
|
if h == nil || payload == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cog := h.collectHitlCognition(conversationID, assistantMessageID)
|
||||||
|
if s := strings.TrimSpace(cog.UserMessage); s != "" {
|
||||||
|
payload[hitlPayloadUserMessage] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(cog.Thinking); s != "" {
|
||||||
|
payload[hitlPayloadThinking] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(cog.ReasoningChain); s != "" {
|
||||||
|
payload[hitlPayloadReasoningChain] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(cog.Planning); s != "" {
|
||||||
|
payload[hitlPayloadPlanning] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) collectHitlCognition(conversationID, assistantMessageID string) hitlCognitionFields {
|
||||||
|
var out hitlCognitionFields
|
||||||
|
if h.tasks != nil {
|
||||||
|
out = h.tasks.GetHitlCognition(conversationID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.UserMessage) == "" && h.db != nil {
|
||||||
|
if msg, err := h.db.GetTurnUserMessage(conversationID, assistantMessageID); err == nil {
|
||||||
|
out.UserMessage = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.db != nil && assistantMessageID != "" {
|
||||||
|
dbCog, err := h.db.GetAssistantCognitionTexts(assistantMessageID)
|
||||||
|
if err == nil {
|
||||||
|
if strings.TrimSpace(out.Thinking) == "" {
|
||||||
|
out.Thinking = dbCog.Thinking
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.ReasoningChain) == "" {
|
||||||
|
out.ReasoningChain = dbCog.ReasoningChain
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.Planning) == "" {
|
||||||
|
out.Planning = dbCog.Planning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotHitlCognitionFromStreams(thinkingStreams map[string]*thinkingBuf, respPlan *responsePlanAgg) (thinking, reasoningChain, planning string) {
|
||||||
|
if len(thinkingStreams) > 0 {
|
||||||
|
var thinkingParts, reasoningParts []string
|
||||||
|
for _, tb := range thinkingStreams {
|
||||||
|
if tb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(tb.b.String())
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tb.persistAs == "reasoning_chain" {
|
||||||
|
reasoningParts = append(reasoningParts, content)
|
||||||
|
} else {
|
||||||
|
thinkingParts = append(thinkingParts, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thinking = strings.Join(thinkingParts, "\n\n")
|
||||||
|
reasoningChain = strings.Join(reasoningParts, "\n\n")
|
||||||
|
}
|
||||||
|
if respPlan != nil {
|
||||||
|
planning = strings.TrimSpace(respPlan.b.String())
|
||||||
|
}
|
||||||
|
return thinking, reasoningChain, planning
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) syncHitlCognitionFromProgress(conversationID, assistantMessageID string, thinkingStreams map[string]*thinkingBuf, respPlan *responsePlanAgg) {
|
||||||
|
if h == nil || h.tasks == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thinking, reasoning, planning := snapshotHitlCognitionFromStreams(thinkingStreams, respPlan)
|
||||||
|
if thinking == "" && reasoning == "" && planning == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.tasks.UpdateHitlCognitionSnapshot(conversationID, assistantMessageID, thinking, reasoning, planning)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnrichHitlApprovalPayload(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
|
||||||
|
conv, err := db.CreateConversation("hitl ctx", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("conv: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.AddMessage(conv.ID, "user", "scan 10.0.0.1 please", nil); err != nil {
|
||||||
|
t.Fatalf("user msg: %v", err)
|
||||||
|
}
|
||||||
|
asst, err := db.AddMessage(conv.ID, "assistant", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("asst msg: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddProcessDetail(asst.ID, conv.ID, "thinking", "need port scan first", nil); err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &AgentHandler{db: db, tasks: NewAgentTaskManager()}
|
||||||
|
payload := map[string]interface{}{"toolName": "nmap", "arguments": "{}"}
|
||||||
|
h.enrichHitlApprovalPayload(conv.ID, asst.ID, payload)
|
||||||
|
|
||||||
|
if got := payload["userMessage"]; got != "scan 10.0.0.1 please" {
|
||||||
|
t.Fatalf("userMessage=%v", got)
|
||||||
|
}
|
||||||
|
if got := payload["thinking"]; got != "need port scan first" {
|
||||||
|
t.Fatalf("thinking=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hitlPayloadExecutionResult = "executionResult"
|
||||||
|
|
||||||
|
type hitlExecutionResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
ToolName string `json:"toolName,omitempty"`
|
||||||
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
|
RecordedAt time.Time `json:"recordedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hitlApprovedExecTrack struct {
|
||||||
|
InterruptID string
|
||||||
|
ConversationID string
|
||||||
|
ToolName string
|
||||||
|
ToolCallID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackApprovedHitlExecution 审批通过后登记,待 tool_result 回写执行结果。
|
||||||
|
func (m *HITLManager) TrackApprovedHitlExecution(interruptID, conversationID, toolName, toolCallID string) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interruptID = strings.TrimSpace(interruptID)
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if interruptID == "" || conversationID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.approvedExec == nil {
|
||||||
|
m.approvedExec = make(map[string][]hitlApprovedExecTrack)
|
||||||
|
}
|
||||||
|
m.approvedExec[conversationID] = append(m.approvedExec[conversationID], hitlApprovedExecTrack{
|
||||||
|
InterruptID: interruptID,
|
||||||
|
ConversationID: conversationID,
|
||||||
|
ToolName: strings.TrimSpace(toolName),
|
||||||
|
ToolCallID: strings.TrimSpace(toolCallID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *HITLManager) popApprovedInterruptForTool(conversationID, toolCallID, toolName string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
toolCallID = strings.TrimSpace(toolCallID)
|
||||||
|
toolName = strings.TrimSpace(toolName)
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
queue := m.approvedExec[conversationID]
|
||||||
|
if len(queue) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
if toolCallID != "" {
|
||||||
|
for i, t := range queue {
|
||||||
|
if t.ToolCallID == toolCallID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 && toolName != "" {
|
||||||
|
for i, t := range queue {
|
||||||
|
if strings.EqualFold(t.ToolName, toolName) {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := queue[idx].InterruptID
|
||||||
|
queue = append(queue[:idx], queue[idx+1:]...)
|
||||||
|
if len(queue) == 0 {
|
||||||
|
delete(m.approvedExec, conversationID)
|
||||||
|
} else {
|
||||||
|
m.approvedExec[conversationID] = queue
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHitlPayloadExecutionResult(payloadJSON string, exec hitlExecutionResult) (string, error) {
|
||||||
|
root := make(map[string]interface{})
|
||||||
|
if strings.TrimSpace(payloadJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(payloadJSON), &root)
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
root = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
root[hitlPayloadExecutionResult] = exec
|
||||||
|
out, err := json.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return payloadJSON, err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) recordHitlToolExecutionResult(conversationID, toolCallID, toolName string, success bool, result string) {
|
||||||
|
if h == nil || h.hitlManager == nil || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interruptID := h.hitlManager.popApprovedInterruptForTool(conversationID, toolCallID, toolName)
|
||||||
|
if interruptID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payloadJSON string
|
||||||
|
err := h.db.QueryRow(`SELECT payload FROM hitl_interrupts WHERE id = ?`, interruptID).Scan(&payloadJSON)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
merged, err := mergeHitlPayloadExecutionResult(payloadJSON, hitlExecutionResult{
|
||||||
|
Success: success,
|
||||||
|
Result: strings.TrimSpace(result),
|
||||||
|
ToolName: strings.TrimSpace(toolName),
|
||||||
|
ToolCallID: strings.TrimSpace(toolCallID),
|
||||||
|
RecordedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET payload = ? WHERE id = ?`, merged, interruptID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeHitlPayloadExecutionResult(t *testing.T) {
|
||||||
|
merged, err := mergeHitlPayloadExecutionResult(`{"userMessage":"hi","toolName":"nmap"}`, hitlExecutionResult{
|
||||||
|
Success: true,
|
||||||
|
Result: "open ports: 80",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var root map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(merged), &root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if root["userMessage"] != "hi" {
|
||||||
|
t.Fatalf("userMessage lost: %v", root["userMessage"])
|
||||||
|
}
|
||||||
|
exec, ok := root["executionResult"].(map[string]interface{})
|
||||||
|
if !ok || exec["success"] != true {
|
||||||
|
t.Fatalf("executionResult missing: %v", root["executionResult"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopApprovedInterruptForTool(t *testing.T) {
|
||||||
|
m := NewHITLManager(nil, nil)
|
||||||
|
m.TrackApprovedHitlExecution("hitl_a", "conv1", "nmap", "tc1")
|
||||||
|
m.TrackApprovedHitlExecution("hitl_b", "conv1", "exec", "")
|
||||||
|
if id := m.popApprovedInterruptForTool("conv1", "tc1", "nmap"); id != "hitl_a" {
|
||||||
|
t.Fatalf("tc1 match=%q", id)
|
||||||
|
}
|
||||||
|
if id := m.popApprovedInterruptForTool("conv1", "", "exec"); id != "hitl_b" {
|
||||||
|
t.Fatalf("tool name match=%q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeHitlReviewer(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "audit_agent", "agent", "ai":
|
||||||
|
return "audit_agent"
|
||||||
|
default:
|
||||||
|
return "human"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHitlDecidedBy(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "audit_agent", "agent", "ai":
|
||||||
|
return "audit_agent"
|
||||||
|
case "system", "timeout":
|
||||||
|
return "system"
|
||||||
|
case "manual":
|
||||||
|
return "manual"
|
||||||
|
default:
|
||||||
|
return "human"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *HITLManager) migrateHitlSchemaColumns() {
|
||||||
|
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
||||||
|
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hitlInterruptRowToMap(
|
||||||
|
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
|
||||||
|
messageID sql.NullString,
|
||||||
|
decision, comment sql.NullString,
|
||||||
|
createdAt time.Time,
|
||||||
|
decidedAt sql.NullTime,
|
||||||
|
) map[string]interface{} {
|
||||||
|
msgID := ""
|
||||||
|
if messageID.Valid {
|
||||||
|
msgID = messageID.String
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"conversationId": cid,
|
||||||
|
"messageId": msgID,
|
||||||
|
"mode": mode,
|
||||||
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"payload": payload,
|
||||||
|
"status": rowStatus,
|
||||||
|
"decision": decision.String,
|
||||||
|
"comment": comment.String,
|
||||||
|
"decidedBy": decidedBy,
|
||||||
|
"createdAt": createdAt,
|
||||||
|
"decidedAt": func() interface{} {
|
||||||
|
if decidedAt.Valid {
|
||||||
|
return decidedAt.Time
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
||||||
|
where, args := h.buildHitlLogsWhere(logs)
|
||||||
|
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||||
|
return q, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
|
||||||
|
q := " WHERE 1=1"
|
||||||
|
args := []interface{}{}
|
||||||
|
if logs {
|
||||||
|
q += " AND status != 'pending'"
|
||||||
|
} else {
|
||||||
|
q += " AND status = 'pending'"
|
||||||
|
}
|
||||||
|
return q, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gin.Context) (string, []interface{}) {
|
||||||
|
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||||
|
toolName := strings.TrimSpace(c.Query("toolName"))
|
||||||
|
decision := strings.TrimSpace(c.Query("decision"))
|
||||||
|
decidedBy := strings.TrimSpace(c.Query("decidedBy"))
|
||||||
|
status := strings.TrimSpace(c.Query("status"))
|
||||||
|
search := strings.TrimSpace(c.Query("q"))
|
||||||
|
|
||||||
|
if conversationID != "" {
|
||||||
|
q += " AND conversation_id = ?"
|
||||||
|
args = append(args, conversationID)
|
||||||
|
}
|
||||||
|
if toolName != "" {
|
||||||
|
q += " AND tool_name LIKE ?"
|
||||||
|
args = append(args, "%"+toolName+"%")
|
||||||
|
}
|
||||||
|
if decision != "" && decision != "all" {
|
||||||
|
q += " AND decision = ?"
|
||||||
|
args = append(args, decision)
|
||||||
|
}
|
||||||
|
if decidedBy != "" && decidedBy != "all" {
|
||||||
|
q += " AND COALESCE(decided_by,'human') = ?"
|
||||||
|
args = append(args, normalizeHitlDecidedBy(decidedBy))
|
||||||
|
}
|
||||||
|
if status != "" && status != "all" {
|
||||||
|
q += " AND status = ?"
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
like := "%" + search + "%"
|
||||||
|
q += " AND (id LIKE ? OR conversation_id LIKE ? OR tool_name LIKE ? OR payload LIKE ? OR COALESCE(decision_comment,'') LIKE ?)"
|
||||||
|
args = append(args, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
return q, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||||
|
items := make([]map[string]interface{}, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||||
|
var messageID sql.NullString
|
||||||
|
var decision, comment sql.NullString
|
||||||
|
var createdAt time.Time
|
||||||
|
var decidedAt sql.NullTime
|
||||||
|
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) countHitlQuery(baseQ string, args []interface{}) (int, error) {
|
||||||
|
countQ := "SELECT COUNT(*) FROM (" + baseQ + ") AS hitl_cnt"
|
||||||
|
var total int
|
||||||
|
if err := h.db.QueryRow(countQ, args...).Scan(&total); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) ListHITLLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
q, args := h.buildHitlListQuery(true)
|
||||||
|
q, args = h.appendHitlListFilters(q, args, c)
|
||||||
|
total, err := h.countHitlQuery(q, args)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q += " ORDER BY COALESCE(decided_at, created_at) DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, pageSize, offset)
|
||||||
|
rows, err := h.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := h.scanHitlInterruptRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total, "retentionDays": h.hitlRetentionDays()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) hitlRetentionDays() int {
|
||||||
|
if h.config != nil {
|
||||||
|
return h.config.Hitl.RetentionDaysEffective()
|
||||||
|
}
|
||||||
|
return config.HitlConfig{}.RetentionDaysEffective()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHITLLogs 批量删除或按筛选清空已决策的人机协同审计日志(不删除 pending)。
|
||||||
|
func (h *AgentHandler) DeleteHITLLogs(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
All bool `json:"all"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted int64
|
||||||
|
var err error
|
||||||
|
if request.All {
|
||||||
|
where, args := h.buildHitlLogsWhere(true)
|
||||||
|
where, args = h.appendHitlListFilters(where, args, c)
|
||||||
|
deleted, err = h.db.DeleteHitlInterruptLogsMatching(where, args)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "hitl", "logs_clear", "清空人机协同审计日志", "hitl_interrupt", "", map[string]interface{}{
|
||||||
|
"deleted": deleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(request.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "审计日志 ID 列表不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted, err = h.db.DeleteHitlInterruptLogsByIDs(request.IDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "hitl", "logs_delete_batch", "批量删除人机协同审计日志", "hitl_interrupt", "", map[string]interface{}{
|
||||||
|
"count": len(request.IDs),
|
||||||
|
"deleted": deleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功", "deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||||
|
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||||
|
var messageID sql.NullString
|
||||||
|
var decision, comment sql.NullString
|
||||||
|
var createdAt time.Time
|
||||||
|
var decidedAt sql.NullTime
|
||||||
|
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||||
|
}
|
||||||
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -251,6 +252,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||||
|
// 用户输入由 message history 承载;压缩后由 summarization 摘要指令保留关键约束。
|
||||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||||
@@ -16,9 +17,6 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
|||||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||||
parts = append(parts, bb)
|
parts = append(parts, bb)
|
||||||
}
|
}
|
||||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
|
||||||
parts = append(parts, uv)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n\n")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,29 +68,6 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
|||||||
return strings.TrimSpace(block)
|
return strings.TrimSpace(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userVerbatimAnchorBlock 从 messages 表构建用户各轮原文锚点(压缩后仍由 summarization Finalize 刷新)。
|
|
||||||
func (h *AgentHandler) userVerbatimAnchorBlock(conversationID string) string {
|
|
||||||
if h == nil || h.db == nil || h.config == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
|
||||||
if conversationID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
maxRunes := h.config.MultiAgent.UserVerbatimAnchorMaxRunesEffective()
|
|
||||||
if maxRunes < 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
msgs, err := h.db.GetMessages(conversationID)
|
|
||||||
if err != nil {
|
|
||||||
if h.logger != nil {
|
|
||||||
h.logger.Warn("构建用户原文锚点失败", zap.String("conversationId", conversationID), zap.Error(err))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return project.BuildUserVerbatimAnchorBlockFromMessages(msgs, maxRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||||
if h == nil || h.db == nil {
|
if h == nil || h.db == nil {
|
||||||
|
|||||||
+45
-22
@@ -711,12 +711,27 @@ type wecomReplyXML struct {
|
|||||||
Content string `xml:"Content"`
|
Content string `xml:"Content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wecomRequireToken 企业微信回调必须配置 Token;未配置时拒绝请求,防止未授权触发 Agent。
|
||||||
|
func (h *RobotHandler) wecomRequireToken(c *gin.Context) (string, bool) {
|
||||||
|
token := strings.TrimSpace(h.config.Robots.Wecom.Token)
|
||||||
|
if token == "" {
|
||||||
|
h.logger.Warn("企业微信已启用但未配置 token,已拒绝回调(请在配置中设置 robots.wecom.token)")
|
||||||
|
c.String(http.StatusForbidden, "")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
|
||||||
// HandleWecomGET 企业微信 URL 校验(GET)
|
// HandleWecomGET 企业微信 URL 校验(GET)
|
||||||
func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
||||||
if !h.config.Robots.Wecom.Enabled {
|
if !h.config.Robots.Wecom.Enabled {
|
||||||
c.String(http.StatusNotFound, "")
|
c.String(http.StatusNotFound, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
token, ok := h.wecomRequireToken(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
|
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
|
||||||
echostr := c.Query("echostr")
|
echostr := c.Query("echostr")
|
||||||
msgSignature := c.Query("msg_signature")
|
msgSignature := c.Query("msg_signature")
|
||||||
@@ -724,7 +739,7 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
|||||||
nonce := c.Query("nonce")
|
nonce := c.Query("nonce")
|
||||||
|
|
||||||
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1
|
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1
|
||||||
signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr)
|
signature := h.signWecomRequest(token, timestamp, nonce, echostr)
|
||||||
if signature != msgSignature {
|
if signature != msgSignature {
|
||||||
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
|
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
|
||||||
c.String(http.StatusBadRequest, "invalid signature")
|
c.String(http.StatusBadRequest, "invalid signature")
|
||||||
@@ -865,27 +880,28 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
|
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
|
||||||
|
|
||||||
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
|
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段。
|
||||||
// 若配置了 Token 则必须校验签名,避免未授权请求触发 Agent(防止平台被接管)
|
// 启用企业微信时必须配置 token 并校验签名,避免未授权请求触发 Agent。
|
||||||
token := h.config.Robots.Wecom.Token
|
token, ok := h.wecomRequireToken(c)
|
||||||
if token != "" {
|
if !ok {
|
||||||
if msgSignature == "" {
|
return
|
||||||
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需配置 token 并确保回调携带 msg_signature)")
|
}
|
||||||
c.String(http.StatusOK, "")
|
if msgSignature == "" {
|
||||||
return
|
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需确保回调携带 msg_signature)")
|
||||||
}
|
c.String(http.StatusOK, "")
|
||||||
var tmp wecomXML
|
return
|
||||||
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
|
}
|
||||||
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
|
var tmp wecomXML
|
||||||
c.String(http.StatusOK, "")
|
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
|
||||||
return
|
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
|
||||||
}
|
c.String(http.StatusOK, "")
|
||||||
expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt)
|
return
|
||||||
if expected != msgSignature {
|
}
|
||||||
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
|
expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt)
|
||||||
c.String(http.StatusOK, "")
|
if expected != msgSignature {
|
||||||
return
|
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
|
||||||
}
|
c.String(http.StatusOK, "")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body wecomXML
|
var body wecomXML
|
||||||
@@ -899,6 +915,13 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
|||||||
// 保存企业 ID(用于明文模式回复)
|
// 保存企业 ID(用于明文模式回复)
|
||||||
enterpriseID := body.ToUserName
|
enterpriseID := body.ToUserName
|
||||||
|
|
||||||
|
// 配置了 EncodingAESKey 时必须走加密消息,拒绝明文 XML 绕过
|
||||||
|
if strings.TrimSpace(h.config.Robots.Wecom.EncodingAESKey) != "" && strings.TrimSpace(body.Encrypt) == "" {
|
||||||
|
h.logger.Warn("企业微信已配置加密模式但收到明文消息,已拒绝")
|
||||||
|
c.String(http.StatusOK, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 加密模式:先解密再解析内层 XML
|
// 加密模式:先解密再解析内层 XML
|
||||||
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||||
h.logger.Debug("企业微信进入加密模式解密流程")
|
h.logger.Debug("企业微信进入加密模式解密流程")
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newWecomTestHandler(token string, aesKey string) *RobotHandler {
|
||||||
|
return &RobotHandler{
|
||||||
|
config: &config.Config{
|
||||||
|
Robots: config.RobotsConfig{
|
||||||
|
Wecom: config.RobotWecomConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: token,
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWecomPOST_rejectsWhenTokenEmpty(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := newWecomTestHandler("", "")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
body := `<?xml version="1.0"?><xml><FromUserName>attacker</FromUserName><MsgType>text</MsgType><Content>hi</Content></xml>`
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom", strings.NewReader(body))
|
||||||
|
|
||||||
|
h.HandleWecomPOST(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if w.Body.String() == "success" {
|
||||||
|
t.Fatal("expected rejection, got success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWecomPOST_rejectsPlaintextWhenEncryptionConfigured(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := newWecomTestHandler("secret-token", "abcdefghijklmnopqrstuvwxyz0123456789ABCD")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
body := `<?xml version="1.0"?><xml><FromUserName>attacker</FromUserName><MsgType>text</MsgType><Content>hi</Content></xml>`
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom?timestamp=1&nonce=2&msg_signature=fake", strings.NewReader(body))
|
||||||
|
|
||||||
|
h.HandleWecomPOST(c)
|
||||||
|
|
||||||
|
if w.Body.String() == "success" {
|
||||||
|
t.Fatal("expected rejection for plaintext in encryption mode, got success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWecomGET_rejectsWhenTokenEmpty(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := newWecomTestHandler("", "")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/robot/wecom?msg_signature=x×tamp=1&nonce=2&echostr=abc", nil)
|
||||||
|
|
||||||
|
h.HandleWecomGET(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
|
|||||||
// AgentTask 描述正在运行的Agent任务
|
// AgentTask 描述正在运行的Agent任务
|
||||||
type AgentTask struct {
|
type AgentTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -42,6 +43,9 @@ type AgentTask struct {
|
|||||||
// activeEinoExecuteAbortNote AbortActiveEinoExecute 写入的用户说明,由 execute 收尾时合并进工具结果
|
// activeEinoExecuteAbortNote AbortActiveEinoExecute 写入的用户说明,由 execute 收尾时合并进工具结果
|
||||||
activeEinoExecuteAbortNote string
|
activeEinoExecuteAbortNote string
|
||||||
|
|
||||||
|
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
|
||||||
|
hitlCognition *hitlCognitionState
|
||||||
|
|
||||||
cancel func(error)
|
cancel func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
|||||||
// CompletedTask 已完成的任务(用于历史记录)
|
// CompletedTask 已完成的任务(用于历史记录)
|
||||||
type CompletedTask struct {
|
type CompletedTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
CompletedAt time.Time `json:"completedAt"`
|
CompletedAt time.Time `json:"completedAt"`
|
||||||
@@ -352,6 +357,7 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.tasks[conversationID] = task
|
m.tasks[conversationID] = task
|
||||||
|
task.hitlCognition = &hitlCognitionState{UserMessage: strings.TrimSpace(message)}
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package hitl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const retentionPurgeInterval = time.Hour
|
||||||
|
|
||||||
|
// Service manages HITL audit log retention (decided hitl_interrupts rows).
|
||||||
|
type Service struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a HITL audit log retention service.
|
||||||
|
func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service {
|
||||||
|
return &Service{db: db, cfg: cfg, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionDays returns configured retention; 0 means keep forever.
|
||||||
|
func (s *Service) RetentionDays() int {
|
||||||
|
if s == nil || s.cfg == nil {
|
||||||
|
return config.HitlConfig{}.RetentionDaysEffective()
|
||||||
|
}
|
||||||
|
return s.cfg.Hitl.RetentionDaysEffective()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpired deletes decided HITL log rows older than retention_days when configured.
|
||||||
|
func (s *Service) PurgeExpired() {
|
||||||
|
if s == nil || s.db == nil || s.cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := s.cfg.Hitl.RetentionDaysEffective()
|
||||||
|
if days <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -days)
|
||||||
|
n, err := s.db.PurgeHitlInterruptLogsBefore(cutoff)
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("清理过期人机协同审计日志失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 && s.logger != nil {
|
||||||
|
s.logger.Info("已清理过期人机协同审计日志", zap.Int64("deleted", n), zap.Int("retention_days", days))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRetentionLoop periodically purges expired HITL audit log rows.
|
||||||
|
func StartRetentionLoop(s *Service, logger *zap.Logger) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(retentionPurgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
s.PurgeExpired()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("hitl audit log retention tick completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package hitl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
appconfig "cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServicePurgeExpired_respectsZeroRetention(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||||
|
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
decision TEXT,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
decided_at DATETIME
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("create table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||||
|
VALUES ('old-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, old, old); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := 0
|
||||||
|
svc := NewService(db, &appconfig.Config{
|
||||||
|
Hitl: appconfig.HitlConfig{RetentionDays: &zero},
|
||||||
|
}, zap.NewNop())
|
||||||
|
svc.PurgeExpired()
|
||||||
|
|
||||||
|
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err != nil {
|
||||||
|
t.Fatalf("record should remain when retention_days=0: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/callbacks"
|
||||||
|
"github.com/cloudwego/eino/components"
|
||||||
|
"github.com/cloudwego/eino/components/retriever"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// knowledgePipelineRetriever: MultiQuery → vector candidates → rerank → post-process.
|
||||||
|
type knowledgePipelineRetriever struct {
|
||||||
|
inner retriever.Retriever
|
||||||
|
base *Retriever
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKnowledgePipelineRetriever(inner retriever.Retriever, base *Retriever) *knowledgePipelineRetriever {
|
||||||
|
if inner == nil || base == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &knowledgePipelineRetriever{inner: inner, base: base}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *knowledgePipelineRetriever) GetType() string {
|
||||||
|
return "KnowledgeRAGPipeline"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) (out []*schema.Document, err error) {
|
||||||
|
if p == nil || p.inner == nil || p.base == nil {
|
||||||
|
return nil, fmt.Errorf("knowledge pipeline retriever: nil")
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if q == "" {
|
||||||
|
return nil, fmt.Errorf("查询不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
ro := retriever.GetCommonOptions(nil, opts...)
|
||||||
|
finalTopK := p.base.config.TopK
|
||||||
|
if finalTopK <= 0 {
|
||||||
|
finalTopK = 5
|
||||||
|
}
|
||||||
|
if ro.TopK != nil && *ro.TopK > 0 {
|
||||||
|
finalTopK = *ro.TopK
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = callbacks.EnsureRunInfo(ctx, p.GetType(), components.ComponentOfRetriever)
|
||||||
|
ctx = callbacks.OnStart(ctx, &retriever.CallbackInput{Query: q, TopK: finalTopK, Extra: ro.DSLInfo})
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
_ = callbacks.OnError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = callbacks.OnEnd(ctx, &retriever.CallbackOutput{Docs: out})
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err = p.inner.Retrieve(ctx, q, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rr := p.base.documentReranker(); rr != nil && len(out) > 1 {
|
||||||
|
reranked, rerr := rr.Rerank(ctx, q, out)
|
||||||
|
if rerr != nil {
|
||||||
|
if p.base.logger != nil {
|
||||||
|
p.base.logger.Warn("知识检索重排失败,已使用融合序", zap.Error(rerr))
|
||||||
|
}
|
||||||
|
} else if len(reranked) > 0 {
|
||||||
|
out = reranked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenModel := ""
|
||||||
|
if p.base.embedder != nil {
|
||||||
|
tokenModel = p.base.embedder.EmbeddingModelName()
|
||||||
|
}
|
||||||
|
var postPO *config.PostRetrieveConfig
|
||||||
|
if p.base.config != nil {
|
||||||
|
postPO = &p.base.config.PostRetrieve
|
||||||
|
}
|
||||||
|
out, err = ApplyPostRetrieve(out, postPO, tokenModel, finalTopK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ retriever.Retriever = (*knowledgePipelineRetriever)(nil)
|
||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain,底层为 SQLite 向量检索([VectorEinoRetriever])。
|
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain(MultiQuery → 向量 → 重排 → 后处理)。
|
||||||
// 去重、上下文预算截断与最终 Top-K 均在 [VectorEinoRetriever.Retrieve] 内完成,与 HTTP/MCP 检索路径一致。
|
|
||||||
func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (compose.Runnable[string, []*schema.Document], error) {
|
func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (compose.Runnable[string, []*schema.Document], error) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil, fmt.Errorf("retriever is nil")
|
return nil, fmt.Errorf("retriever is nil")
|
||||||
|
|||||||
@@ -11,19 +11,10 @@ import (
|
|||||||
"github.com/cloudwego/eino/components"
|
"github.com/cloudwego/eino/components"
|
||||||
"github.com/cloudwego/eino/components/retriever"
|
"github.com/cloudwego/eino/components/retriever"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// VectorEinoRetriever implements [retriever.Retriever] on top of SQLite-stored embeddings + cosine similarity.
|
// VectorEinoRetriever implements [retriever.Retriever] on top of SQLite-stored embeddings + cosine similarity.
|
||||||
//
|
// It returns prefetch-sized vector candidates only; rerank and post-process run in [knowledgePipelineRetriever].
|
||||||
// Options:
|
|
||||||
// - [retriever.WithTopK]
|
|
||||||
// - [retriever.WithDSLInfo] with [DSLRiskType] (string), [DSLSimilarityThreshold] (float, cosine 0–1), [DSLSubIndexFilter] (string)
|
|
||||||
//
|
|
||||||
// Document scores are cosine similarity; [retriever.WithScoreThreshold] is not mapped to a different metric.
|
|
||||||
//
|
|
||||||
// After vector search: optional [DocumentReranker] (see [Retriever.SetDocumentReranker]), then
|
|
||||||
// [ApplyPostRetrieve] (normalized-text dedupe, context budget, final Top-K) using [config.PostRetrieveConfig].
|
|
||||||
type VectorEinoRetriever struct {
|
type VectorEinoRetriever struct {
|
||||||
inner *Retriever
|
inner *Retriever
|
||||||
}
|
}
|
||||||
@@ -119,26 +110,6 @@ func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts .
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = retrievalResultsToDocuments(results)
|
out = retrievalResultsToDocuments(results)
|
||||||
|
|
||||||
if rr := h.inner.documentReranker(); rr != nil && len(out) > 1 {
|
|
||||||
reranked, rerr := rr.Rerank(ctx, q, out)
|
|
||||||
if rerr != nil {
|
|
||||||
if h.inner.logger != nil {
|
|
||||||
h.inner.logger.Warn("知识检索重排失败,已使用向量序", zap.Error(rerr))
|
|
||||||
}
|
|
||||||
} else if len(reranked) > 0 {
|
|
||||||
out = reranked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenModel := ""
|
|
||||||
if h.inner.embedder != nil {
|
|
||||||
tokenModel = h.inner.embedder.EmbeddingModelName()
|
|
||||||
}
|
|
||||||
out, err = ApplyPostRetrieve(out, postPO, tokenModel, finalTopK)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPReranker calls a hosted rerank API (DashScope or Cohere-compatible).
|
||||||
|
type HTTPReranker struct {
|
||||||
|
provider string
|
||||||
|
model string
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
client *http.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPReranker builds a rerank client from knowledge retrieval config; openAI supplies fallback credentials.
|
||||||
|
func NewHTTPReranker(rc *config.RerankConfig, openAI *config.OpenAIConfig, logger *zap.Logger) (*HTTPReranker, error) {
|
||||||
|
if rc == nil {
|
||||||
|
return nil, fmt.Errorf("rerank config is nil")
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimSpace(rc.BaseURL)
|
||||||
|
apiKey := strings.TrimSpace(rc.APIKey)
|
||||||
|
if openAI != nil {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = strings.TrimSpace(openAI.BaseURL)
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = strings.TrimSpace(openAI.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("rerank api_key is required")
|
||||||
|
}
|
||||||
|
provider := rc.ProviderEffective(baseURL)
|
||||||
|
model := rc.ModelEffective(provider)
|
||||||
|
return &HTTPReranker{
|
||||||
|
provider: provider,
|
||||||
|
model: model,
|
||||||
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||||
|
apiKey: apiKey,
|
||||||
|
client: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error) {
|
||||||
|
if r == nil {
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if q == "" || len(docs) == 0 {
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
if len(docs) == 1 {
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
texts := make([]string, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
if d == nil {
|
||||||
|
texts = append(texts, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
texts = append(texts, d.Content)
|
||||||
|
}
|
||||||
|
var order []int
|
||||||
|
var err error
|
||||||
|
switch r.provider {
|
||||||
|
case "dashscope":
|
||||||
|
order, err = r.rerankDashScope(ctx, q, texts, len(docs))
|
||||||
|
default:
|
||||||
|
order, err = r.rerankCohere(ctx, q, texts, len(docs))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*schema.Document, 0, len(order))
|
||||||
|
for _, idx := range order {
|
||||||
|
if idx < 0 || idx >= len(docs) || docs[idx] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, docs[idx])
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPReranker) rerankCohere(ctx context.Context, query string, documents []string, topN int) ([]int, error) {
|
||||||
|
url := r.cohereRerankURL()
|
||||||
|
body := map[string]any{
|
||||||
|
"model": r.model,
|
||||||
|
"query": query,
|
||||||
|
"documents": documents,
|
||||||
|
"top_n": topN,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rerank request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("rerank http %d: %s", resp.StatusCode, truncateForRerankLog(string(respBody)))
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Results []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("rerank decode: %w", err)
|
||||||
|
}
|
||||||
|
order := make([]int, 0, len(parsed.Results))
|
||||||
|
for _, row := range parsed.Results {
|
||||||
|
order = append(order, row.Index)
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPReranker) rerankDashScope(ctx context.Context, query string, documents []string, topN int) ([]int, error) {
|
||||||
|
url := r.dashscopeRerankURL()
|
||||||
|
body := map[string]any{
|
||||||
|
"model": r.model,
|
||||||
|
"input": map[string]any{
|
||||||
|
"query": query,
|
||||||
|
"documents": documents,
|
||||||
|
},
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"return_documents": false,
|
||||||
|
"top_n": topN,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dashscope rerank: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("dashscope rerank http %d: %s", resp.StatusCode, truncateForRerankLog(string(respBody)))
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Output struct {
|
||||||
|
Results []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
} `json:"results"`
|
||||||
|
} `json:"output"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashscope rerank decode: %w", err)
|
||||||
|
}
|
||||||
|
order := make([]int, 0, len(parsed.Output.Results))
|
||||||
|
for _, row := range parsed.Output.Results {
|
||||||
|
order = append(order, row.Index)
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPReranker) cohereRerankURL() string {
|
||||||
|
base := r.baseURL
|
||||||
|
if base == "" {
|
||||||
|
base = "https://api.cohere.com"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, "/v1") {
|
||||||
|
return base + "/rerank"
|
||||||
|
}
|
||||||
|
return base + "/v1/rerank"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPReranker) dashscopeRerankURL() string {
|
||||||
|
base := strings.TrimSpace(r.baseURL)
|
||||||
|
if base == "" {
|
||||||
|
return "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank"
|
||||||
|
}
|
||||||
|
if strings.Contains(base, "/api/v1/services/rerank") {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if strings.Contains(base, "dashscope.aliyuncs.com") || strings.Contains(base, "compatible-mode") {
|
||||||
|
return "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank"
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(base, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForRerankLog(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) > 512 {
|
||||||
|
return s[:512] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DocumentReranker = (*HTTPReranker)(nil)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPReranker_CohereOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/rerank" {
|
||||||
|
t.Fatalf("path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{"index": 2, "relevance_score": 0.9},
|
||||||
|
{"index": 0, "relevance_score": 0.5},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rr, err := NewHTTPReranker(&config.RerankConfig{
|
||||||
|
Provider: "cohere",
|
||||||
|
Model: "rerank-multilingual-v3.0",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
docs := []*schema.Document{
|
||||||
|
{ID: "a", Content: "alpha"},
|
||||||
|
{ID: "b", Content: "beta"},
|
||||||
|
{ID: "c", Content: "gamma"},
|
||||||
|
}
|
||||||
|
out, err := rr.Rerank(context.Background(), "query", docs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 || out[0].ID != "c" || out[1].ID != "a" {
|
||||||
|
t.Fatalf("order wrong: %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPReranker_DashScopeOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"output": map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{"index": 1, "relevance_score": 0.88},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rr, err := NewHTTPReranker(&config.RerankConfig{
|
||||||
|
Provider: "dashscope",
|
||||||
|
Model: "gte-rerank",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
docs := []*schema.Document{{ID: "a", Content: "a"}, {ID: "b", Content: "b"}}
|
||||||
|
out, err := rr.Rerank(context.Background(), "q", docs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].ID != "b" {
|
||||||
|
t.Fatalf("got %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRerankConfigDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rc := config.RerankConfig{}
|
||||||
|
if rc.ProviderEffective("https://dashscope.aliyuncs.com/x") != "dashscope" {
|
||||||
|
t.Fatal("dashscope detect")
|
||||||
|
}
|
||||||
|
if rc.ModelEffective("dashscope") != "gte-rerank" {
|
||||||
|
t.Fatal("dashscope model")
|
||||||
|
}
|
||||||
|
if rc.ModelEffective("cohere") != "rerank-multilingual-v3.0" {
|
||||||
|
t.Fatal("cohere model")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
// postRetrieveMaxPrefetchCap 限制单次向量候选上限,避免误配置导致全表扫压力过大。
|
// postRetrieveMaxPrefetchCap 限制单次向量候选上限,避免误配置导致全表扫压力过大。
|
||||||
const postRetrieveMaxPrefetchCap = 200
|
const postRetrieveMaxPrefetchCap = 200
|
||||||
|
|
||||||
// DocumentReranker 可选重排(如交叉编码器 / 第三方 Rerank API),由 [Retriever.SetDocumentReranker] 注入;失败时在适配层降级为向量序。
|
// DocumentReranker 精排(HTTP dashscope / Cohere 兼容 API),由 [WireRetrieverPipeline] 注入。
|
||||||
type DocumentReranker interface {
|
type DocumentReranker interface {
|
||||||
Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error)
|
Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error)
|
||||||
}
|
}
|
||||||
@@ -167,13 +167,16 @@ func truncateDocumentsByBudget(docs []*schema.Document, maxRunes, maxTokens int,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectivePrefetchTopK 计算向量检索应拉取的候选条数(供粗排 / 去重 / 重排)。
|
// EffectivePrefetchTopK 计算每条 MultiQuery 变体在向量阶段的候选条数(供融合 / 重排 / 后处理)。
|
||||||
func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
||||||
if topK < 1 {
|
if topK < 1 {
|
||||||
topK = 5
|
topK = 5
|
||||||
}
|
}
|
||||||
fetch := topK
|
fetch := topK * 4
|
||||||
if po != nil && po.PrefetchTopK > fetch {
|
if fetch < 20 {
|
||||||
|
fetch = 20
|
||||||
|
}
|
||||||
|
if po != nil && po.PrefetchTopK > 0 {
|
||||||
fetch = po.PrefetchTopK
|
fetch = po.PrefetchTopK
|
||||||
}
|
}
|
||||||
if fetch > postRetrieveMaxPrefetchCap {
|
if fetch > postRetrieveMaxPrefetchCap {
|
||||||
@@ -182,7 +185,7 @@ func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
|||||||
return fetch
|
return fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK。重排在 [VectorEinoRetriever] 中单独调用以便失败时降级。
|
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK(精排已在流水线中完成)。
|
||||||
func ApplyPostRetrieve(docs []*schema.Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*schema.Document, error) {
|
func ApplyPostRetrieve(docs []*schema.Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*schema.Document, error) {
|
||||||
if finalTopK < 1 {
|
if finalTopK < 1 {
|
||||||
finalTopK = 5
|
finalTopK = 5
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestDedupeByNormalizedContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectivePrefetchTopK(t *testing.T) {
|
func TestEffectivePrefetchTopK(t *testing.T) {
|
||||||
if g := EffectivePrefetchTopK(5, nil); g != 5 {
|
if g := EffectivePrefetchTopK(5, nil); g != 20 {
|
||||||
t.Fatalf("got %d", g)
|
t.Fatalf("default prefetch got %d want 20", g)
|
||||||
}
|
}
|
||||||
if g := EffectivePrefetchTopK(5, &config.PostRetrieveConfig{PrefetchTopK: 50}); g != 50 {
|
if g := EffectivePrefetchTopK(5, &config.PostRetrieveConfig{PrefetchTopK: 50}); g != 50 {
|
||||||
t.Fatalf("got %d", g)
|
t.Fatalf("got %d", g)
|
||||||
|
|||||||
@@ -27,15 +27,19 @@ type Retriever struct {
|
|||||||
|
|
||||||
rerankMu sync.RWMutex
|
rerankMu sync.RWMutex
|
||||||
reranker DocumentReranker
|
reranker DocumentReranker
|
||||||
|
|
||||||
|
pipeline retriever.Retriever
|
||||||
|
wireOpenAI *config.OpenAIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetrievalConfig 检索配置
|
// RetrievalConfig 检索配置
|
||||||
type RetrievalConfig struct {
|
type RetrievalConfig struct {
|
||||||
TopK int
|
TopK int
|
||||||
SimilarityThreshold float64
|
SimilarityThreshold float64
|
||||||
// SubIndexFilter 非空时仅检索 sub_indexes 包含该标签(逗号分隔之一)的行;空 sub_indexes 的旧行仍保留以兼容。
|
SubIndexFilter string
|
||||||
SubIndexFilter string
|
MultiQuery config.MultiQueryConfig
|
||||||
PostRetrieve config.PostRetrieveConfig
|
Rerank config.RerankConfig
|
||||||
|
PostRetrieve config.PostRetrieveConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRetriever 创建新的检索器
|
// NewRetriever 创建新的检索器
|
||||||
@@ -48,7 +52,7 @@ func NewRetriever(db *sql.DB, embedder *Embedder, config *RetrievalConfig, logge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig 更新检索配置
|
// UpdateConfig 更新检索配置并重建 Eino MultiQuery + 重排流水线。
|
||||||
func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
r.config = cfg
|
r.config = cfg
|
||||||
@@ -57,12 +61,18 @@ func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
|||||||
zap.Int("top_k", cfg.TopK),
|
zap.Int("top_k", cfg.TopK),
|
||||||
zap.Float64("similarity_threshold", cfg.SimilarityThreshold),
|
zap.Float64("similarity_threshold", cfg.SimilarityThreshold),
|
||||||
zap.String("sub_index_filter", cfg.SubIndexFilter),
|
zap.String("sub_index_filter", cfg.SubIndexFilter),
|
||||||
|
zap.Int("multi_query_max", cfg.MultiQuery.MaxQueriesEffective()),
|
||||||
zap.Int("post_retrieve_prefetch_top_k", cfg.PostRetrieve.PrefetchTopK),
|
zap.Int("post_retrieve_prefetch_top_k", cfg.PostRetrieve.PrefetchTopK),
|
||||||
zap.Int("post_retrieve_max_context_chars", cfg.PostRetrieve.MaxContextChars),
|
zap.Int("post_retrieve_max_context_chars", cfg.PostRetrieve.MaxContextChars),
|
||||||
zap.Int("post_retrieve_max_context_tokens", cfg.PostRetrieve.MaxContextTokens),
|
zap.Int("post_retrieve_max_context_tokens", cfg.PostRetrieve.MaxContextTokens),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.wireOpenAI != nil {
|
||||||
|
if err := WireRetrieverPipeline(context.Background(), r, r.wireOpenAI); err != nil && r.logger != nil {
|
||||||
|
r.logger.Warn("检索流水线重建失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDocumentReranker 注入可选重排器(并发安全);nil 表示禁用。
|
// SetDocumentReranker 注入可选重排器(并发安全);nil 表示禁用。
|
||||||
@@ -103,7 +113,7 @@ func cosineSimilarity(a, b []float32) float64 {
|
|||||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search 搜索知识库。统一经 [VectorEinoRetriever](Eino retriever.Retriever 边界)。
|
// Search 搜索知识库(Eino MultiQuery → 向量检索 → 重排 → 后处理)。
|
||||||
func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*RetrievalResult, error) {
|
func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*RetrievalResult, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, fmt.Errorf("请求不能为空")
|
return nil, fmt.Errorf("请求不能为空")
|
||||||
@@ -113,7 +123,7 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
|
|||||||
return nil, fmt.Errorf("查询不能为空")
|
return nil, fmt.Errorf("查询不能为空")
|
||||||
}
|
}
|
||||||
opts := r.einoRetrieverOptions(req)
|
opts := r.einoRetrieverOptions(req)
|
||||||
docs, err := NewVectorEinoRetriever(r).Retrieve(ctx, q, opts...)
|
docs, err := r.activeEinoRetriever().Retrieve(ctx, q, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -143,7 +153,19 @@ func (r *Retriever) einoRetrieverOptions(req *SearchRequest) []retriever.Option
|
|||||||
|
|
||||||
// EinoRetrieve 直接返回 [schema.Document],供 Eino Graph / Chain 使用。
|
// EinoRetrieve 直接返回 [schema.Document],供 Eino Graph / Chain 使用。
|
||||||
func (r *Retriever) EinoRetrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) {
|
func (r *Retriever) EinoRetrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) {
|
||||||
return NewVectorEinoRetriever(r).Retrieve(ctx, query, opts...)
|
return r.activeEinoRetriever().Retrieve(ctx, query, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Retriever) activeEinoRetriever() retriever.Retriever {
|
||||||
|
if r != nil && r.pipeline != nil {
|
||||||
|
return r.pipeline
|
||||||
|
}
|
||||||
|
return NewVectorEinoRetriever(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsEinoRetriever 将知识库检索流水线暴露为 Eino [retriever.Retriever]。
|
||||||
|
func (r *Retriever) AsEinoRetriever() retriever.Retriever {
|
||||||
|
return r.activeEinoRetriever()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Retriever) knowledgeEmbeddingSelectSQL(riskType, subIndexFilter string) (string, []interface{}) {
|
func (r *Retriever) knowledgeEmbeddingSelectSQL(riskType, subIndexFilter string) (string, []interface{}) {
|
||||||
@@ -299,7 +321,14 @@ func (r *Retriever) vectorSearch(ctx context.Context, req *SearchRequest) ([]*Re
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsEinoRetriever 将纯向量检索暴露为 Eino [retriever.Retriever]。
|
// RetrievalConfigFromYAML maps API/YAML retrieval settings into the knowledge package.
|
||||||
func (r *Retriever) AsEinoRetriever() retriever.Retriever {
|
func RetrievalConfigFromYAML(r config.RetrievalConfig) *RetrievalConfig {
|
||||||
return NewVectorEinoRetriever(r)
|
return &RetrievalConfig{
|
||||||
|
TopK: r.TopK,
|
||||||
|
SimilarityThreshold: r.SimilarityThreshold,
|
||||||
|
SubIndexFilter: r.SubIndexFilter,
|
||||||
|
MultiQuery: r.MultiQuery,
|
||||||
|
Rerank: r.Rerank,
|
||||||
|
PostRetrieve: r.PostRetrieve,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
"github.com/cloudwego/eino/flow/retriever/multiquery"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WireRetrieverPipeline builds Eino MultiQuery + HTTP rerank + post-process pipeline on r.
|
||||||
|
// Call once after NewRetriever; UpdateConfig re-invokes when wireOpenAI is set.
|
||||||
|
func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.OpenAIConfig) error {
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("retriever is nil")
|
||||||
|
}
|
||||||
|
if openAI == nil {
|
||||||
|
return fmt.Errorf("openai config is nil")
|
||||||
|
}
|
||||||
|
if r.config == nil {
|
||||||
|
return fmt.Errorf("retrieval config is nil")
|
||||||
|
}
|
||||||
|
r.wireOpenAI = openAI
|
||||||
|
|
||||||
|
httpClient := openai.NewEinoHTTPClient(openAI, &http.Client{Timeout: 120 * time.Second})
|
||||||
|
chatCfg := &einoopenai.ChatModelConfig{
|
||||||
|
APIKey: strings.TrimSpace(openAI.APIKey),
|
||||||
|
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
|
||||||
|
Model: strings.TrimSpace(openAI.Model),
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
}
|
||||||
|
if chatCfg.Model == "" {
|
||||||
|
chatCfg.Model = "gpt-4o"
|
||||||
|
}
|
||||||
|
rewriteLLM, err := einoopenai.NewChatModel(ctx, chatCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("multi_query rewrite model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reranker: %w", err)
|
||||||
|
}
|
||||||
|
r.SetDocumentReranker(reranker)
|
||||||
|
|
||||||
|
vec := NewVectorEinoRetriever(r)
|
||||||
|
mq, err := multiquery.NewRetriever(ctx, &multiquery.Config{
|
||||||
|
RewriteLLM: rewriteLLM,
|
||||||
|
MaxQueriesNum: r.config.MultiQuery.MaxQueriesEffective(),
|
||||||
|
OrigRetriever: vec,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("multi_query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.pipeline = newKnowledgePipelineRetriever(mq, r)
|
||||||
|
if r.logger != nil {
|
||||||
|
provider := r.config.Rerank.ProviderEffective(strings.TrimSpace(openAI.BaseURL))
|
||||||
|
r.logger.Info("知识库检索流水线已启用",
|
||||||
|
zap.String("pipeline", "MultiQuery→Vector→Rerank→PostRetrieve"),
|
||||||
|
zap.Int("multi_query_max", r.config.MultiQuery.MaxQueriesEffective()),
|
||||||
|
zap.String("rerank_provider", provider),
|
||||||
|
zap.String("rerank_model", r.config.Rerank.ModelEffective(provider)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultEmptyResponseContinueMaxAttempts = 5
|
||||||
|
|
||||||
|
// IsEinoEmptyResponseResult 判断 Run 是否以「未捕获助手正文」占位结束(非真实用户可见回复)。
|
||||||
|
func IsEinoEmptyResponseResult(result *RunResult) bool {
|
||||||
|
if result == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isEinoEmptyResponseText(result.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEinoEmptyResponseText(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "no assistant text was captured") ||
|
||||||
|
strings.Contains(s, "未捕获到助手文本输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEinoResumeTrace 轨迹非空,续跑才有上下文可恢复。
|
||||||
|
func HasEinoResumeTrace(result *RunResult) bool {
|
||||||
|
if result == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(result.LastAgentTraceInput)
|
||||||
|
return s != "" && s != "[]" && s != "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyResponseContinueMaxAttemptsFromConfig 无助手正文时 Handler 层退避续跑上限;0=默认 5。
|
||||||
|
func EmptyResponseContinueMaxAttemptsFromConfig(mw *config.MultiAgentEinoMiddlewareConfig) int {
|
||||||
|
if mw != nil && mw.EmptyResponseContinueMaxAttempts > 0 {
|
||||||
|
return mw.EmptyResponseContinueMaxAttempts
|
||||||
|
}
|
||||||
|
return defaultEmptyResponseContinueMaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyResponseContinueBackoff 与 run_retry 相同指数退避(2s, 4s, 8s… capped)。
|
||||||
|
func EmptyResponseContinueBackoff(attempt int, mw *config.MultiAgentEinoMiddlewareConfig) time.Duration {
|
||||||
|
maxBackoff := defaultEinoRunRetryMaxBackoff
|
||||||
|
if mw != nil && mw.RunRetryMaxBackoffSec > 0 {
|
||||||
|
maxBackoff = time.Duration(mw.RunRetryMaxBackoffSec) * time.Second
|
||||||
|
}
|
||||||
|
return einoTransientRetryBackoff(attempt, maxBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatEmptyResponseContinueUserMessage 系统自动续跑时注入的 user 轮次(不写入 messages 表气泡)。
|
||||||
|
func FormatEmptyResponseContinueUserMessage() string {
|
||||||
|
return strings.TrimSpace(`【系统自动续跑 / Auto resume】
|
||||||
|
上一轮 Eino 会话未产出可见助手正文(可能流式中断或仅完成工具调用)。请基于已有轨迹与工具结果继续推进,并给出阶段性总结;勿重复已完成步骤。`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsEinoEmptyResponseResult(t *testing.T) {
|
||||||
|
empty := &RunResult{
|
||||||
|
Response: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
|
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
|
}
|
||||||
|
if !IsEinoEmptyResponseResult(empty) {
|
||||||
|
t.Fatal("expected empty placeholder response")
|
||||||
|
}
|
||||||
|
ok := &RunResult{Response: "扫描完成,发现 2 个开放端口。"}
|
||||||
|
if IsEinoEmptyResponseResult(ok) {
|
||||||
|
t.Fatalf("expected real response, got placeholder match")
|
||||||
|
}
|
||||||
|
if IsEinoEmptyResponseResult(nil) {
|
||||||
|
t.Fatal("nil result should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasEinoResumeTrace(t *testing.T) {
|
||||||
|
if HasEinoResumeTrace(nil) {
|
||||||
|
t.Fatal("nil")
|
||||||
|
}
|
||||||
|
if HasEinoResumeTrace(&RunResult{LastAgentTraceInput: "[]"}) {
|
||||||
|
t.Fatal("enable resume on empty trace")
|
||||||
|
}
|
||||||
|
if !HasEinoResumeTrace(&RunResult{LastAgentTraceInput: `[{"role":"user","content":"hi"}]`}) {
|
||||||
|
t.Fatal("expected resume trace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyResponseContinueMaxAttemptsFromConfig(t *testing.T) {
|
||||||
|
if got := EmptyResponseContinueMaxAttemptsFromConfig(nil); got != defaultEmptyResponseContinueMaxAttempts {
|
||||||
|
t.Fatalf("default: got %d want %d", got, defaultEmptyResponseContinueMaxAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,34 +80,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
|||||||
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组装 executor handler 栈,顺序与 Deep/Supervisor 主代理一致(outermost first)。
|
execHandlers, err := buildPlanExecuteExecutorHandlers(ctx, a)
|
||||||
var execHandlers []adk.ChatModelAgentMiddleware
|
if err != nil {
|
||||||
// 1. patchtoolcalls, reduction, toolsearch, plantask(来自 prependEinoMiddlewares)
|
return nil, err
|
||||||
if len(a.ExecPreMiddlewares) > 0 {
|
|
||||||
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
|
|
||||||
}
|
|
||||||
// 2. filesystem 中间件(可选)
|
|
||||||
if a.FilesystemMiddleware != nil {
|
|
||||||
execHandlers = append(execHandlers, a.FilesystemMiddleware)
|
|
||||||
}
|
|
||||||
// 3. skill 中间件(可选)
|
|
||||||
if a.SkillMiddleware != nil {
|
|
||||||
execHandlers = append(execHandlers, a.SkillMiddleware)
|
|
||||||
}
|
|
||||||
// 4. pre-summarization normalize + continuation dedup, then summarization (与 Deep/Supervisor 一致)
|
|
||||||
if a.AppCfg != nil {
|
|
||||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
|
|
||||||
if sumErr != nil {
|
|
||||||
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
|
|
||||||
}
|
|
||||||
execHandlers = appendEinoChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
|
|
||||||
logger: a.Logger,
|
|
||||||
phase: "plan_execute_executor",
|
|
||||||
summarization: sumMw,
|
|
||||||
modelName: a.ModelName,
|
|
||||||
conversationID: a.ConversationID,
|
|
||||||
trace: a.ModelFacingTrace,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
||||||
Model: a.ExecModel,
|
Model: a.ExecModel,
|
||||||
@@ -130,6 +105,39 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPlanExecuteExecutorHandlers 组装 Executor 中间件栈(outermost first),与 Deep/Supervisor 主代理对齐:
|
||||||
|
// ExecPreMiddlewares(patch / reduction / toolsearch / plantask)→ filesystem → skill → summarization tail。
|
||||||
|
func buildPlanExecuteExecutorHandlers(ctx context.Context, a *PlanExecuteRootArgs) ([]adk.ChatModelAgentMiddleware, error) {
|
||||||
|
if a == nil {
|
||||||
|
return nil, fmt.Errorf("plan_execute: args 为空")
|
||||||
|
}
|
||||||
|
var execHandlers []adk.ChatModelAgentMiddleware
|
||||||
|
if len(a.ExecPreMiddlewares) > 0 {
|
||||||
|
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
|
||||||
|
}
|
||||||
|
if a.FilesystemMiddleware != nil {
|
||||||
|
execHandlers = append(execHandlers, a.FilesystemMiddleware)
|
||||||
|
}
|
||||||
|
if a.SkillMiddleware != nil {
|
||||||
|
execHandlers = append(execHandlers, a.SkillMiddleware)
|
||||||
|
}
|
||||||
|
if a.AppCfg != nil {
|
||||||
|
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
|
||||||
|
if sumErr != nil {
|
||||||
|
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
|
||||||
|
}
|
||||||
|
execHandlers = appendEinoChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
|
||||||
|
logger: a.Logger,
|
||||||
|
phase: "plan_execute_executor",
|
||||||
|
summarization: sumMw,
|
||||||
|
modelName: a.ModelName,
|
||||||
|
conversationID: a.ConversationID,
|
||||||
|
trace: a.ModelFacingTrace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return execHandlers, nil
|
||||||
|
}
|
||||||
|
|
||||||
// planExecutePlannerGenInput 将 orchestrator instruction 作为 SystemMessage 注入 planner 输入。
|
// planExecutePlannerGenInput 将 orchestrator instruction 作为 SystemMessage 注入 planner 输入。
|
||||||
// 返回 nil 时 Eino 使用内置默认 planner prompt。
|
// 返回 nil 时 Eino 使用内置默认 planner prompt。
|
||||||
func planExecutePlannerGenInput(
|
func planExecutePlannerGenInput(
|
||||||
|
|||||||
@@ -22,15 +22,60 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
// einoSummarizeUserInstruction:压缩历史时保留渗透测试与用户约束关键信息。
|
||||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
// 结构对齐 Eino 最佳实践(禁止工具、<analysis>+<summary>、<all_user_messages>),章节为安全测试领域化。
|
||||||
|
const einoSummarizeUserInstruction = `关键:仅以纯文本响应。禁止调用任何工具(read_file、exec、grep、glob、write、edit 等)。
|
||||||
|
上述对话中已包含全部待压缩上下文;不要要求用户粘贴历史,不要输出「请提供待压缩的对话历史」等占位/meta 回复。
|
||||||
|
工具调用将被拒绝并浪费唯一一次摘要机会。
|
||||||
|
|
||||||
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
你的任务:在保持所有关键安全测试信息完整的前提下压缩对话历史,使后续代理能无缝继续同一授权测试任务。
|
||||||
保留精确技术细节(URL、路径、参数、Payload、版本号、报错原文可摘要但要点不丢)。
|
|
||||||
将冗长扫描输出概括为结论;重复发现合并表述。
|
|
||||||
已枚举资产须保留**可继承的摘要**:主域、关键子域/主机短表(或数量+代表样例)、高价值目标与已识别服务/端口要点,避免后续子代理因「看不见清单」而重复全量枚举。
|
|
||||||
|
|
||||||
输出须使后续代理能无缝继续同一授权测试任务。`
|
压缩原则:
|
||||||
|
- 必须保留:已确认漏洞与攻击路径、工具输出核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策
|
||||||
|
- 保留精确技术细节(URL、路径、参数、Payload、版本号;报错原文可摘要但要点不丢)
|
||||||
|
- 冗长扫描输出概括为结论;重复发现合并表述
|
||||||
|
- 已枚举资产须保留可继承摘要:主域、关键子域/主机短表(或数量+代表样例)、高价值目标、已识别服务/端口要点
|
||||||
|
|
||||||
|
输出格式(严格遵循,仅一轮回复):
|
||||||
|
1. 先输出 <analysis> 块:按时间顺序梳理对话,检查是否涵盖下方各章节要点;analysis 仅供自检,保持简洁(建议 ≤400 字)
|
||||||
|
2. 再输出 <summary> 块:按以下章节写入可继承的压缩报告(无信息处写「无」,禁止留空模板占位符)
|
||||||
|
|
||||||
|
<summary>
|
||||||
|
## 1. 授权范围与约束
|
||||||
|
- 目标/范围/禁止项(域名、路径、IP、环境)
|
||||||
|
- 凭证/认证信息(账号、Token、Cookie;敏感值原文保留)
|
||||||
|
- 用户指定的方法、工具、优先级与待办
|
||||||
|
- 否定约束(不测什么、不用什么手法)
|
||||||
|
|
||||||
|
## 2. 资产与服务枚举摘要
|
||||||
|
- 主域/核心资产、关键子域或主机短表(或数量+代表样例)
|
||||||
|
- 高价值目标、已识别服务/端口要点
|
||||||
|
- 资产状态(存活/可攻/已排除/待验证)
|
||||||
|
|
||||||
|
## 3. 架构与已知薄弱点
|
||||||
|
- 技术栈/部署拓扑/信任边界
|
||||||
|
- 已识别薄弱点列表
|
||||||
|
|
||||||
|
## 4. 已确认漏洞与攻击路径
|
||||||
|
- 漏洞名/CVE、URL/路径、参数/Payload、PoC 要点、影响等级
|
||||||
|
- 攻击链/利用路径(步骤化)
|
||||||
|
|
||||||
|
## 5. 工具核心发现与扫描结论
|
||||||
|
- 各工具结论(概括核心输出,非冗长日志)
|
||||||
|
- 重复发现合并表述
|
||||||
|
|
||||||
|
## 6. 所有用户消息
|
||||||
|
<all_user_messages>
|
||||||
|
- [逐条列出非 tool 结果的用户消息要点;敏感约束与原文措辞尽量保留]
|
||||||
|
</all_user_messages>
|
||||||
|
|
||||||
|
## 7. 当前进度、策略决策与下一步
|
||||||
|
- 当前位置(已完成/进行中/卡点)
|
||||||
|
- 失败尝试与死路(方法、现象/报错摘要、结论)
|
||||||
|
- 策略决策与下一步具体操作(须与最近用户请求及未完成任务一致)
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
提醒:不要调用任何工具;必须基于上文已有对话直接输出 <analysis> 与 <summary>,勿输出 analysis 以外的正文。`
|
||||||
|
|
||||||
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
||||||
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
||||||
@@ -144,13 +189,13 @@ func newEinoSummarizationMiddleware(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||||
|
summary = stripAnalysisFromSummarizationMessage(summary)
|
||||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||||
if ferr != nil {
|
if ferr != nil {
|
||||||
return nil, ferr
|
return nil, ferr
|
||||||
}
|
}
|
||||||
if appCfg != nil {
|
if appCfg != nil {
|
||||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
},
|
},
|
||||||
@@ -414,36 +459,6 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshUserVerbatimAnchorInMessages 压缩后从 messages 表刷新 system 中的用户原文锚点。
|
|
||||||
func refreshUserVerbatimAnchorInMessages(msgs []adk.Message, db *database.DB, conversationID string, maxRunes int, logger *zap.Logger) []adk.Message {
|
|
||||||
if maxRunes < 0 || db == nil {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
|
||||||
if conversationID == "" {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
rows, err := db.GetMessages(conversationID)
|
|
||||||
if err != nil {
|
|
||||||
if logger != nil {
|
|
||||||
logger.Warn("summarization: 刷新用户原文锚点失败",
|
|
||||||
zap.String("conversationId", conversationID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
block := project.BuildUserVerbatimAnchorBlockFromMessages(rows, maxRunes)
|
|
||||||
if block == "" {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
out := project.RefreshUserVerbatimAnchorInMessages(msgs, block)
|
|
||||||
if logger != nil {
|
|
||||||
logger.Info("summarization: 已刷新用户原文锚点", zap.String("conversationId", conversationID))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
||||||
tc := agent.NewTikTokenCounter()
|
tc := agent.NewTikTokenCounter()
|
||||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)<analysis>\s*.*?\s*</analysis>`)
|
||||||
|
summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)<summary>\s*(.*?)\s*</summary>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripAnalysisFromSummarizationMessage removes the <analysis> block from a post-processed
|
||||||
|
// Eino summary user message. Analysis helps one-shot generation quality but should not
|
||||||
|
// occupy continuation context after compaction.
|
||||||
|
func stripAnalysisFromSummarizationMessage(msg adk.Message) adk.Message {
|
||||||
|
if msg == nil {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
cloned := *msg
|
||||||
|
if cloned.Content != "" {
|
||||||
|
cloned.Content = stripAnalysisFromSummarizationText(cloned.Content)
|
||||||
|
}
|
||||||
|
if len(cloned.UserInputMultiContent) > 0 {
|
||||||
|
parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent))
|
||||||
|
copy(parts, cloned.UserInputMultiContent)
|
||||||
|
// Only the first text part carries model output plus Eino preamble/transcript path.
|
||||||
|
for i := range parts {
|
||||||
|
if parts[i].Type != schema.ChatMessagePartTypeText || parts[i].Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
parts[i].Text = stripAnalysisFromSummarizationText(parts[i].Text)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cloned.UserInputMultiContent = parts
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripAnalysisFromSummarizationText(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
stripped := strings.TrimSpace(summarizationAnalysisBlockRegex.ReplaceAllString(text, ""))
|
||||||
|
if stripped == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSummarizationSummaryBody returns the inner text of the last <summary> block when present.
|
||||||
|
// Used by tests and optional strict compaction paths.
|
||||||
|
func extractSummarizationSummaryBody(text string) (string, bool) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
all := summarizationSummaryBlockRegex.FindAllStringSubmatch(text, -1)
|
||||||
|
if len(all) == 0 || len(all[len(all)-1]) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(all[len(all)-1][1])
|
||||||
|
if body == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationText(t *testing.T) {
|
||||||
|
in := "<analysis>internal notes</analysis>\n\n<summary>\n## 1. 授权\n- example.com\n</summary>"
|
||||||
|
got := stripAnalysisFromSummarizationText(in)
|
||||||
|
if strings.Contains(got, "<analysis>") {
|
||||||
|
t.Fatalf("analysis block should be removed: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "## 1. 授权") {
|
||||||
|
t.Fatalf("summary body should remain: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationMessage_UserInputMultiContent(t *testing.T) {
|
||||||
|
msg := &schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
UserInputMultiContent: []schema.MessageInputPart{
|
||||||
|
{
|
||||||
|
Type: schema.ChatMessagePartTypeText,
|
||||||
|
Text: "此会话延续自此前一段因上下文耗尽而终止的对话。\n\n<analysis>draft</analysis>\n<summary>body</summary>\n\n完整记录位于:/tmp/transcript.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: schema.ChatMessagePartTypeText,
|
||||||
|
Text: "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := stripAnalysisFromSummarizationMessage(msg)
|
||||||
|
if len(out.UserInputMultiContent) != 2 {
|
||||||
|
t.Fatalf("expected 2 parts, got %d", len(out.UserInputMultiContent))
|
||||||
|
}
|
||||||
|
if strings.Contains(out.UserInputMultiContent[0].Text, "<analysis>") {
|
||||||
|
t.Fatalf("part 0 should drop analysis: %q", out.UserInputMultiContent[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.UserInputMultiContent[0].Text, "<summary>body</summary>") {
|
||||||
|
t.Fatalf("part 0 should keep summary: %q", out.UserInputMultiContent[0].Text)
|
||||||
|
}
|
||||||
|
if out.UserInputMultiContent[1].Text != "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。" {
|
||||||
|
t.Fatalf("continue instruction part should be unchanged: %q", out.UserInputMultiContent[1].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSummarizationSummaryBody(t *testing.T) {
|
||||||
|
body, ok := extractSummarizationSummaryBody("<analysis>x</analysis><summary> kept </summary>")
|
||||||
|
if !ok || body != "kept" {
|
||||||
|
t.Fatalf("extract summary body: ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
_, ok = extractSummarizationSummaryBody("plain text only")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected false for plain text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationText_NoAnalysisUnchanged(t *testing.T) {
|
||||||
|
in := "<summary>only summary</summary>"
|
||||||
|
got := stripAnalysisFromSummarizationText(in)
|
||||||
|
if got != in {
|
||||||
|
t.Fatalf("expected unchanged text, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,12 @@
|
|||||||
package multiagent
|
package multiagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/bytedance/sonic"
|
copenai "cyberstrike-ai/internal/openai"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
||||||
// chat-completions JSON body. Applied only to summarization Generate calls via
|
// chat-completions JSON body. Applied only to summarization Generate calls via
|
||||||
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
||||||
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
||||||
var payload map[string]any
|
return copenai.StripReasoningFromChatCompletionBody(rawBody)
|
||||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
|
||||||
return rawBody, nil
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
for _, key := range []string{
|
|
||||||
"thinking",
|
|
||||||
"reasoning_effort",
|
|
||||||
"output_config",
|
|
||||||
"reasoning",
|
|
||||||
} {
|
|
||||||
if _, ok := payload[key]; ok {
|
|
||||||
delete(payload, key)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return rawBody, nil
|
|
||||||
}
|
|
||||||
out, err := sonic.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return rawBody, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -75,8 +74,8 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if IsHumanRejectError(err) {
|
if IsHumanRejectError(err) {
|
||||||
// Human rejection should be a soft tool result so the model can continue iterating.
|
// Human rejection should be a soft tool result so the model can continue iterating.
|
||||||
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
// tool_search 须保持 JSON,否则 Eino toolsearch 中间件解析历史时会硬崩 ChatModel。
|
||||||
input.Name, strings.TrimSpace(err.Error()))
|
msg := HitlRejectToolResult(input.Name, err.Error())
|
||||||
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
||||||
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
||||||
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
||||||
@@ -103,8 +102,7 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
|||||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsHumanRejectError(err) {
|
if IsHumanRejectError(err) {
|
||||||
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
msg := HitlRejectToolResult(input.Name, err.Error())
|
||||||
input.Name, strings.TrimSpace(err.Error()))
|
|
||||||
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
|
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
|
||||||
return &compose.StreamToolOutput{
|
return &compose.StreamToolOutput{
|
||||||
Result: schema.StreamReaderFromArray([]string{msg}),
|
Result: schema.StreamReaderFromArray([]string{msg}),
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolSearchToolName = "tool_search"
|
||||||
|
|
||||||
|
// HitlExemptMetaTools 为编排/元工具:不直接执行攻击动作,但会阻塞 agent 控制流。
|
||||||
|
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel)。
|
||||||
|
var HitlExemptMetaTools = []string{
|
||||||
|
toolSearchToolName,
|
||||||
|
"skill",
|
||||||
|
"task",
|
||||||
|
"write_todos",
|
||||||
|
"transfer_to_agent",
|
||||||
|
"exit",
|
||||||
|
"TaskCreate",
|
||||||
|
"TaskGet",
|
||||||
|
"TaskUpdate",
|
||||||
|
"TaskList",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool.
|
||||||
|
func IsToolSearchTool(name string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(name), toolSearchToolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeHitlExemptMetaTools unions configured whitelist with built-in meta-tool exemptions.
|
||||||
|
func MergeHitlExemptMetaTools(configured []string) []string {
|
||||||
|
merged := make([]string, 0, len(configured)+len(HitlExemptMetaTools))
|
||||||
|
seen := make(map[string]struct{}, len(configured)+len(HitlExemptMetaTools))
|
||||||
|
add := func(name string) {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[n]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[n] = struct{}{}
|
||||||
|
merged = append(merged, strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
for _, t := range configured {
|
||||||
|
add(t)
|
||||||
|
}
|
||||||
|
for _, t := range HitlExemptMetaTools {
|
||||||
|
add(t)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolSearchHitlRejectPayload struct {
|
||||||
|
SelectedTools []string `json:"selectedTools"`
|
||||||
|
HitlRejected bool `json:"_hitlRejected"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HitlRejectToolResult returns a tool result body safe for downstream consumers.
|
||||||
|
// tool_search must stay JSON-shaped so toolsearch.extractSelectedTools does not terminate the graph.
|
||||||
|
func HitlRejectToolResult(toolName, reason string) string {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if !IsToolSearchTool(toolName) {
|
||||||
|
if reason == "" {
|
||||||
|
reason = "rejected by reviewer"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||||
|
strings.TrimSpace(toolName), reason)
|
||||||
|
}
|
||||||
|
payload := toolSearchHitlRejectPayload{
|
||||||
|
SelectedTools: []string{},
|
||||||
|
HitlRejected: true,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
if payload.Reason == "" {
|
||||||
|
payload.Reason = "tool_search rejected by reviewer; no dynamic tools unlocked"
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return `{"selectedTools":[],"_hitlRejected":true,"reason":"tool_search rejected by reviewer"}`
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHitlRejectToolResult_toolSearchIsJSON(t *testing.T) {
|
||||||
|
raw := HitlRejectToolResult("tool_search", "rejected by user: timeout")
|
||||||
|
var payload toolSearchHitlRejectPayload
|
||||||
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.SelectedTools) != 0 {
|
||||||
|
t.Fatalf("expected empty selectedTools, got %v", payload.SelectedTools)
|
||||||
|
}
|
||||||
|
if !payload.HitlRejected {
|
||||||
|
t.Fatal("expected _hitlRejected true")
|
||||||
|
}
|
||||||
|
if !strings.Contains(payload.Reason, "timeout") {
|
||||||
|
t.Fatalf("reason=%q", payload.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHitlRejectToolResult_otherToolKeepsLegacyText(t *testing.T) {
|
||||||
|
raw := HitlRejectToolResult("nmap", "too risky")
|
||||||
|
if strings.HasPrefix(raw, "{") {
|
||||||
|
t.Fatalf("expected legacy text, got %q", raw)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, "[HITL Reject]") {
|
||||||
|
t.Fatalf("expected [HITL Reject] prefix, got %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) {
|
||||||
|
merged := MergeHitlExemptMetaTools([]string{"read_file"})
|
||||||
|
found := false
|
||||||
|
for _, name := range merged {
|
||||||
|
if IsToolSearchTool(name) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("tool_search missing from %v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/components/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubChatModelAgentMiddleware struct {
|
||||||
|
adk.BaseChatModelAgentMiddleware
|
||||||
|
tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubMW(tag string) adk.ChatModelAgentMiddleware {
|
||||||
|
return &stubChatModelAgentMiddleware{tag: tag}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanExecuteExecutorHandlers_IncludesExecPreMiddlewares(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pre := []adk.ChatModelAgentMiddleware{
|
||||||
|
stubMW("patch"),
|
||||||
|
stubMW("reduction"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := buildPlanExecuteExecutorHandlers(context.Background(), &PlanExecuteRootArgs{
|
||||||
|
ExecPreMiddlewares: pre,
|
||||||
|
FilesystemMiddleware: stubMW("filesystem"),
|
||||||
|
SkillMiddleware: stubMW("skill"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildPlanExecuteExecutorHandlers: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("expected 4 pre-tail handlers (2 pre + fs + skill), got %d", len(got))
|
||||||
|
}
|
||||||
|
for i, want := range []string{"patch", "reduction", "filesystem", "skill"} {
|
||||||
|
st, ok := got[i].(*stubChatModelAgentMiddleware)
|
||||||
|
if !ok || st.tag != want {
|
||||||
|
t.Fatalf("handler[%d]: got %#v want tag %q", i, got[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubTools(n int) []tool.BaseTool {
|
||||||
|
out := make([]tool.BaseTool, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out[i] = stubTool{name: fmt.Sprintf("t%d", i)}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanExecuteExecutorHandlers_NilArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, err := buildPlanExecuteExecutorHandlers(context.Background(), nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil args")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrependEinoMiddlewares_Main_IncludesPatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
mw := configMultiAgentEinoMiddlewareForTest()
|
||||||
|
mw.ReductionEnable = false
|
||||||
|
mw.ToolSearchEnable = false
|
||||||
|
mw.PlantaskEnable = false
|
||||||
|
_, extra, _, err := prependEinoMiddlewares(ctx, mw, einoMWMain, stubTools(25), nil, "", "conv-test", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prependEinoMiddlewares: %v", err)
|
||||||
|
}
|
||||||
|
if len(extra) == 0 {
|
||||||
|
t.Fatal("expected patch middleware on einoMWMain when patch_tool_calls enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configMultiAgentEinoMiddlewareForTest() *config.MultiAgentEinoMiddlewareConfig {
|
||||||
|
patch := true
|
||||||
|
return &config.MultiAgentEinoMiddlewareConfig{
|
||||||
|
PatchToolCalls: &patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -432,6 +432,22 @@ func RunDeepAgent(
|
|||||||
var da adk.Agent
|
var da adk.Agent
|
||||||
switch orchMode {
|
switch orchMode {
|
||||||
case "plan_execute":
|
case "plan_execute":
|
||||||
|
plannerModelCfg := &einoopenai.ChatModelConfig{
|
||||||
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
|
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
||||||
|
Model: appCfg.OpenAI.Model,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
}
|
||||||
|
reasoning.ApplyPlanExecutePlannerModelConfig(plannerModelCfg, &appCfg.OpenAI)
|
||||||
|
peMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("plan_execute 规划模型: %w", perr)
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("plan_execute: planner/replanner 使用无 reasoning 的独立 ChatModel(ToolChoiceForced 兼容)",
|
||||||
|
zap.String("model", appCfg.OpenAI.Model),
|
||||||
|
)
|
||||||
|
}
|
||||||
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
||||||
@@ -445,7 +461,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
|
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
|
||||||
MainToolCallingModel: mainModel,
|
MainToolCallingModel: peMainModel,
|
||||||
ExecModel: execModel,
|
ExecModel: execModel,
|
||||||
OrchInstruction: orchInstruction,
|
OrchInstruction: orchInstruction,
|
||||||
ToolsCfg: mainToolsCfg,
|
ToolsCfg: mainToolsCfg,
|
||||||
@@ -458,6 +474,7 @@ func RunDeepAgent(
|
|||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
ModelName: appCfg.OpenAI.Model,
|
ModelName: appCfg.OpenAI.Model,
|
||||||
|
// 与 Deep/Supervisor 主代理同源:patch / reduction / toolsearch / plantask(见 buildPlanExecuteExecutorHandlers)。
|
||||||
ExecPreMiddlewares: mainOrchestratorPre,
|
ExecPreMiddlewares: mainOrchestratorPre,
|
||||||
SkillMiddleware: einoSkillMW,
|
SkillMiddleware: einoSkillMW,
|
||||||
FilesystemMiddleware: peFsMw,
|
FilesystemMiddleware: peFsMw,
|
||||||
|
|||||||
@@ -806,10 +806,12 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool {
|
|||||||
// Eino HTTP Client Bridge
|
// Eino HTTP Client Bridge
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装:
|
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含多层 transport 包装:
|
||||||
// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明
|
// 1. 当 cfg.Provider 为 claude 时,套 claudeRoundTripper,把 OpenAI /chat/completions 透明
|
||||||
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
|
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
|
||||||
// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
|
// 2. reasoningToolChoiceCompatRoundTripper:tool_choice=required/object 时剥离 thinking 字段,避免
|
||||||
|
// plan_execute replanner 等强制工具调用与推理模式冲突(部分网关返回 400)。
|
||||||
|
// 3. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
|
||||||
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
|
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
|
||||||
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。
|
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。
|
||||||
//
|
//
|
||||||
@@ -825,6 +827,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
|||||||
if transport == nil {
|
if transport == nil {
|
||||||
transport = http.DefaultTransport
|
transport = http.DefaultTransport
|
||||||
}
|
}
|
||||||
|
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
|
||||||
if isClaudeProvider(cfg) {
|
if isClaudeProvider(cfg) {
|
||||||
transport = &claudeRoundTripper{
|
transport = &claudeRoundTripper{
|
||||||
base: transport,
|
base: transport,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reasoningPayloadKeys are OpenAI-compatible root fields that enable "thinking" /
|
||||||
|
// extended-reasoning modes on gateways such as DashScope/Qwen and MiniMax.
|
||||||
|
var reasoningPayloadKeys = []string{
|
||||||
|
"thinking",
|
||||||
|
"reasoning_effort",
|
||||||
|
"output_config",
|
||||||
|
"reasoning",
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripReasoningFromChatCompletionBody removes thinking / reasoning fields from a
|
||||||
|
// chat-completions JSON body.
|
||||||
|
func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
if !stripReasoningFields(payload) {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
out, err := sonic.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return rawBody, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the
|
||||||
|
// request sets tool_choice to "required" or an object. Several providers reject
|
||||||
|
// that combination (e.g. DashScope: "tool_choice does not support being set to
|
||||||
|
// required or object in thinking mode").
|
||||||
|
func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
if !forcedToolChoiceIncompatibleWithThinking(payload) {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
if !stripReasoningFields(payload) {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
out, err := sonic.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return rawBody, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripReasoningFields(payload map[string]any) bool {
|
||||||
|
changed := false
|
||||||
|
for _, key := range reasoningPayloadKeys {
|
||||||
|
if _, ok := payload[key]; ok {
|
||||||
|
delete(payload, key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool {
|
||||||
|
tc, ok := payload["tool_choice"]
|
||||||
|
if !ok || tc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v := tc.(type) {
|
||||||
|
case string:
|
||||||
|
return v == "required"
|
||||||
|
case map[string]any:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripReasoningFromChatCompletionBody(t *testing.T) {
|
||||||
|
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||||
|
out, err := StripReasoningFromChatCompletionBody(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
|
||||||
|
t.Fatalf("expected reasoning fields stripped, got %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"model":"deepseek-chat"`) {
|
||||||
|
t.Fatalf("expected model preserved, got %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
|
||||||
|
out2, err := StripReasoningFromChatCompletionBody(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(out2) != string(plain) {
|
||||||
|
t.Fatalf("expected unchanged payload, got %s", out2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripReasoningIfForcedToolChoice(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
strip bool
|
||||||
|
contain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "required strips thinking",
|
||||||
|
in: `{"model":"minimax","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[]}`,
|
||||||
|
strip: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "object tool_choice strips thinking",
|
||||||
|
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":{"type":"function","function":{"name":"respond"}}}`,
|
||||||
|
strip: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auto keeps thinking",
|
||||||
|
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":"auto"}`,
|
||||||
|
strip: false,
|
||||||
|
contain: "thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no tool_choice keeps thinking",
|
||||||
|
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"}}`,
|
||||||
|
strip: false,
|
||||||
|
contain: "thinking",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := StripReasoningIfForcedToolChoice([]byte(tc.in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
hasThinking := strings.Contains(s, "thinking")
|
||||||
|
if tc.strip && hasThinking {
|
||||||
|
t.Fatalf("expected thinking stripped, got %s", s)
|
||||||
|
}
|
||||||
|
if !tc.strip && tc.contain != "" && !strings.Contains(s, tc.contain) {
|
||||||
|
t.Fatalf("expected %q in %s", tc.contain, s)
|
||||||
|
}
|
||||||
|
if !tc.strip && string(out) != tc.in {
|
||||||
|
t.Fatalf("expected unchanged payload, got %s", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
||||||
|
var gotBody string
|
||||||
|
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||||
|
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", strings.NewReader(
|
||||||
|
`{"model":"m","thinking":{"type":"enabled"},"tool_choice":"required","messages":[]}`,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = rt.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(gotBody, "thinking") {
|
||||||
|
t.Fatalf("expected thinking stripped in transit, got %s", gotBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody, `"tool_choice":"required"`) {
|
||||||
|
t.Fatalf("expected tool_choice preserved, got %s", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
||||||
|
// chat/completions requests that force tool_choice, which some gateways reject
|
||||||
|
// when thinking mode is enabled on the same request.
|
||||||
|
type reasoningToolChoiceCompatRoundTripper struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if rt == nil || rt.base == nil || req == nil || req.Body == nil {
|
||||||
|
if rt != nil && rt.base != nil {
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
return http.DefaultTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodPost || !strings.HasSuffix(req.URL.Path, "/chat/completions") {
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
patched, perr := StripReasoningIfForcedToolChoice(body)
|
||||||
|
if perr != nil {
|
||||||
|
patched = body
|
||||||
|
}
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(patched))
|
||||||
|
req.ContentLength = int64(len(patched))
|
||||||
|
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
|
|
||||||
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
|
|
||||||
|
|
||||||
// UserVerbatimSectionStartMarker / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
|
||||||
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
|
|
||||||
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
|
|
||||||
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
|
|
||||||
out := make([]string, 0, len(msgs))
|
|
||||||
for i := range msgs {
|
|
||||||
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content := strings.TrimSpace(msgs[i].Content)
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, content)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
|
|
||||||
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
|
|
||||||
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
|
|
||||||
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
|
|
||||||
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
|
|
||||||
if len(userContents) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, len(userContents))
|
|
||||||
for _, content := range userContents {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
body := strings.Join(lines, "\n")
|
|
||||||
if maxRunes > 0 {
|
|
||||||
body = capUserVerbatimBody(body, maxRunes)
|
|
||||||
}
|
|
||||||
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func capUserVerbatimBody(body string, maxRunes int) string {
|
|
||||||
rs := []rune(body)
|
|
||||||
if len(rs) <= maxRunes {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
|
|
||||||
suffixRunes := []rune(suffix)
|
|
||||||
keep := maxRunes - len(suffixRunes)
|
|
||||||
if keep <= 0 {
|
|
||||||
return string(rs[:maxRunes])
|
|
||||||
}
|
|
||||||
return string(rs[:keep]) + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapUserVerbatimBlock(content string) string {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
|
|
||||||
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
freshBlock = strings.TrimSpace(freshBlock)
|
|
||||||
if freshBlock == "" {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
start, ok := userVerbatimSectionStart(content)
|
|
||||||
if !ok {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
end, ok := userVerbatimSectionEnd(content, start)
|
|
||||||
if !ok {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func userVerbatimSectionStart(content string) (int, bool) {
|
|
||||||
idx := strings.Index(content, UserVerbatimSectionStartMarker)
|
|
||||||
if idx < 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return idx, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func userVerbatimSectionEnd(content string, start int) (int, bool) {
|
|
||||||
if start < 0 || start >= len(content) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
tail := content[start:]
|
|
||||||
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
|
|
||||||
if idx < 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return start + idx + len(UserVerbatimSectionEndMarker), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
|
|
||||||
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
|
|
||||||
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
|
|
||||||
freshBlock = strings.TrimSpace(freshBlock)
|
|
||||||
if freshBlock == "" || len(msgs) == 0 {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]adk.Message, len(msgs))
|
|
||||||
changed := false
|
|
||||||
for i, msg := range msgs {
|
|
||||||
if msg == nil || msg.Role != schema.System {
|
|
||||||
out[i] = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
|
|
||||||
if !ok {
|
|
||||||
out[i] = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cloned := *msg
|
|
||||||
cloned.Content = newContent
|
|
||||||
out[i] = &cloned
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, msg := range msgs {
|
|
||||||
if msg == nil || msg.Role != schema.System {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cloned := *msg
|
|
||||||
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
|
|
||||||
out[i] = &cloned
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := make([]adk.Message, 0, len(msgs)+1)
|
|
||||||
prefix = append(prefix, schema.SystemMessage(freshBlock))
|
|
||||||
return append(prefix, msgs...)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
|
|
||||||
msgs := []database.Message{
|
|
||||||
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
|
|
||||||
{Role: "assistant", Content: "好的"},
|
|
||||||
{Role: "user", Content: "用 admin:test 登录"},
|
|
||||||
}
|
|
||||||
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
|
|
||||||
if block == "" {
|
|
||||||
t.Fatal("expected non-empty block")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
|
|
||||||
t.Error("missing start marker")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
|
|
||||||
t.Error("missing first user turn")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
|
|
||||||
t.Error("missing second user turn")
|
|
||||||
}
|
|
||||||
if strings.Contains(block, "好的") {
|
|
||||||
t.Error("assistant content should not appear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
|
|
||||||
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
|
|
||||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
|
|
||||||
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected replace ok")
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "[第2轮] c") {
|
|
||||||
t.Errorf("expected new block, got %q", out)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
|
|
||||||
t.Error("prefix should remain")
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "suffix") {
|
|
||||||
t.Error("suffix should remain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
|
|
||||||
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
|
|
||||||
msgs := []adk.Message{
|
|
||||||
schema.SystemMessage("instr\n\n" + oldBlock),
|
|
||||||
schema.UserMessage("hi"),
|
|
||||||
}
|
|
||||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
|
|
||||||
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("message count: got %d", len(out))
|
|
||||||
}
|
|
||||||
if !strings.Contains(out[0].Content, "[第1轮] new") {
|
|
||||||
t.Errorf("system content: %q", out[0].Content)
|
|
||||||
}
|
|
||||||
if strings.Contains(out[0].Content, "[第1轮] old") {
|
|
||||||
t.Error("old anchor should be replaced")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
|
|
||||||
msgs := []adk.Message{
|
|
||||||
schema.SystemMessage("base instruction"),
|
|
||||||
schema.UserMessage("hi"),
|
|
||||||
}
|
|
||||||
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
|
|
||||||
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
|
|
||||||
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
|
|
||||||
t.Errorf("expected appended anchor, got %q", out[0].Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
|
|
||||||
long := strings.Repeat("字", 200)
|
|
||||||
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
|
|
||||||
body := block
|
|
||||||
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
|
|
||||||
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
|
|
||||||
}
|
|
||||||
if len([]rune(body)) > 120 {
|
|
||||||
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,35 @@ const (
|
|||||||
wireOutputConfig
|
wireOutputConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ApplyPlanExecutePlannerModelConfig configures the plan_execute planner/replanner
|
||||||
|
// ChatModel. Those Eino agents call WithToolChoice(Forced); several gateways reject
|
||||||
|
// thinking / reasoning fields on the same request (tool_choice required/object).
|
||||||
|
// Executor should keep the normal ApplyToEinoChatModelConfig path.
|
||||||
|
func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig) {
|
||||||
|
if cfg == nil || oa == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offOA := *oa
|
||||||
|
offReasoning := oa.Reasoning
|
||||||
|
offReasoning.Mode = "off"
|
||||||
|
offOA.Reasoning = offReasoning
|
||||||
|
ApplyToEinoChatModelConfig(cfg, &offOA, nil)
|
||||||
|
clearReasoningFromChatModelConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ReasoningEffort = ""
|
||||||
|
if cfg.ExtraFields != nil {
|
||||||
|
for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} {
|
||||||
|
delete(cfg.ExtraFields, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyThinkingDisabled(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
|
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
|
||||||
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
|
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
|
||||||
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
|
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
|
||||||
|
|||||||
@@ -49,6 +49,30 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
BaseURL: "https://antchat.example.com/v1",
|
||||||
|
Model: "minimax-m3",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Profile: "openai_compat",
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
||||||
|
if cfg.ReasoningEffort != "" {
|
||||||
|
t.Fatalf("expected ReasoningEffort cleared, got %q", cfg.ReasoningEffort)
|
||||||
|
}
|
||||||
|
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||||
|
if !ok || th["type"] != "disabled" {
|
||||||
|
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields)
|
||||||
|
}
|
||||||
|
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
|
||||||
|
t.Fatalf("expected reasoning_effort stripped, got %#v", cfg.ExtraFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyReasoningOff_disablesThinking(t *testing.T) {
|
func TestApplyReasoningOff_disablesThinking(t *testing.T) {
|
||||||
cfg := &einoopenai.ChatModelConfig{}
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
oa := &config.OpenAIConfig{
|
oa := &config.OpenAIConfig{
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
name: "virustotal_search"
|
||||||
|
command: "python3"
|
||||||
|
args:
|
||||||
|
- "-c"
|
||||||
|
- |
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ==================== VirusTotal 配置 ====================
|
||||||
|
# 请在此处配置您的 VirusTotal API 密钥
|
||||||
|
# 您也可以在环境变量中设置:VT_API_KEY
|
||||||
|
# enable 默认为 false,需开启才能调用该MCP
|
||||||
|
VT_API_KEY = "" # 请填写您的 VirusTotal API 密钥
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
# VirusTotal API 基础 URL
|
||||||
|
BASE_URL = "https://www.virustotal.com/api/v3"
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""解析命令行参数"""
|
||||||
|
# 尝试从第一个参数读取 JSON 配置
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
arg1 = str(sys.argv[1])
|
||||||
|
config = json.loads(arg1)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
return config
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 传统位置参数方式
|
||||||
|
config = {}
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
config['domain'] = str(sys.argv[1])
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
try:
|
||||||
|
config['limit'] = int(sys.argv[2])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if len(sys.argv) > 3:
|
||||||
|
config['include_ips'] = sys.argv[3].lower() in ('true', '1', 'yes')
|
||||||
|
return config
|
||||||
|
|
||||||
|
def query_virustotal_subdomains(domain, api_key, limit=100, include_ips=False):
|
||||||
|
"""
|
||||||
|
查询 VirusTotal 的子域名信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: 要查询的域名
|
||||||
|
api_key: VirusTotal API 密钥
|
||||||
|
limit: 返回结果数量限制
|
||||||
|
include_ips: 是否包含 IP 地址信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含查询结果的字典
|
||||||
|
"""
|
||||||
|
# 构建 API 请求 URL
|
||||||
|
url = f"{BASE_URL}/domains/{domain}/subdomains"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-apikey": api_key,
|
||||||
|
"accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"limit": min(limit, 40) # API 限制最大 40
|
||||||
|
}
|
||||||
|
|
||||||
|
all_results = []
|
||||||
|
next_url = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 处理分页
|
||||||
|
while True:
|
||||||
|
if next_url:
|
||||||
|
response = requests.get(next_url, headers=headers, timeout=30)
|
||||||
|
else:
|
||||||
|
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 提取子域名数据
|
||||||
|
if 'data' in data and data['data']:
|
||||||
|
for item in data['data']:
|
||||||
|
if 'id' in item:
|
||||||
|
subdomain_info = {
|
||||||
|
'subdomain': item['id'],
|
||||||
|
'type': item.get('type', 'domain'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果 include_ips 为 True,尝试获取解析 IP
|
||||||
|
if include_ips and 'attributes' in item:
|
||||||
|
attributes = item.get('attributes', {})
|
||||||
|
# 这里简化处理,实际可能需要额外的 API 调用
|
||||||
|
subdomain_info['last_dns_records'] = attributes.get('last_dns_records', [])
|
||||||
|
|
||||||
|
all_results.append(subdomain_info)
|
||||||
|
|
||||||
|
# 检查是否有下一页
|
||||||
|
if 'links' in data and 'next' in data['links'] and len(all_results) < limit:
|
||||||
|
next_url = data['links']['next']
|
||||||
|
# 避免请求过快
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果已达到限制,停止获取
|
||||||
|
if len(all_results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 处理返回结果
|
||||||
|
if all_results:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"domain": domain,
|
||||||
|
"total_found": len(all_results),
|
||||||
|
"results": all_results[:limit],
|
||||||
|
"message": f"成功获取 {len(all_results[:limit])} 个子域名"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"domain": domain,
|
||||||
|
"total_found": 0,
|
||||||
|
"results": [],
|
||||||
|
"message": f"未找到 {domain} 的子域名"
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"API 请求失败: {error_msg}",
|
||||||
|
"suggestion": "请检查网络连接、API 密钥是否正确,或 VirusTotal API 服务是否可用"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 处理特定 HTTP 状态码
|
||||||
|
if hasattr(e, 'response') and e.response:
|
||||||
|
status_code = e.response.status_code
|
||||||
|
if status_code == 401:
|
||||||
|
error_result["message"] = "API 密钥无效或未授权"
|
||||||
|
error_result["suggestion"] = "请检查 VirusTotal API 密钥是否正确,或在 https://www.virustotal.com/ 获取有效密钥"
|
||||||
|
elif status_code == 429:
|
||||||
|
error_result["message"] = "API 请求频率超限"
|
||||||
|
error_result["suggestion"] = "请稍后再试,VirusTotal API 有严格的速率限制(免费版每分钟4次)"
|
||||||
|
elif status_code == 404:
|
||||||
|
error_result["message"] = f"域名 '{domain}' 不存在或未找到"
|
||||||
|
|
||||||
|
return error_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = parse_args()
|
||||||
|
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||||
|
"type": "TypeError"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 获取 API 密钥(从配置或环境变量)
|
||||||
|
api_key = os.getenv('VT_API_KEY', VT_API_KEY).strip()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少 VirusTotal API 密钥",
|
||||||
|
"required_config": ["VT_API_KEY"],
|
||||||
|
"note": "请在 YAML 文件的 VT_API_KEY 配置项中填写您的 VirusTotal API 密钥,或在环境变量 VT_API_KEY 中设置。API 密钥可在 https://www.virustotal.com/ 注册获取"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 获取必需参数
|
||||||
|
domain = config.get('domain', '').strip()
|
||||||
|
if not domain:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少必需参数: domain(要查询的域名)",
|
||||||
|
"required_params": ["domain"],
|
||||||
|
"examples": [
|
||||||
|
"example.com",
|
||||||
|
"google.com",
|
||||||
|
"baidu.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 获取可选参数
|
||||||
|
limit = config.get('limit', 100)
|
||||||
|
try:
|
||||||
|
limit = int(limit)
|
||||||
|
if limit < 1:
|
||||||
|
limit = 100
|
||||||
|
elif limit > 1000:
|
||||||
|
limit = 1000 # 限制最大 1000
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
include_ips = config.get('include_ips', False)
|
||||||
|
if isinstance(include_ips, str):
|
||||||
|
include_ips = include_ips.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
# 执行查询
|
||||||
|
result = query_virustotal_subdomains(domain, api_key, limit, include_ips)
|
||||||
|
|
||||||
|
# 输出结果
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"执行出错: {str(e)}",
|
||||||
|
"type": type(e).__name__
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
short_description: "VirusTotal 子域名查询工具,通过 VirusTotal API 被动收集域名子域名"
|
||||||
|
|
||||||
|
description: |
|
||||||
|
VirusTotal 子域名查询工具,利用 VirusTotal 聚合的历史 DNS 数据来发现目标域名的子域名。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- 被动子域名收集:从 VirusTotal 历史 DNS 数据中检索子域名
|
||||||
|
- 分页查询:支持大量子域名的获取
|
||||||
|
- IP 关联:可选包含 DNS 解析记录
|
||||||
|
- 去重处理:自动去重返回结果
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 安全测试前期信息收集
|
||||||
|
- 企业网络资产发现
|
||||||
|
- 攻击面分析
|
||||||
|
- 威胁情报收集
|
||||||
|
- 渗透测试信息收集
|
||||||
|
|
||||||
|
**数据来源:**
|
||||||
|
VirusTotal 聚合了来自多个来源的 DNS 数据,包括:
|
||||||
|
- 历史 DNS 解析记录
|
||||||
|
- 被动 DNS 数据库
|
||||||
|
- 证书透明度日志
|
||||||
|
- 安全扫描数据
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- **API 密钥必需**:需要在 VirusTotal 注册账号并获取 API 密钥
|
||||||
|
- **速率限制**:免费版 API 每分钟限制 4 次请求
|
||||||
|
- **数据时效性**:数据基于历史扫描记录,可能不是实时的
|
||||||
|
- **使用授权**:仅允许对您拥有合法授权的目标进行查询
|
||||||
|
- **配额限制**:免费版每月有查询配额限制
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
- name: "domain"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
要查询的目标域名(必需)。
|
||||||
|
|
||||||
|
**格式要求:**
|
||||||
|
- 仅输入主域名,不要包含协议头(http://)或路径
|
||||||
|
- 支持二级域名查询
|
||||||
|
|
||||||
|
**示例值:**
|
||||||
|
- "example.com"
|
||||||
|
- "google.com"
|
||||||
|
- "baidu.com"
|
||||||
|
- "github.com"
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- 域名格式必须正确
|
||||||
|
- 查询结果可能包含跨域子域名
|
||||||
|
required: true
|
||||||
|
position: 2
|
||||||
|
format: "positional"
|
||||||
|
|
||||||
|
- name: "limit"
|
||||||
|
type: "int"
|
||||||
|
description: |
|
||||||
|
返回结果数量限制(可选)。
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- 默认值:40
|
||||||
|
- 最大值:1000(API 限制)
|
||||||
|
- 建议值:100-500
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- 设置过大的值可能导致请求超时
|
||||||
|
- API 单次返回限制为 40 条,超过会自动分页
|
||||||
|
required: false
|
||||||
|
position: 3
|
||||||
|
format: "positional"
|
||||||
|
default: 40
|
||||||
|
|
||||||
|
- name: "include_ips"
|
||||||
|
type: "bool"
|
||||||
|
description: |
|
||||||
|
是否包含 IP 地址信息(可选)。
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- true:在结果中包含 DNS 解析记录
|
||||||
|
- false:仅返回子域名列表
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- 包含 IP 信息会增加 API 调用次数
|
||||||
|
- 可能包含历史解析 IP,不一定准确
|
||||||
|
required: false
|
||||||
|
position: 4
|
||||||
|
format: "positional"
|
||||||
|
default: false
|
||||||
+88
-1
@@ -33,6 +33,93 @@
|
|||||||
--c2-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
|
--c2-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--c2-accent: #60a5fa;
|
||||||
|
--c2-accent-hover: #93c5fd;
|
||||||
|
--c2-accent-dim: rgba(96, 165, 250, 0.14);
|
||||||
|
--c2-accent-glow: rgba(96, 165, 250, 0.28);
|
||||||
|
--c2-green: #34d399;
|
||||||
|
--c2-green-dim: rgba(52, 211, 153, 0.14);
|
||||||
|
--c2-red: #f87171;
|
||||||
|
--c2-red-dim: rgba(248, 113, 113, 0.14);
|
||||||
|
--c2-amber: #fbbf24;
|
||||||
|
--c2-amber-dim: rgba(251, 191, 36, 0.14);
|
||||||
|
--c2-purple: #a78bfa;
|
||||||
|
--c2-purple-dim: rgba(167, 139, 250, 0.14);
|
||||||
|
--c2-surface: #111827;
|
||||||
|
--c2-surface-alt: #0b1120;
|
||||||
|
--c2-border: #263244;
|
||||||
|
--c2-border-hover: #3b4a63;
|
||||||
|
--c2-text: #e5e7eb;
|
||||||
|
--c2-text-dim: #a7b0c0;
|
||||||
|
--c2-text-muted: #6b7280;
|
||||||
|
--c2-shadow-sm: 0 1px 3px rgba(0,0,0,0.34);
|
||||||
|
--c2-shadow-md: 0 8px 24px rgba(0,0,0,0.38);
|
||||||
|
--c2-shadow-lg: 0 18px 48px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-modal,
|
||||||
|
html[data-theme="dark"] .c2-modal-content,
|
||||||
|
html[data-theme="dark"] .c2-tab-panel--card,
|
||||||
|
html[data-theme="dark"] .c2-session-detail,
|
||||||
|
html[data-theme="dark"] .c2-payload-card,
|
||||||
|
html[data-theme="dark"] .c2-profile-card,
|
||||||
|
html[data-theme="dark"] .c2-event-card,
|
||||||
|
html[data-theme="dark"] .c2-task-row,
|
||||||
|
html[data-theme="dark"] .c2-listener-card {
|
||||||
|
background: var(--c2-surface);
|
||||||
|
color: var(--c2-text);
|
||||||
|
border-color: var(--c2-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-listener-card:hover,
|
||||||
|
html[data-theme="dark"] .c2-payload-card:hover,
|
||||||
|
html[data-theme="dark"] .c2-profile-card:hover {
|
||||||
|
border-color: var(--c2-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-session-chip,
|
||||||
|
html[data-theme="dark"] .c2-listener-pill,
|
||||||
|
html[data-theme="dark"] .c2-task-type-badge,
|
||||||
|
html[data-theme="dark"] .c2-tab-btn {
|
||||||
|
background: var(--c2-surface-alt);
|
||||||
|
border-color: var(--c2-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] #page-c2-listeners,
|
||||||
|
html[data-theme="dark"] #page-c2-sessions,
|
||||||
|
html[data-theme="dark"] #page-c2-tasks,
|
||||||
|
html[data-theme="dark"] #page-c2-payloads,
|
||||||
|
html[data-theme="dark"] #page-c2-events,
|
||||||
|
html[data-theme="dark"] #page-c2-profiles,
|
||||||
|
html[data-theme="dark"] #page-c2-listeners .page-content,
|
||||||
|
html[data-theme="dark"] #page-c2-sessions .page-content,
|
||||||
|
html[data-theme="dark"] #page-c2-tasks .page-content,
|
||||||
|
html[data-theme="dark"] #page-c2-payloads .page-content,
|
||||||
|
html[data-theme="dark"] #page-c2-events .page-content,
|
||||||
|
html[data-theme="dark"] #page-c2-profiles .page-content,
|
||||||
|
html[data-theme="dark"] .c2-session-layout,
|
||||||
|
html[data-theme="dark"] .c2-session-main {
|
||||||
|
background: var(--c2-surface-alt) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-session-sidebar-wrap,
|
||||||
|
html[data-theme="dark"] .c2-sessions-toolbar,
|
||||||
|
html[data-theme="dark"] .c2-session-sidebar {
|
||||||
|
background: #0b1120 !important;
|
||||||
|
border-color: var(--c2-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-session-main-empty {
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--c2-text-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .c2-session-main-empty__icon {
|
||||||
|
background: var(--c2-accent-dim) !important;
|
||||||
|
border-color: rgba(96, 165, 250, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Form Controls (scoped to C2 pages)
|
Form Controls (scoped to C2 pages)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -533,7 +620,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 12px 16px 16px;
|
padding: 12px 16px 16px;
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 180px);
|
background: linear-gradient(180deg, var(--c2-surface-alt) 0%, var(--c2-surface) 180px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
+3040
-16
File diff suppressed because it is too large
Load Diff
+120
-3
@@ -39,6 +39,15 @@
|
|||||||
"version": "Current version",
|
"version": "Current version",
|
||||||
"toggleSidebar": "Collapse/expand sidebar"
|
"toggleSidebar": "Collapse/expand sidebar"
|
||||||
},
|
},
|
||||||
|
"theme": {
|
||||||
|
"system": "System",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"titleSystem": "Current: system theme. Click to switch to light.",
|
||||||
|
"titleLight": "Current: light theme. Click to switch to dark.",
|
||||||
|
"titleDark": "Current: dark theme. Click to switch to system.",
|
||||||
|
"toggle": "Toggle theme"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"empty": "No new events",
|
"empty": "No new events",
|
||||||
@@ -504,6 +513,12 @@
|
|||||||
"filterByProject": "Filter by project",
|
"filterByProject": "Filter by project",
|
||||||
"filterAllProjects": "All projects",
|
"filterAllProjects": "All projects",
|
||||||
"filterUnboundProjects": "Unbound",
|
"filterUnboundProjects": "Unbound",
|
||||||
|
"filterProjectSearch": "Search projects…",
|
||||||
|
"filterProjectSearchEmpty": "No matching projects",
|
||||||
|
"filterProjectSearchHint": "Type to search projects",
|
||||||
|
"filterProjectSearchMore": "Type to find more projects",
|
||||||
|
"filterProjectSearchLoading": "Searching…",
|
||||||
|
"filterProjectSearchFailed": "Failed to load projects. Try again.",
|
||||||
"projectConversationsTitle": "{{name}} · Conversations",
|
"projectConversationsTitle": "{{name}} · Conversations",
|
||||||
"unboundConversationsTitle": "Unbound conversations",
|
"unboundConversationsTitle": "Unbound conversations",
|
||||||
"noProjectConversations": "No conversations in this project",
|
"noProjectConversations": "No conversations in this project",
|
||||||
@@ -645,7 +660,10 @@
|
|||||||
"agentModeOrchSupervisor": "Supervisor",
|
"agentModeOrchSupervisor": "Supervisor",
|
||||||
"hitlTitle": "Human-in-the-loop",
|
"hitlTitle": "Human-in-the-loop",
|
||||||
"hitlCardSubtitle": "Approvals & allowlist",
|
"hitlCardSubtitle": "Approvals & allowlist",
|
||||||
"hitlReviewer": "Review",
|
"hitlReviewerLabel": "Reviewer",
|
||||||
|
"hitlReviewerHuman": "Human approval",
|
||||||
|
"hitlReviewerAgent": "Audit Agent",
|
||||||
|
"hitlReviewerHint": "Switch between human and Audit Agent anytime; rules and whitelist stay the same. You can pre-select even when HITL is off.",
|
||||||
"hitlConfigTitle": "Collaboration mode config",
|
"hitlConfigTitle": "Collaboration mode config",
|
||||||
"hitlModeLabel": "Mode",
|
"hitlModeLabel": "Mode",
|
||||||
"hitlModeOff": "Off",
|
"hitlModeOff": "Off",
|
||||||
@@ -664,7 +682,89 @@
|
|||||||
},
|
},
|
||||||
"hitl": {
|
"hitl": {
|
||||||
"pageTitle": "HITL approvals",
|
"pageTitle": "HITL approvals",
|
||||||
|
"pageReviewerLabel": "Current reviewer",
|
||||||
|
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved locally for new chats. Takes effect immediately.",
|
||||||
|
"pageReviewerSaved": "Reviewer saved.",
|
||||||
|
"whitelistLabel": "Tool whitelist (no approval)",
|
||||||
|
"whitelistHint": "One per line or comma-separated. Saved to config.yaml global whitelist and takes effect immediately (synced with chat sidebar).",
|
||||||
|
"whitelistSaved": "Whitelist saved.",
|
||||||
|
"whitelistSaveFailed": "Failed to save whitelist",
|
||||||
|
"strategyLabel": "Audit strategy (prompt)",
|
||||||
|
"strategyHint": "Whitelisted tools skip approval. Other tools are judged by the model using this prompt when Audit Agent is selected.",
|
||||||
|
"strategyTabApproval": "Approval mode",
|
||||||
|
"strategyTabReviewEdit": "Review & edit mode",
|
||||||
|
"strategyHintApproval": "Whitelisted tools skip approval. In approval mode the Audit Agent only approves or rejects.",
|
||||||
|
"strategyHintReviewEdit": "In review & edit mode the Audit Agent may narrow parameters via editedArguments before approve; reject if parameters cannot be safely adjusted.",
|
||||||
|
"strategyReset": "Reset to default",
|
||||||
|
"strategySaved": "Audit strategy saved.",
|
||||||
|
"strategySaveFailed": "Failed to save audit strategy",
|
||||||
|
"tabPending": "Pending",
|
||||||
|
"tabLogs": "Audit logs",
|
||||||
|
"tabStrategy": "Audit strategy",
|
||||||
|
"tabWhitelist": "Tool whitelist",
|
||||||
"pendingTitle": "Pending approvals",
|
"pendingTitle": "Pending approvals",
|
||||||
|
"searchLabel": "Search",
|
||||||
|
"searchPlaceholder": "Tool, conversation, payload, comment…",
|
||||||
|
"searchApply": "Search",
|
||||||
|
"filterDecision": "Decision",
|
||||||
|
"filterDecidedBy": "Reviewer",
|
||||||
|
"filterAll": "All",
|
||||||
|
"decisionApprove": "Approve",
|
||||||
|
"decisionReject": "Reject",
|
||||||
|
"reviewerHuman": "Human",
|
||||||
|
"reviewerAgent": "Audit Agent",
|
||||||
|
"reviewerSystem": "System",
|
||||||
|
"reviewerManual": "Manual entry",
|
||||||
|
"logCreate": "New log",
|
||||||
|
"logModalTitle": "Audit log",
|
||||||
|
"logModalEdit": "Edit audit log",
|
||||||
|
"fieldConversation": "Conversation ID",
|
||||||
|
"fieldTool": "Tool name",
|
||||||
|
"fieldComment": "Comment",
|
||||||
|
"fieldPayload": "Payload (JSON)",
|
||||||
|
"fieldUserMessage": "User message",
|
||||||
|
"fieldThinking": "Thinking",
|
||||||
|
"fieldReasoning": "Reasoning chain",
|
||||||
|
"fieldPlanning": "Planning",
|
||||||
|
"colId": "ID",
|
||||||
|
"colTool": "Tool",
|
||||||
|
"colConversation": "Conversation",
|
||||||
|
"colDecision": "Decision",
|
||||||
|
"colDecidedBy": "Reviewer",
|
||||||
|
"colContext": "Context",
|
||||||
|
"colTime": "Time",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"viewDetail": "Detail",
|
||||||
|
"logModalView": "Audit log detail",
|
||||||
|
"fieldExecutionResult": "Execution result",
|
||||||
|
"executionSuccess": "success",
|
||||||
|
"executionFailed": "failed",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"logsEmpty": "No audit logs",
|
||||||
|
"logsEmptyHint": "Records are created automatically when HITL approvals are approved or rejected.",
|
||||||
|
"pageInfo": "{{total}} total",
|
||||||
|
"prevPage": "Previous",
|
||||||
|
"nextPage": "Next",
|
||||||
|
"conversationRequired": "Conversation ID is required",
|
||||||
|
"toolRequired": "Tool name is required",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"deleteConfirm": "Delete this audit log?",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"retentionHint": "Audit logs are kept for {{days}} days, then purged automatically.",
|
||||||
|
"selectedCount": "{{count}} selected",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"batchDelete": "Batch delete",
|
||||||
|
"batchDeleteConfirm": "Delete the selected {{count}} audit log(s)? This cannot be undone.",
|
||||||
|
"batchDeleteSuccess": "Successfully deleted {{count}} audit log(s)",
|
||||||
|
"batchDeleteFailed": "Batch delete failed",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"clearAllConfirm": "Clear all {{count}} audit log(s) matching the current filters? This cannot be undone.",
|
||||||
|
"clearAllConfirmNoFilter": "No filters are set. This will clear all {{count}} audit log(s). This cannot be undone. Continue?",
|
||||||
|
"clearAllSuccess": "Cleared {{count}} audit log(s)",
|
||||||
|
"clearAllFailed": "Clear failed",
|
||||||
|
"selectLogsFirst": "Select audit logs to delete first",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"emptyState": "No pending approvals",
|
"emptyState": "No pending approvals",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
@@ -2087,11 +2187,27 @@
|
|||||||
"subIndexFilter": "Sub-index filter (optional)",
|
"subIndexFilter": "Sub-index filter (optional)",
|
||||||
"subIndexFilterPlaceholder": "e.g. prod, must match an indexing sub_indexes tag",
|
"subIndexFilterPlaceholder": "e.g. prod, must match an indexing sub_indexes tag",
|
||||||
"subIndexFilterHint": "Empty = no filter. When set, only rows whose sub_indexes contain this tag (legacy rows with empty sub_indexes still match).",
|
"subIndexFilterHint": "Empty = no filter. When set, only rows whose sub_indexes contain this tag (legacy rows with empty sub_indexes still match).",
|
||||||
|
"ragPipelineHeader": "RAG pipeline (MultiQuery + Rerank)",
|
||||||
|
"ragPipelineHint": "MultiQuery and rerank are always on: LLM query rewrite → vector prefetch & fusion → HTTP rerank → dedupe & budget truncate.",
|
||||||
|
"multiQueryMaxQueries": "MultiQuery rewrite variant limit",
|
||||||
|
"multiQueryMaxQueriesPlaceholder": "4",
|
||||||
|
"multiQueryMaxQueriesHint": "Max LLM-generated retrieval variants (including paraphrases of the original query). Recommended 3–4, max 8.",
|
||||||
|
"rerankProvider": "Rerank provider",
|
||||||
|
"rerankProviderAuto": "Auto (infer from Base URL)",
|
||||||
|
"rerankProviderCohere": "Cohere-compatible API",
|
||||||
|
"rerankProviderHint": "DashScope uses gte-rerank; other compatible endpoints use /v1/rerank. Leave empty to infer from Base URL below.",
|
||||||
|
"rerankModel": "Rerank model (optional)",
|
||||||
|
"rerankModelPlaceholder": "Empty: DashScope→gte-rerank, Cohere→rerank-multilingual-v3.0",
|
||||||
|
"rerankBaseUrl": "Rerank Base URL (optional)",
|
||||||
|
"rerankBaseUrlPlaceholder": "Leave empty to reuse embedding / OpenAI base_url",
|
||||||
|
"rerankApiKey": "Rerank API Key (optional)",
|
||||||
|
"rerankApiKeyPlaceholder": "Leave empty to reuse embedding / OpenAI api_key",
|
||||||
|
"rerankApiKeyHint": "On rerank failure, results fall back to fusion order; search still works.",
|
||||||
"postRetrieveHeader": "Post-retrieval (dedupe / budget)",
|
"postRetrieveHeader": "Post-retrieval (dedupe / budget)",
|
||||||
"postRetrieveDedupeAuto": "Results are always deduped by normalized text (whitespace-collapsed bodies). No setting required.",
|
"postRetrieveDedupeAuto": "Results are always deduped by normalized text (whitespace-collapsed bodies). No setting required.",
|
||||||
"prefetchTopK": "Prefetch candidates (vector stage)",
|
"prefetchTopK": "Prefetch candidates (vector stage)",
|
||||||
"prefetchTopKPlaceholder": "0",
|
"prefetchTopKPlaceholder": "20",
|
||||||
"prefetchTopKHint": "0 = same as Top-K; larger values fetch more vector hits before dedupe/truncate (max 200).",
|
"prefetchTopKHint": "Vector candidates per MultiQuery variant; 0 uses built-in max(top_k×4, 20) (max 200).",
|
||||||
"maxContextChars": "Max returned characters (Unicode)",
|
"maxContextChars": "Max returned characters (Unicode)",
|
||||||
"maxContextCharsPlaceholder": "0",
|
"maxContextCharsPlaceholder": "0",
|
||||||
"maxContextCharsHint": "0 = unlimited; keeps whole chunks in rank order until the budget is exceeded.",
|
"maxContextCharsHint": "0 = unlimited; keeps whole chunks in rank order until the budget is exceeded.",
|
||||||
@@ -2542,6 +2658,7 @@
|
|||||||
"conversationName": "Conversation name",
|
"conversationName": "Conversation name",
|
||||||
"project": "Project",
|
"project": "Project",
|
||||||
"noProject": "No project",
|
"noProject": "No project",
|
||||||
|
"unknownProject": "Unknown project",
|
||||||
"filterByProject": "Filter by project",
|
"filterByProject": "Filter by project",
|
||||||
"lastTime": "Last activity",
|
"lastTime": "Last activity",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
|
|||||||
+121
-4
@@ -39,6 +39,15 @@
|
|||||||
"version": "当前版本",
|
"version": "当前版本",
|
||||||
"toggleSidebar": "折叠/展开侧边栏"
|
"toggleSidebar": "折叠/展开侧边栏"
|
||||||
},
|
},
|
||||||
|
"theme": {
|
||||||
|
"system": "跟随系统",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "暗色",
|
||||||
|
"titleSystem": "当前:跟随系统主题。点击切换为浅色。",
|
||||||
|
"titleLight": "当前:浅色主题。点击切换为暗色。",
|
||||||
|
"titleDark": "当前:暗色主题。点击切换为跟随系统。",
|
||||||
|
"toggle": "切换主题"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "事件通知",
|
"title": "事件通知",
|
||||||
"empty": "暂无新事件",
|
"empty": "暂无新事件",
|
||||||
@@ -492,6 +501,12 @@
|
|||||||
"filterByProject": "按项目筛选",
|
"filterByProject": "按项目筛选",
|
||||||
"filterAllProjects": "全部项目",
|
"filterAllProjects": "全部项目",
|
||||||
"filterUnboundProjects": "未绑定项目",
|
"filterUnboundProjects": "未绑定项目",
|
||||||
|
"filterProjectSearch": "搜索项目…",
|
||||||
|
"filterProjectSearchEmpty": "没有匹配的项目",
|
||||||
|
"filterProjectSearchHint": "输入关键字搜索项目",
|
||||||
|
"filterProjectSearchMore": "更多项目请输入关键字搜索",
|
||||||
|
"filterProjectSearchLoading": "搜索中…",
|
||||||
|
"filterProjectSearchFailed": "加载项目失败,请重试",
|
||||||
"projectConversationsTitle": "{{name}} · 对话",
|
"projectConversationsTitle": "{{name}} · 对话",
|
||||||
"unboundConversationsTitle": "未绑定项目",
|
"unboundConversationsTitle": "未绑定项目",
|
||||||
"noProjectConversations": "该项目暂无对话",
|
"noProjectConversations": "该项目暂无对话",
|
||||||
@@ -633,7 +648,10 @@
|
|||||||
"agentModeOrchSupervisor": "Supervisor",
|
"agentModeOrchSupervisor": "Supervisor",
|
||||||
"hitlTitle": "人机协同",
|
"hitlTitle": "人机协同",
|
||||||
"hitlCardSubtitle": "审批与白名单",
|
"hitlCardSubtitle": "审批与白名单",
|
||||||
"hitlReviewer": "Review",
|
"hitlReviewerLabel": "审批方",
|
||||||
|
"hitlReviewerHuman": "人工审批",
|
||||||
|
"hitlReviewerAgent": "审计 Agent",
|
||||||
|
"hitlReviewerHint": "可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。",
|
||||||
"hitlConfigTitle": "协同模式配置",
|
"hitlConfigTitle": "协同模式配置",
|
||||||
"hitlModeLabel": "模式",
|
"hitlModeLabel": "模式",
|
||||||
"hitlModeOff": "关闭",
|
"hitlModeOff": "关闭",
|
||||||
@@ -642,7 +660,7 @@
|
|||||||
"hitlSensitiveTools": "敏感工具(逗号分隔)",
|
"hitlSensitiveTools": "敏感工具(逗号分隔)",
|
||||||
"hitlWhitelistTools": "白名单工具(免审批,逗号分隔)",
|
"hitlWhitelistTools": "白名单工具(免审批,逗号分隔)",
|
||||||
"hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)",
|
"hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)",
|
||||||
"hitlWhitelistHint": "每行一个或逗号分隔;与 config 中全局白名单合并展示。",
|
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||||
"hitlApply": "应用",
|
"hitlApply": "应用",
|
||||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
||||||
@@ -652,7 +670,89 @@
|
|||||||
},
|
},
|
||||||
"hitl": {
|
"hitl": {
|
||||||
"pageTitle": "人机协同审批",
|
"pageTitle": "人机协同审批",
|
||||||
|
"pageReviewerLabel": "当前审批方",
|
||||||
|
"pageReviewerHint": "作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。",
|
||||||
|
"pageReviewerSaved": "审批方已保存。",
|
||||||
|
"whitelistLabel": "免审批工具白名单",
|
||||||
|
"whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。",
|
||||||
|
"whitelistSaved": "白名单已保存。",
|
||||||
|
"whitelistSaveFailed": "保存白名单失败",
|
||||||
|
"strategyLabel": "审计策略(提示词)",
|
||||||
|
"strategyHint": "白名单内工具免审批;其余工具在审批方为「审计 Agent」时,由模型按此提示词自主裁决。",
|
||||||
|
"strategyTabApproval": "审批模式",
|
||||||
|
"strategyTabReviewEdit": "审查编辑模式",
|
||||||
|
"strategyHintApproval": "白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。",
|
||||||
|
"strategyHintReviewEdit": "审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。",
|
||||||
|
"strategyReset": "恢复默认",
|
||||||
|
"strategySaved": "审计策略已保存。",
|
||||||
|
"strategySaveFailed": "保存审计策略失败",
|
||||||
|
"tabPending": "待审计",
|
||||||
|
"tabLogs": "审计日志",
|
||||||
|
"tabStrategy": "审计策略",
|
||||||
|
"tabWhitelist": "工具白名单",
|
||||||
"pendingTitle": "待处理审批",
|
"pendingTitle": "待处理审批",
|
||||||
|
"searchLabel": "搜索",
|
||||||
|
"searchPlaceholder": "工具名、会话 ID、载荷、备注…",
|
||||||
|
"searchApply": "搜索",
|
||||||
|
"filterDecision": "决策",
|
||||||
|
"filterDecidedBy": "审批方",
|
||||||
|
"filterAll": "全部",
|
||||||
|
"decisionApprove": "通过",
|
||||||
|
"decisionReject": "拒绝",
|
||||||
|
"reviewerHuman": "人工",
|
||||||
|
"reviewerAgent": "审计 Agent",
|
||||||
|
"reviewerSystem": "系统",
|
||||||
|
"reviewerManual": "手动录入",
|
||||||
|
"logCreate": "新建日志",
|
||||||
|
"logModalTitle": "审计日志",
|
||||||
|
"logModalEdit": "编辑审计日志",
|
||||||
|
"fieldConversation": "会话 ID",
|
||||||
|
"fieldTool": "工具名",
|
||||||
|
"fieldComment": "备注",
|
||||||
|
"fieldPayload": "载荷 (JSON)",
|
||||||
|
"fieldUserMessage": "用户原话",
|
||||||
|
"fieldThinking": "本轮思考",
|
||||||
|
"fieldReasoning": "推理链",
|
||||||
|
"fieldPlanning": "规划",
|
||||||
|
"colId": "ID",
|
||||||
|
"colTool": "工具",
|
||||||
|
"colConversation": "会话",
|
||||||
|
"colDecision": "决策",
|
||||||
|
"colDecidedBy": "审批方",
|
||||||
|
"colContext": "上下文",
|
||||||
|
"colTime": "时间",
|
||||||
|
"colActions": "操作",
|
||||||
|
"viewDetail": "详情",
|
||||||
|
"logModalView": "审计日志详情",
|
||||||
|
"fieldExecutionResult": "执行结果",
|
||||||
|
"executionSuccess": "成功",
|
||||||
|
"executionFailed": "失败",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"logsEmpty": "暂无审计日志",
|
||||||
|
"logsEmptyHint": "人机协同审批通过或拒绝后会自动记录在此。",
|
||||||
|
"pageInfo": "共 {{total}} 条",
|
||||||
|
"prevPage": "上一页",
|
||||||
|
"nextPage": "下一页",
|
||||||
|
"conversationRequired": "请填写会话 ID",
|
||||||
|
"toolRequired": "请填写工具名",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleteConfirm": "确定删除这条审计日志?",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"retentionHint": "审计日志保留 {{days}} 天,超期自动清理",
|
||||||
|
"selectedCount": "已选择 {{count}} 项",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"batchDeleteConfirm": "确定删除选中的 {{count}} 条审计日志?此操作不可恢复。",
|
||||||
|
"batchDeleteSuccess": "成功删除 {{count}} 条审计日志",
|
||||||
|
"batchDeleteFailed": "批量删除失败",
|
||||||
|
"clearAll": "清空",
|
||||||
|
"clearAllConfirm": "确定清空当前筛选条件下的全部 {{count}} 条审计日志?此操作不可恢复。",
|
||||||
|
"clearAllConfirmNoFilter": "未设置筛选条件,将清空全部 {{count}} 条审计日志。此操作不可恢复,是否继续?",
|
||||||
|
"clearAllSuccess": "已清空 {{count}} 条审计日志",
|
||||||
|
"clearAllFailed": "清空失败",
|
||||||
|
"selectLogsFirst": "请先选择要删除的审计日志",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"emptyState": "暂无待审批项",
|
"emptyState": "暂无待审批项",
|
||||||
"dismiss": "忽略",
|
"dismiss": "忽略",
|
||||||
@@ -2075,11 +2175,27 @@
|
|||||||
"subIndexFilter": "子索引过滤(可选)",
|
"subIndexFilter": "子索引过滤(可选)",
|
||||||
"subIndexFilterPlaceholder": "如 prod,与索引 sub_indexes 一致",
|
"subIndexFilterPlaceholder": "如 prod,与索引 sub_indexes 一致",
|
||||||
"subIndexFilterHint": "留空不过滤;填写后仅检索向量行 sub_indexes 中含该标签的结果(未打标旧行仍保留)。",
|
"subIndexFilterHint": "留空不过滤;填写后仅检索向量行 sub_indexes 中含该标签的结果(未打标旧行仍保留)。",
|
||||||
|
"ragPipelineHeader": "RAG 管线(MultiQuery + Rerank)",
|
||||||
|
"ragPipelineHint": "MultiQuery 与精排始终启用:LLM 改写多路检索 → 向量预取与融合 → HTTP 精排 → 去重与预算截断。",
|
||||||
|
"multiQueryMaxQueries": "MultiQuery 改写变体上限",
|
||||||
|
"multiQueryMaxQueriesPlaceholder": "4",
|
||||||
|
"multiQueryMaxQueriesHint": "LLM 生成的检索变体数量上限(含原问语义覆盖);建议 3~4,最大 8。",
|
||||||
|
"rerankProvider": "精排提供商",
|
||||||
|
"rerankProviderAuto": "自动(按 Base URL 推断)",
|
||||||
|
"rerankProviderCohere": "Cohere 兼容 API",
|
||||||
|
"rerankProviderHint": "DashScope 使用 gte-rerank;其他兼容端点走 /v1/rerank。留空时按下方 Base URL 自动推断。",
|
||||||
|
"rerankModel": "精排模型(可选)",
|
||||||
|
"rerankModelPlaceholder": "留空:DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0",
|
||||||
|
"rerankBaseUrl": "精排 Base URL(可选)",
|
||||||
|
"rerankBaseUrlPlaceholder": "留空则复用嵌入 / OpenAI 的 base_url",
|
||||||
|
"rerankApiKey": "精排 API Key(可选)",
|
||||||
|
"rerankApiKeyPlaceholder": "留空则复用嵌入 / OpenAI 的 api_key",
|
||||||
|
"rerankApiKeyHint": "精排失败时自动降级为融合排序,检索仍可用。",
|
||||||
"postRetrieveHeader": "检索后处理(去重 / 预算)",
|
"postRetrieveHeader": "检索后处理(去重 / 预算)",
|
||||||
"postRetrieveDedupeAuto": "检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。",
|
"postRetrieveDedupeAuto": "检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。",
|
||||||
"prefetchTopK": "预取候选数(向量阶段)",
|
"prefetchTopK": "预取候选数(向量阶段)",
|
||||||
"prefetchTopKPlaceholder": "0",
|
"prefetchTopKPlaceholder": "20",
|
||||||
"prefetchTopKHint": "0 表示与 Top-K 相同;大于 Top-K 时先多取候选再经去重/截断回到 Top-K(上限 200)。",
|
"prefetchTopKHint": "每条 MultiQuery 变体的向量候选数;0 表示内置 max(top_k×4, 20)(上限 200)。",
|
||||||
"maxContextChars": "返回内容最大字符数(Unicode)",
|
"maxContextChars": "返回内容最大字符数(Unicode)",
|
||||||
"maxContextCharsPlaceholder": "0",
|
"maxContextCharsPlaceholder": "0",
|
||||||
"maxContextCharsHint": "0 表示不限制;按检索顺序整段保留 chunk,超出则丢弃后续。",
|
"maxContextCharsHint": "0 表示不限制;按检索顺序整段保留 chunk,超出则丢弃后续。",
|
||||||
@@ -2530,6 +2646,7 @@
|
|||||||
"conversationName": "对话名称",
|
"conversationName": "对话名称",
|
||||||
"project": "项目",
|
"project": "项目",
|
||||||
"noProject": "无项目",
|
"noProject": "无项目",
|
||||||
|
"unknownProject": "未知项目",
|
||||||
"filterByProject": "按项目筛选",
|
"filterByProject": "按项目筛选",
|
||||||
"lastTime": "最近一次对话时间",
|
"lastTime": "最近一次对话时间",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const AUDIT_ACTIONS_BY_CATEGORY = {
|
|||||||
task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'],
|
task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'],
|
||||||
tool: ['execution_delete', 'execution_delete_batch'],
|
tool: ['execution_delete', 'execution_delete_batch'],
|
||||||
file: ['upload', 'delete'],
|
file: ['upload', 'delete'],
|
||||||
hitl: ['decision'],
|
hitl: ['decision', 'audit_strategy_update'],
|
||||||
role: ['create', 'update', 'delete'],
|
role: ['create', 'update', 'delete'],
|
||||||
skill: ['create', 'update', 'delete'],
|
skill: ['create', 'update', 'delete'],
|
||||||
agent: ['markdown_create', 'markdown_update', 'markdown_delete']
|
agent: ['markdown_create', 'markdown_update', 'markdown_delete']
|
||||||
|
|||||||
+541
-161
File diff suppressed because it is too large
Load Diff
+57
-19
@@ -1707,7 +1707,7 @@ window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter
|
|||||||
|
|
||||||
// 漏洞严重程度分布:半环形(donut)渲染
|
// 漏洞严重程度分布:半环形(donut)渲染
|
||||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
// 段间分隔由 gapRad 几何间隙完成,不使用描边,避免浅色/暗色下白边或黑边过重
|
||||||
var SEVERITY_DONUT_CFG = {
|
var SEVERITY_DONUT_CFG = {
|
||||||
// viewBox 0 0 480 260:整体保持紧凑,但环厚回到「黄金比例」附近,
|
// viewBox 0 0 480 260:整体保持紧凑,但环厚回到「黄金比例」附近,
|
||||||
// 让弧带本身有视觉分量,又不像最早那版那样占太多空间。
|
// 让弧带本身有视觉分量,又不像最早那版那样占太多空间。
|
||||||
@@ -1717,7 +1717,7 @@ var SEVERITY_DONUT_CFG = {
|
|||||||
rOuter: 165,
|
rOuter: 165,
|
||||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||||
labelOffset: 14,
|
labelOffset: 14,
|
||||||
gapRad: 0.012
|
gapRad: 0.022
|
||||||
};
|
};
|
||||||
|
|
||||||
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
||||||
@@ -1759,28 +1759,66 @@ function severityLabel(id) {
|
|||||||
return SEVERITY_DEFAULT_LABELS[id] || id;
|
return SEVERITY_DEFAULT_LABELS[id] || id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDashboardDarkTheme() {
|
||||||
|
return document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSeverityDonutThemeObserver() {
|
||||||
|
if (severityDonutState.themeObserver) return;
|
||||||
|
severityDonutState.themeObserver = new MutationObserver(function (mutations) {
|
||||||
|
for (var i = 0; i < mutations.length; i++) {
|
||||||
|
if (mutations[i].attributeName === 'data-theme') {
|
||||||
|
renderSeverityDonut(severityDonutState.bySeverity, severityDonutState.total);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
severityDonutState.themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSeverityDonutDefs() {
|
function ensureSeverityDonutDefs() {
|
||||||
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
||||||
if (!defsEl || defsEl.hasChildNodes()) return;
|
if (!defsEl) return;
|
||||||
|
var dark = isDashboardDarkTheme();
|
||||||
var html = '';
|
var html = '';
|
||||||
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
||||||
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
if (dark) {
|
||||||
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
html += '<stop offset="0%" stop-color="#334155"/>';
|
||||||
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
html += '<stop offset="55%" stop-color="#1e293b"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#172033"/>';
|
||||||
|
} else {
|
||||||
|
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||||
|
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||||
|
}
|
||||||
html += '</linearGradient>';
|
html += '</linearGradient>';
|
||||||
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
|
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
|
||||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
if (dark) {
|
||||||
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
html += '<stop offset="0%" stop-color="#0f172a" stop-opacity="0.55"/>';
|
||||||
|
html += '<stop offset="70%" stop-color="#0f172a" stop-opacity="0"/>';
|
||||||
|
} else {
|
||||||
|
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
||||||
|
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||||
|
}
|
||||||
html += '</radialGradient>';
|
html += '</radialGradient>';
|
||||||
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
||||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
if (dark) {
|
||||||
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
html += '<stop offset="0%" stop-color="#94a3b8" stop-opacity="0.10"/>';
|
||||||
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
html += '<stop offset="55%" stop-color="#94a3b8" stop-opacity="0.03"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#94a3b8" stop-opacity="0"/>';
|
||||||
|
} else {
|
||||||
|
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
||||||
|
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||||
|
}
|
||||||
html += '</radialGradient>';
|
html += '</radialGradient>';
|
||||||
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
|
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
|
||||||
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
|
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
|
||||||
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
|
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
|
||||||
html += '<feFlood flood-color="#0f172a" flood-opacity="0.13" result="flood"/>';
|
html += '<feFlood flood-color="' + (dark ? '#000000' : '#0f172a') + '" flood-opacity="' + (dark ? '0.28' : '0.13') + '" result="flood"/>';
|
||||||
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
|
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
|
||||||
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
||||||
html += '</filter>';
|
html += '</filter>';
|
||||||
@@ -1808,17 +1846,17 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
severityDonutState.total = total || 0;
|
severityDonutState.total = total || 0;
|
||||||
severityDonutState.hoverId = null;
|
severityDonutState.hoverId = null;
|
||||||
|
|
||||||
|
ensureSeverityDonutThemeObserver();
|
||||||
|
|
||||||
var cfg = SEVERITY_DONUT_CFG;
|
var cfg = SEVERITY_DONUT_CFG;
|
||||||
ensureSeverityDonutDefs();
|
ensureSeverityDonutDefs();
|
||||||
|
|
||||||
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
||||||
if (!trackEl.hasChildNodes()) {
|
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
trackEl.innerHTML =
|
||||||
trackEl.innerHTML =
|
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
||||||
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
||||||
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
||||||
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
|
||||||
}
|
|
||||||
|
|
||||||
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
||||||
var severities = ids.map(function (id) {
|
var severities = ids.map(function (id) {
|
||||||
|
|||||||
+1057
-17
File diff suppressed because it is too large
Load Diff
@@ -154,6 +154,9 @@
|
|||||||
}
|
}
|
||||||
applyTranslations(document);
|
applyTranslations(document);
|
||||||
updateLangLabel();
|
updateLangLabel();
|
||||||
|
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||||
|
window.refreshThemeToggleLabel();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
window.__locale = lang;
|
window.__locale = lang;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
@@ -180,6 +183,9 @@
|
|||||||
await loadLanguageResources(initialLang);
|
await loadLanguageResources(initialLang);
|
||||||
applyTranslations(document);
|
applyTranslations(document);
|
||||||
updateLangLabel();
|
updateLangLabel();
|
||||||
|
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||||
|
window.refreshThemeToggleLabel();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
window.__locale = i18next.language || initialLang;
|
window.__locale = i18next.language || initialLang;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|||||||
@@ -172,6 +172,24 @@ function einoMainStreamPlanningTitle(responseData) {
|
|||||||
return prefix + '📝 ' + plan;
|
return prefix + '📝 ' + plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eino 未捕获助手正文占位文案;终态 response 不应覆盖已有流式 buffer。
|
||||||
|
*/
|
||||||
|
function isEinoEmptyResponsePlaceholder(text) {
|
||||||
|
if (text == null) return false;
|
||||||
|
const s = String(text);
|
||||||
|
return s.indexOf('no assistant text was captured') !== -1
|
||||||
|
|| s.indexOf('未捕获到助手文本输出') !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
||||||
|
const buf = streamState && streamState.buffer != null ? String(streamState.buffer).trim() : '';
|
||||||
|
if (isEinoEmptyResponsePlaceholder(finalMessage) && buf) {
|
||||||
|
return streamState.buffer;
|
||||||
|
}
|
||||||
|
return finalMessage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||||
@@ -181,8 +199,9 @@ function finalizeMainResponseStreamItem(streamState, finalMessage, responseData)
|
|||||||
const item = document.getElementById(streamState.itemId);
|
const item = document.getElementById(streamState.itemId);
|
||||||
if (!item || !item.parentNode) return false;
|
if (!item || !item.parentNode) return false;
|
||||||
|
|
||||||
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
|
const resolved = resolveFinalAssistantResponseText(finalMessage, streamState);
|
||||||
? String(finalMessage)
|
const fullText = (resolved != null && String(resolved).trim() !== '')
|
||||||
|
? String(resolved)
|
||||||
: (streamState.buffer || '');
|
: (streamState.buffer || '');
|
||||||
if (!String(fullText).trim()) {
|
if (!String(fullText).trim()) {
|
||||||
item.parentNode.removeChild(item);
|
item.parentNode.removeChild(item);
|
||||||
@@ -2414,19 +2433,20 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||||
let assistantIdFinal = existingAssistantId;
|
let assistantIdFinal = existingAssistantId;
|
||||||
|
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
|
||||||
|
|
||||||
if (!assistantIdFinal) {
|
if (!assistantIdFinal) {
|
||||||
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId);
|
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||||
setAssistantId(assistantIdFinal);
|
setAssistantId(assistantIdFinal);
|
||||||
} else {
|
} else {
|
||||||
setAssistantId(assistantIdFinal);
|
setAssistantId(assistantIdFinal);
|
||||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||||
if (streamState && streamState.itemId) {
|
if (streamState && streamState.itemId) {
|
||||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||||
} else if (event.message && String(event.message).trim()) {
|
} else if (bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||||
addTimelineItem(timeline, 'planning', {
|
addTimelineItem(timeline, 'planning', {
|
||||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||||
? einoMainStreamPlanningTitle(responseData)
|
? einoMainStreamPlanningTitle(responseData)
|
||||||
@@ -3440,6 +3460,28 @@ async function loadActiveTasks(showErrors = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActiveTaskDisplayName(task) {
|
||||||
|
const _t = function (k) { return typeof window.t === 'function' ? window.t(k) : k; };
|
||||||
|
const unnamedTaskText = _t('tasks.unnamedTask');
|
||||||
|
if (!task) return unnamedTaskText;
|
||||||
|
const title = (task.title || '').trim();
|
||||||
|
if (title) return title;
|
||||||
|
const message = (task.message || '').trim();
|
||||||
|
return message || unnamedTaskText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveTaskConversationTitle(conversationId, newTitle) {
|
||||||
|
const bar = document.getElementById('active-tasks-bar');
|
||||||
|
if (!bar || !conversationId) return;
|
||||||
|
const title = (newTitle || '').trim();
|
||||||
|
if (!title) return;
|
||||||
|
bar.querySelectorAll('.active-task-item[data-conversation-id="' + conversationId + '"] .active-task-message')
|
||||||
|
.forEach(function (el) {
|
||||||
|
el.textContent = title;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.updateActiveTaskConversationTitle = updateActiveTaskConversationTitle;
|
||||||
|
|
||||||
function renderActiveTasks(tasks) {
|
function renderActiveTasks(tasks) {
|
||||||
const bar = document.getElementById('active-tasks-bar');
|
const bar = document.getElementById('active-tasks-bar');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
@@ -3500,13 +3542,17 @@ function renderActiveTasks(tasks) {
|
|||||||
};
|
};
|
||||||
const statusText = statusMap[task.status] || _t('tasks.statusRunning');
|
const statusText = statusMap[task.status] || _t('tasks.statusRunning');
|
||||||
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
|
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
|
||||||
const unnamedTaskText = _t('tasks.unnamedTask');
|
const taskDisplayName = getActiveTaskDisplayName(task);
|
||||||
const stopTaskBtnText = _t('tasks.stopTask');
|
const stopTaskBtnText = _t('tasks.stopTask');
|
||||||
|
|
||||||
|
if (task && task.conversationId) {
|
||||||
|
item.dataset.conversationId = task.conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="active-task-info">
|
<div class="active-task-info">
|
||||||
<span class="active-task-status">${statusText}</span>
|
<span class="active-task-status">${statusText}</span>
|
||||||
<span class="active-task-message">${escapeHtml(task.message || unnamedTaskText)}</span>
|
<span class="active-task-message">${escapeHtml(taskDisplayName)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="active-task-actions">
|
<div class="active-task-actions">
|
||||||
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
|
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
|
||||||
|
|||||||
+265
-70
@@ -173,6 +173,65 @@ function rebuildProjectNameMap(list) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberProjectsInNameMap(list) {
|
||||||
|
(list || []).forEach((p) => {
|
||||||
|
if (p && p.id) projectNameById[p.id] = p.name || p.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 projectListSearchPattern 对齐:name / description / id 子串匹配(忽略大小写) */
|
||||||
|
function matchProjectSearchQuery(project, query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const name = String(project.name || '').toLowerCase();
|
||||||
|
const desc = String(project.description || '').toLowerCase();
|
||||||
|
const id = String(project.id || '').toLowerCase();
|
||||||
|
return name.includes(q) || desc.includes(q) || id.includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortProjectsForPicker(projects) {
|
||||||
|
return [...projects].sort((a, b) => {
|
||||||
|
const ap = a.pinned ? 1 : 0;
|
||||||
|
const bp = b.pinned ? 1 : 0;
|
||||||
|
if (bp !== ap) return bp - ap;
|
||||||
|
const au = a.updated_at || a.updatedAt || '';
|
||||||
|
const bu = b.updated_at || b.updatedAt || '';
|
||||||
|
return String(bu).localeCompare(String(au));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已加载列表中筛选活跃项目(对话选择器 / 项目筛选下拉) */
|
||||||
|
function filterActiveProjectsLocal(projects, query) {
|
||||||
|
const list = (projects || []).filter((p) => p && p.id && p.status !== 'archived');
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
const filtered = q ? list.filter((p) => matchProjectSearchQuery(p, q)) : list;
|
||||||
|
return sortProjectsForPicker(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchActiveProjects(query, opts = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('status', opts.status || 'active');
|
||||||
|
params.set('limit', String(opts.limit ?? (String(query || '').trim() ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT)));
|
||||||
|
params.set('offset', String(opts.offset ?? 0));
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (q) params.set('search', q);
|
||||||
|
const res = await apiFetch(`/api/projects?${params}`);
|
||||||
|
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||||
|
const parsed = parseProjectsListResponse(await res.json());
|
||||||
|
rememberProjectsInNameMap(parsed.items);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectSummary(projectId) {
|
||||||
|
const id = String(projectId || '').trim();
|
||||||
|
if (!id) return null;
|
||||||
|
const res = await apiFetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const project = await res.json();
|
||||||
|
if (project && project.id) rememberProjectsInNameMap([project]);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
function getProjectsListPageSize() {
|
function getProjectsListPageSize() {
|
||||||
try {
|
try {
|
||||||
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||||
@@ -308,7 +367,15 @@ async function ensureProjectsLoaded(force) {
|
|||||||
return _projectsFetchPromise;
|
return _projectsFetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProjectsCacheReady() {
|
||||||
|
return _projectsListReady;
|
||||||
|
}
|
||||||
|
|
||||||
function prefetchProjectsForChat() {
|
function prefetchProjectsForChat() {
|
||||||
|
const id = (resolveChatProjectSelection() || '').trim();
|
||||||
|
if (id && !projectNameById[id]) {
|
||||||
|
fetchProjectSummary(id).catch(() => {});
|
||||||
|
}
|
||||||
ensureProjectsLoaded().catch(() => {});
|
ensureProjectsLoaded().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,9 +722,12 @@ function updateProjectStatusPill(status) {
|
|||||||
|
|
||||||
function renderProjectDetailMeta(updatedAt) {
|
function renderProjectDetailMeta(updatedAt) {
|
||||||
const metaEl = document.getElementById('projects-detail-meta');
|
const metaEl = document.getElementById('projects-detail-meta');
|
||||||
if (!metaEl) return;
|
const timeEl = document.getElementById('projects-detail-meta-time');
|
||||||
|
if (!metaEl || !timeEl) return;
|
||||||
const time = formatProjectTime(updatedAt);
|
const time = formatProjectTime(updatedAt);
|
||||||
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
const full = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||||
|
timeEl.textContent = time;
|
||||||
|
metaEl.title = full;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshProjectDetailMetaI18n() {
|
function refreshProjectDetailMetaI18n() {
|
||||||
@@ -2032,27 +2102,20 @@ function getChatProjectSelection() {
|
|||||||
return getActiveProjectId();
|
return getActiveProjectId();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveChatProjectId(id) {
|
/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */
|
||||||
if (!id) return false;
|
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
|
||||||
return source.some((p) => p.id === id && p.status !== 'archived');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
|
||||||
function resolveChatProjectSelection() {
|
function resolveChatProjectSelection() {
|
||||||
const raw = getChatProjectSelection();
|
return getChatProjectSelection() || '';
|
||||||
if (!raw) return '';
|
|
||||||
if (!_projectsListReady) return raw;
|
|
||||||
return isActiveChatProjectId(raw) ? raw : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _normalizingStaleProject = false;
|
let _normalizingStaleProject = false;
|
||||||
|
|
||||||
/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */
|
/** 清除 localStorage 或对话上残留的失效项目 ID */
|
||||||
async function normalizeStaleChatProjectSelection() {
|
async function normalizeStaleChatProjectSelection() {
|
||||||
if (!_projectsListReady || _normalizingStaleProject) return;
|
if (_normalizingStaleProject) return;
|
||||||
const raw = getChatProjectSelection();
|
const raw = (getChatProjectSelection() || '').trim();
|
||||||
if (!raw || isActiveChatProjectId(raw)) return;
|
if (!raw) return;
|
||||||
|
const project = await fetchProjectSummary(raw);
|
||||||
|
if (project && project.id && project.status !== 'archived') return;
|
||||||
|
|
||||||
_normalizingStaleProject = true;
|
_normalizingStaleProject = true;
|
||||||
try {
|
try {
|
||||||
@@ -2079,6 +2142,171 @@ async function normalizeStaleChatProjectSelection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_PICKER_DEBOUNCE_MS = 100;
|
||||||
|
const projectPickerPanelState = {
|
||||||
|
chat: { seq: 0, timer: null },
|
||||||
|
webshell: { seq: 0, timer: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
|
||||||
|
const t = tFn || tp;
|
||||||
|
const isNone = !project.id;
|
||||||
|
const isSelected = isNone ? !selectedId : selectedId === project.id;
|
||||||
|
const desc = isNone
|
||||||
|
? (project.description || '')
|
||||||
|
: (project.description || '').trim().slice(0, 80) || t('projects.sharedFactBoard');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||||
|
btn.setAttribute('role', 'option');
|
||||||
|
btn.onclick = () => onSelect(project.id || '');
|
||||||
|
btn.innerHTML = `
|
||||||
|
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
||||||
|
<div class="role-selection-item-content-main">
|
||||||
|
<div class="role-selection-item-name-main">${escapeHtml(project.name || t('common.untitled'))}</div>
|
||||||
|
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
||||||
|
</div>
|
||||||
|
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
||||||
|
`;
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendChatProjectPanelMessage(list, className, text) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = className;
|
||||||
|
el.textContent = text;
|
||||||
|
list.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerMessage(t, key, fallback) {
|
||||||
|
const value = t(key);
|
||||||
|
if (!value || value === key) return fallback;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProjectPickerPanel(panelKey, config) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
const list = document.getElementById(config.listId);
|
||||||
|
if (!list || !state) return;
|
||||||
|
const query = (document.getElementById(config.searchInputId)?.value || '').trim();
|
||||||
|
const seq = ++state.seq;
|
||||||
|
const selectedId = config.getSelectedId();
|
||||||
|
const t = config.t || tp;
|
||||||
|
|
||||||
|
const renderPinned = () => {
|
||||||
|
appendChatProjectPanelItem(
|
||||||
|
list,
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: t('projects.noProject'),
|
||||||
|
description: t('projects.noProjectDescription'),
|
||||||
|
},
|
||||||
|
selectedId,
|
||||||
|
config.onSelect,
|
||||||
|
t
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsFetch = !isProjectsCacheReady();
|
||||||
|
let loadingEl = null;
|
||||||
|
if (needsFetch) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
loadingEl = appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-loading',
|
||||||
|
pickerMessage(t, 'common.loading', '加载中…')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = await ensureProjectsLoaded();
|
||||||
|
if (seq !== state.seq) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
const projects = filterActiveProjectsLocal(all, query);
|
||||||
|
projects.forEach((p) => {
|
||||||
|
appendChatProjectPanelItem(list, p, selectedId, config.onSelect, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query && projects.length === 0) {
|
||||||
|
appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-empty',
|
||||||
|
pickerMessage(t, 'chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== state.seq) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-empty',
|
||||||
|
pickerMessage(t, 'chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProjectPickerPanelSearch(panelKey, searchInputId, onSearch) {
|
||||||
|
const input = document.getElementById(searchInputId);
|
||||||
|
if (!input || input.dataset.pickerBound === panelKey) return;
|
||||||
|
input.dataset.pickerBound = panelKey;
|
||||||
|
input.addEventListener('input', onSearch);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (panelKey === 'chat' && typeof closeChatProjectPanel === 'function') {
|
||||||
|
closeChatProjectPanel();
|
||||||
|
} else if (panelKey === 'webshell' && typeof wsCloseProjectPanel === 'function') {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProjectPickerPanelSearch(panelKey, searchInputId) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
if (!state) return;
|
||||||
|
state.seq += 1;
|
||||||
|
if (state.timer) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
const input = document.getElementById(searchInputId);
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProjectPickerPanelSearch(panelKey, loadFn) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
if (!state) return;
|
||||||
|
if (state.timer) clearTimeout(state.timer);
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.timer = null;
|
||||||
|
loadFn();
|
||||||
|
}, PROJECT_PICKER_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatProjectPanelList() {
|
||||||
|
await renderProjectPickerPanel('chat', {
|
||||||
|
listId: 'chat-project-list',
|
||||||
|
searchInputId: 'chat-project-search',
|
||||||
|
getSelectedId: resolveChatProjectSelection,
|
||||||
|
onSelect: (projectId) => selectChatProject(projectId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureChatProjectButtonLabel() {
|
||||||
|
const id = (resolveChatProjectSelection() || '').trim();
|
||||||
|
if (id && !projectNameById[id]) {
|
||||||
|
await fetchProjectSummary(id);
|
||||||
|
}
|
||||||
|
updateChatProjectButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
function updateChatProjectButtonLabel() {
|
function updateChatProjectButtonLabel() {
|
||||||
const textEl = document.getElementById('chat-project-text');
|
const textEl = document.getElementById('chat-project-text');
|
||||||
if (!textEl) return;
|
if (!textEl) return;
|
||||||
@@ -2086,56 +2314,13 @@ function updateChatProjectButtonLabel() {
|
|||||||
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
|
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatProjectPanelList() {
|
|
||||||
const list = document.getElementById('chat-project-list');
|
|
||||||
if (!list) return;
|
|
||||||
const selected = resolveChatProjectSelection();
|
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
|
||||||
const activeProjects = source.filter((p) => p.status !== 'archived');
|
|
||||||
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
|
||||||
if (!items.length) {
|
|
||||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = '';
|
|
||||||
items.forEach((p) => {
|
|
||||||
const isNone = !p.id;
|
|
||||||
const isSelected = isNone ? !selected : selected === p.id;
|
|
||||||
const desc = isNone
|
|
||||||
? (p.description || '')
|
|
||||||
: (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard');
|
|
||||||
const projectId = p.id || '';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
|
||||||
btn.setAttribute('role', 'option');
|
|
||||||
btn.onclick = () => {
|
|
||||||
selectChatProject(projectId);
|
|
||||||
};
|
|
||||||
btn.innerHTML = `
|
|
||||||
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
|
||||||
<div class="role-selection-item-content-main">
|
|
||||||
<div class="role-selection-item-name-main">${escapeHtml(p.name || tp('common.untitled'))}</div>
|
|
||||||
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
|
||||||
</div>
|
|
||||||
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
|
||||||
`;
|
|
||||||
list.appendChild(btn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderChatProjectPanel() {
|
async function renderChatProjectPanel() {
|
||||||
const list = document.getElementById('chat-project-list');
|
initProjectPickerPanelSearch('chat', 'chat-project-search', () => {
|
||||||
if (!list) return;
|
scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList());
|
||||||
list.innerHTML = `<div class="chat-project-panel-loading">${escapeHtml(tp('common.loading'))}</div>`;
|
});
|
||||||
try {
|
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||||
await ensureProjectsLoaded();
|
await loadChatProjectPanelList();
|
||||||
} catch (e) {
|
requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus());
|
||||||
console.warn(e);
|
|
||||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.loadFailedRetry'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderChatProjectPanelList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChatProjectPanel() {
|
function closeChatProjectPanel() {
|
||||||
@@ -2146,6 +2331,7 @@ function closeChatProjectPanel() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
|
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleChatProjectPanel() {
|
async function toggleChatProjectPanel() {
|
||||||
@@ -2213,15 +2399,14 @@ async function applyChatProjectSelection(projectId) {
|
|||||||
async function refreshChatProjectSelector() {
|
async function refreshChatProjectSelector() {
|
||||||
if (!document.getElementById('chat-project-btn')) return;
|
if (!document.getElementById('chat-project-btn')) return;
|
||||||
try {
|
try {
|
||||||
await ensureProjectsLoaded();
|
|
||||||
await normalizeStaleChatProjectSelection();
|
await normalizeStaleChatProjectSelection();
|
||||||
|
await ensureChatProjectButtonLabel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
updateChatProjectButtonLabel();
|
|
||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') {
|
if (panel && panel.style.display === 'flex') {
|
||||||
renderChatProjectPanelList();
|
await loadChatProjectPanelList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2240,7 +2425,7 @@ function initChatProjectSelector() {
|
|||||||
renderProjectsPagination();
|
renderProjectsPagination();
|
||||||
updateChatProjectButtonLabel();
|
updateChatProjectButtonLabel();
|
||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
if (panel && panel.style.display === 'flex') loadChatProjectPanelList();
|
||||||
if (currentProjectId) {
|
if (currentProjectId) {
|
||||||
refreshProjectDetailMetaI18n();
|
refreshProjectDetailMetaI18n();
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
@@ -2298,6 +2483,11 @@ window.onChatProjectChange = onChatProjectChange;
|
|||||||
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||||
window.closeChatProjectPanel = closeChatProjectPanel;
|
window.closeChatProjectPanel = closeChatProjectPanel;
|
||||||
window.selectChatProject = selectChatProject;
|
window.selectChatProject = selectChatProject;
|
||||||
|
window.renderProjectPickerPanel = renderProjectPickerPanel;
|
||||||
|
window.initProjectPickerPanelSearch = initProjectPickerPanelSearch;
|
||||||
|
window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch;
|
||||||
|
window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch;
|
||||||
|
window.loadChatProjectPanelList = loadChatProjectPanelList;
|
||||||
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
||||||
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
||||||
window.getActiveProjectId = getActiveProjectId;
|
window.getActiveProjectId = getActiveProjectId;
|
||||||
@@ -2334,5 +2524,10 @@ window.deleteProjectFactEdge = deleteProjectFactEdge;
|
|||||||
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||||
|
window.rememberProjectsInNameMap = rememberProjectsInNameMap;
|
||||||
|
window.searchActiveProjects = searchActiveProjects;
|
||||||
|
window.filterActiveProjectsLocal = filterActiveProjectsLocal;
|
||||||
|
window.fetchProjectSummary = fetchProjectSummary;
|
||||||
window.projectNameById = projectNameById;
|
window.projectNameById = projectNameById;
|
||||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||||
|
window.isProjectsCacheReady = isProjectsCacheReady;
|
||||||
|
|||||||
@@ -335,7 +335,9 @@ async function initPage(pageId) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'hitl':
|
case 'hitl':
|
||||||
if (typeof refreshHitlPending === 'function') {
|
if (typeof refreshHitlActivePanel === 'function') {
|
||||||
|
refreshHitlActivePanel();
|
||||||
|
} else if (typeof refreshHitlPending === 'function') {
|
||||||
refreshHitlPending();
|
refreshHitlPending();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -389,10 +389,35 @@ async function loadConfig(loadTools = true) {
|
|||||||
subIdxFilterInput.value = knowledge.retrieval?.sub_index_filter || '';
|
subIdxFilterInput.value = knowledge.retrieval?.sub_index_filter || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mq = knowledge.retrieval?.multi_query || {};
|
||||||
|
const mqMaxInput = document.getElementById('knowledge-multi-query-max-queries');
|
||||||
|
if (mqMaxInput) {
|
||||||
|
const mqVal = parseInt(mq.max_queries, 10);
|
||||||
|
mqMaxInput.value = (!isNaN(mqVal) && mqVal > 0) ? mqVal : 4;
|
||||||
|
}
|
||||||
|
const rr = knowledge.retrieval?.rerank || {};
|
||||||
|
const rerankProviderSelect = document.getElementById('knowledge-rerank-provider');
|
||||||
|
if (rerankProviderSelect) {
|
||||||
|
const p = (rr.provider || '').toLowerCase();
|
||||||
|
rerankProviderSelect.value = (p === 'dashscope' || p === 'cohere') ? p : '';
|
||||||
|
}
|
||||||
|
const rerankModelInput = document.getElementById('knowledge-rerank-model');
|
||||||
|
if (rerankModelInput) {
|
||||||
|
rerankModelInput.value = rr.model || '';
|
||||||
|
}
|
||||||
|
const rerankBaseUrlInput = document.getElementById('knowledge-rerank-base-url');
|
||||||
|
if (rerankBaseUrlInput) {
|
||||||
|
rerankBaseUrlInput.value = rr.base_url || '';
|
||||||
|
}
|
||||||
|
const rerankApiKeyInput = document.getElementById('knowledge-rerank-api-key');
|
||||||
|
if (rerankApiKeyInput) {
|
||||||
|
rerankApiKeyInput.value = rr.api_key || '';
|
||||||
|
}
|
||||||
|
|
||||||
const post = knowledge.retrieval?.post_retrieve || {};
|
const post = knowledge.retrieval?.post_retrieve || {};
|
||||||
const prefetchInput = document.getElementById('knowledge-post-retrieve-prefetch-top-k');
|
const prefetchInput = document.getElementById('knowledge-post-retrieve-prefetch-top-k');
|
||||||
if (prefetchInput) {
|
if (prefetchInput) {
|
||||||
prefetchInput.value = post.prefetch_top_k ?? 0;
|
prefetchInput.value = post.prefetch_top_k ?? 20;
|
||||||
}
|
}
|
||||||
const maxCharsInput = document.getElementById('knowledge-post-retrieve-max-chars');
|
const maxCharsInput = document.getElementById('knowledge-post-retrieve-max-chars');
|
||||||
if (maxCharsInput) {
|
if (maxCharsInput) {
|
||||||
@@ -1273,8 +1298,25 @@ async function applySettings() {
|
|||||||
return isNaN(val) ? 0.7 : val;
|
return isNaN(val) ? 0.7 : val;
|
||||||
})(),
|
})(),
|
||||||
sub_index_filter: document.getElementById('knowledge-retrieval-sub-index-filter')?.value?.trim() || '',
|
sub_index_filter: document.getElementById('knowledge-retrieval-sub-index-filter')?.value?.trim() || '',
|
||||||
|
multi_query: {
|
||||||
|
max_queries: (() => {
|
||||||
|
const v = parseInt(document.getElementById('knowledge-multi-query-max-queries')?.value, 10);
|
||||||
|
if (isNaN(v) || v <= 0) return 4;
|
||||||
|
return Math.min(8, v);
|
||||||
|
})()
|
||||||
|
},
|
||||||
|
rerank: {
|
||||||
|
provider: document.getElementById('knowledge-rerank-provider')?.value?.trim() || '',
|
||||||
|
model: document.getElementById('knowledge-rerank-model')?.value?.trim() || '',
|
||||||
|
base_url: document.getElementById('knowledge-rerank-base-url')?.value?.trim() || '',
|
||||||
|
api_key: document.getElementById('knowledge-rerank-api-key')?.value?.trim() || ''
|
||||||
|
},
|
||||||
post_retrieve: {
|
post_retrieve: {
|
||||||
prefetch_top_k: parseInt(document.getElementById('knowledge-post-retrieve-prefetch-top-k')?.value, 10) || 0,
|
prefetch_top_k: (() => {
|
||||||
|
const raw = document.getElementById('knowledge-post-retrieve-prefetch-top-k')?.value;
|
||||||
|
const v = parseInt(raw, 10);
|
||||||
|
return isNaN(v) ? 20 : Math.max(0, v);
|
||||||
|
})(),
|
||||||
max_context_chars: parseInt(document.getElementById('knowledge-post-retrieve-max-chars')?.value, 10) || 0,
|
max_context_chars: parseInt(document.getElementById('knowledge-post-retrieve-max-chars')?.value, 10) || 0,
|
||||||
max_context_tokens: parseInt(document.getElementById('knowledge-post-retrieve-max-tokens')?.value, 10) || 0
|
max_context_tokens: parseInt(document.getElementById('knowledge-post-retrieve-max-tokens')?.value, 10) || 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ async function viewSkill(skillId) {
|
|||||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(sumSkill.path || '')}</div>
|
<div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(sumSkill.path || '')}</div>
|
||||||
<div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(sumSkill.mod_time || '')}</div>
|
<div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(sumSkill.mod_time || '')}</div>
|
||||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(contentLabel)}</strong> <span style="opacity:0.8;font-size:12px;">${escapeHtml(_t('skills.summaryHint'))}</span></div>
|
<div style="margin-bottom: 8px;"><strong>${escapeHtml(contentLabel)}</strong> <span style="opacity:0.8;font-size:12px;">${escapeHtml(_t('skills.summaryHint'))}</span></div>
|
||||||
<pre id="skill-view-body" style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(sumSkill.content || '')}</pre>
|
<pre id="skill-view-body" style="padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; border: 1px solid var(--border-color);">${escapeHtml(sumSkill.content || '')}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-secondary" data-skill-load-full>${escapeHtml(loadFullLabel)}</button>
|
<button type="button" class="btn-secondary" data-skill-load-full>${escapeHtml(loadFullLabel)}</button>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function updateCompletedTasksHistory(currentTasks) {
|
|||||||
|
|
||||||
tasksState.completedTasksHistory.push({
|
tasksState.completedTasksHistory.push({
|
||||||
conversationId: task.conversationId,
|
conversationId: task.conversationId,
|
||||||
message: task.message || '未命名任务',
|
message: task.title || task.message || '未命名任务',
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
completedAt: new Date().toISOString()
|
completedAt: new Date().toISOString()
|
||||||
@@ -537,7 +537,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
|||||||
` : '<div class="task-checkbox-placeholder"></div>'}
|
` : '<div class="task-checkbox-placeholder"></div>'}
|
||||||
<span class="task-status ${status.class}">${status.text}</span>
|
<span class="task-status ${status.class}">${status.text}</span>
|
||||||
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
||||||
<span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
|
<span class="task-message" title="${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}">${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cyberstrike-theme';
|
||||||
|
const THEMES = ['system', 'light', 'dark'];
|
||||||
|
const FALLBACK_LABELS = {
|
||||||
|
system: '跟随系统',
|
||||||
|
light: '浅色',
|
||||||
|
dark: '暗色'
|
||||||
|
};
|
||||||
|
const FALLBACK_TITLES = {
|
||||||
|
system: '当前:跟随系统主题。点击切换为浅色。',
|
||||||
|
light: '当前:浅色主题。点击切换为暗色。',
|
||||||
|
dark: '当前:暗色主题。点击切换为跟随系统。'
|
||||||
|
};
|
||||||
|
const TITLE_KEYS = {
|
||||||
|
system: 'titleSystem',
|
||||||
|
light: 'titleLight',
|
||||||
|
dark: 'titleDark'
|
||||||
|
};
|
||||||
|
|
||||||
|
const media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
||||||
|
|
||||||
|
function themeText(key, fallback) {
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
const value = window.t('theme.' + key);
|
||||||
|
if (value && value !== 'theme.' + key) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabel(preference) {
|
||||||
|
return themeText(preference, FALLBACK_LABELS[preference] || FALLBACK_LABELS.system);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(preference) {
|
||||||
|
const titleKey = TITLE_KEYS[preference] || TITLE_KEYS.system;
|
||||||
|
return themeText(titleKey, FALLBACK_TITLES[preference] || FALLBACK_TITLES.system);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreference(value) {
|
||||||
|
return THEMES.includes(value) ? value : 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreference() {
|
||||||
|
try {
|
||||||
|
return normalizePreference(localStorage.getItem(STORAGE_KEY));
|
||||||
|
} catch (err) {
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(preference) {
|
||||||
|
if (preference === 'dark' || preference === 'light') {
|
||||||
|
return preference;
|
||||||
|
}
|
||||||
|
return media && media.matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButton(preference, resolved) {
|
||||||
|
const btn = document.getElementById('theme-toggle-btn');
|
||||||
|
const label = document.getElementById('theme-toggle-label');
|
||||||
|
if (!btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.dataset.themePreference = preference;
|
||||||
|
btn.dataset.theme = resolved;
|
||||||
|
const title = getTitle(preference);
|
||||||
|
btn.title = title;
|
||||||
|
btn.setAttribute('aria-label', title);
|
||||||
|
if (label) {
|
||||||
|
label.textContent = getLabel(preference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(preference) {
|
||||||
|
const normalized = normalizePreference(preference);
|
||||||
|
const resolved = resolveTheme(normalized);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme-preference', normalized);
|
||||||
|
root.setAttribute('data-theme', resolved);
|
||||||
|
root.style.colorScheme = resolved;
|
||||||
|
updateButton(normalized, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreference(preference) {
|
||||||
|
const normalized = normalizePreference(preference);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, normalized);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore storage failures; the current page can still apply the theme.
|
||||||
|
}
|
||||||
|
applyTheme(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setThemePreference = savePreference;
|
||||||
|
window.getThemePreference = readPreference;
|
||||||
|
window.cycleThemePreference = function () {
|
||||||
|
const current = readPreference();
|
||||||
|
const next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
|
||||||
|
savePreference(next);
|
||||||
|
};
|
||||||
|
window.refreshThemeToggleLabel = function () {
|
||||||
|
applyTheme(readPreference());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (media) {
|
||||||
|
const onSystemThemeChange = function () {
|
||||||
|
if (readPreference() === 'system') {
|
||||||
|
applyTheme('system');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof media.addEventListener === 'function') {
|
||||||
|
media.addEventListener('change', onSystemThemeChange);
|
||||||
|
} else if (typeof media.addListener === 'function') {
|
||||||
|
media.addListener(onSystemThemeChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('languagechange', function () {
|
||||||
|
applyTheme(readPreference());
|
||||||
|
});
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
applyTheme(readPreference());
|
||||||
|
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
|
||||||
|
window.i18nReady.then(function () {
|
||||||
|
applyTheme(readPreference());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initTheme);
|
||||||
|
} else {
|
||||||
|
initTheme();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+42
-41
@@ -362,6 +362,20 @@ function wsProjectT(key, fallback) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wsProjectPickerT(key) {
|
||||||
|
var fallbacks = {
|
||||||
|
'projects.noProject': '无项目',
|
||||||
|
'projects.noProjectDescription': '不绑定项目黑板',
|
||||||
|
'projects.sharedFactBoard': '共享事实黑板',
|
||||||
|
'common.untitled': '未命名',
|
||||||
|
'common.loading': '加载中…',
|
||||||
|
'chat.filterProjectSearchEmpty': '没有匹配的项目',
|
||||||
|
'chat.filterProjectSearchMore': '更多项目请输入关键字搜索',
|
||||||
|
'chat.filterProjectSearchFailed': '加载项目失败,请重试',
|
||||||
|
};
|
||||||
|
return wsProjectT(key, fallbacks[key]);
|
||||||
|
}
|
||||||
|
|
||||||
function getWebshellAiConvId(conn) {
|
function getWebshellAiConvId(conn) {
|
||||||
if (!conn || !conn.id) return '';
|
if (!conn || !conn.id) return '';
|
||||||
return webshellAiConvMap[conn.id] || '';
|
return webshellAiConvMap[conn.id] || '';
|
||||||
@@ -409,51 +423,32 @@ function wsUpdateProjectButtonLabel() {
|
|||||||
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsRenderProjectPanelList() {
|
async function wsLoadProjectPanelList() {
|
||||||
var list = document.getElementById('ws-project-list');
|
if (typeof window.renderProjectPickerPanel !== 'function') return;
|
||||||
if (!list || !webshellCurrentConn) return;
|
await window.renderProjectPickerPanel('webshell', {
|
||||||
var conn = webshellCurrentConn;
|
listId: 'ws-project-list',
|
||||||
var selected = wsResolveWebshellAiProjectSelection(conn);
|
searchInputId: 'ws-project-search',
|
||||||
var projects = [];
|
getSelectedId: function () {
|
||||||
try {
|
return webshellCurrentConn ? wsResolveWebshellAiProjectSelection(webshellCurrentConn) : '';
|
||||||
if (typeof window.fetchAllProjects === 'function') {
|
},
|
||||||
projects = await window.fetchAllProjects(false);
|
onSelect: function (projectId) { wsSelectProject(projectId); },
|
||||||
}
|
t: wsProjectPickerT,
|
||||||
} catch (e) {
|
|
||||||
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
|
||||||
window.rebuildProjectNameMap(projects);
|
|
||||||
}
|
|
||||||
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
|
|
||||||
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
|
|
||||||
list.innerHTML = '';
|
|
||||||
items.forEach(function (p) {
|
|
||||||
var isNone = !p.id;
|
|
||||||
var isSelected = isNone ? !selected : selected === p.id;
|
|
||||||
var desc = isNone
|
|
||||||
? (p.description || '')
|
|
||||||
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
|
||||||
btn.setAttribute('role', 'option');
|
|
||||||
btn.onclick = function () { wsSelectProject(p.id || ''); };
|
|
||||||
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
|
|
||||||
'<div class="role-selection-item-content-main">' +
|
|
||||||
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
|
|
||||||
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
|
|
||||||
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
|
|
||||||
list.appendChild(btn);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsRenderProjectPanel() {
|
async function wsRenderProjectPanel() {
|
||||||
var list = document.getElementById('ws-project-list');
|
if (typeof window.initProjectPickerPanelSearch === 'function') {
|
||||||
if (!list) return;
|
window.initProjectPickerPanelSearch('webshell', 'ws-project-search', function () {
|
||||||
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
if (typeof window.scheduleProjectPickerPanelSearch === 'function') {
|
||||||
await wsRenderProjectPanelList();
|
window.scheduleProjectPickerPanelSearch('webshell', function () { wsLoadProjectPanelList(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof window.clearProjectPickerPanelSearch === 'function') {
|
||||||
|
window.clearProjectPickerPanelSearch('webshell', 'ws-project-search');
|
||||||
|
}
|
||||||
|
await wsLoadProjectPanelList();
|
||||||
|
requestAnimationFrame(function () { document.getElementById('ws-project-search')?.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function wsCloseProjectPanel() {
|
function wsCloseProjectPanel() {
|
||||||
@@ -464,6 +459,9 @@ function wsCloseProjectPanel() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
|
if (typeof window.clearProjectPickerPanelSearch === 'function') {
|
||||||
|
window.clearProjectPickerPanelSearch('webshell', 'ws-project-search');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsToggleProjectPanel() {
|
async function wsToggleProjectPanel() {
|
||||||
@@ -2230,6 +2228,9 @@ function selectWebshell(id, stateReady) {
|
|||||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="chat-project-panel-body">' +
|
'<div class="chat-project-panel-body">' +
|
||||||
|
'<div class="chat-project-panel-search">' +
|
||||||
|
'<input type="search" id="ws-project-search" class="chat-project-panel-search-input" autocomplete="off" placeholder="' + escapeHtml(wsProjectT('projects.searchProjectsPlaceholder', '搜索项目…')) + '">' +
|
||||||
|
'</div>' +
|
||||||
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
||||||
'<div class="chat-project-panel-footer">' +
|
'<div class="chat-project-panel-footer">' +
|
||||||
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
||||||
|
|||||||
+139
-1
@@ -6,6 +6,7 @@
|
|||||||
<title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
|
<title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
|
||||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="/static/js/theme.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* 覆盖主CSS的overflow限制,允许API文档页面滚动 */
|
/* 覆盖主CSS的overflow限制,允许API文档页面滚动 */
|
||||||
body {
|
body {
|
||||||
@@ -25,9 +26,24 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
|
padding-right: 280px;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-docs-header-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-docs-header-actions .theme-toggle-btn,
|
||||||
|
.api-docs-header-actions .lang-switcher-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.api-docs-header h1 {
|
.api-docs-header h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -822,6 +838,114 @@
|
|||||||
.empty-state p {
|
.empty-state p {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] body {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-docs-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-docs-sidebar,
|
||||||
|
html[data-theme="dark"] .auth-info-section,
|
||||||
|
html[data-theme="dark"] .api-endpoint,
|
||||||
|
html[data-theme="dark"] .api-endpoint-header,
|
||||||
|
html[data-theme="dark"] .api-test-form,
|
||||||
|
html[data-theme="dark"] .api-response-example,
|
||||||
|
html[data-theme="dark"] .api-description pre,
|
||||||
|
html[data-theme="dark"] .api-description-detail .code-block,
|
||||||
|
html[data-theme="dark"] .api-description-detail .inline-code,
|
||||||
|
html[data-theme="dark"] .api-description code {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #263244;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-endpoint:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-group-link:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-group-link.active {
|
||||||
|
background: rgba(96, 165, 250, 0.16);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-params-table th {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-input-group input,
|
||||||
|
html[data-theme="dark"] .api-test-input-group textarea {
|
||||||
|
background: #0f172a;
|
||||||
|
border-color: #2b374b;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-input-group input:focus,
|
||||||
|
html[data-theme="dark"] .api-test-input-group textarea:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-result.success {
|
||||||
|
background: rgba(52, 211, 153, 0.14);
|
||||||
|
color: #6ee7b7;
|
||||||
|
border-color: rgba(52, 211, 153, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-result.error {
|
||||||
|
background: rgba(248, 113, 113, 0.14);
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: rgba(248, 113, 113, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-btn.secondary {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #2b374b;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-test-btn.secondary:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-docs-header-actions .theme-toggle-btn,
|
||||||
|
html[data-theme="dark"] .api-docs-header-actions .lang-switcher-btn {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #2b374b;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .api-docs-header-actions .theme-toggle-btn:hover,
|
||||||
|
html[data-theme="dark"] .api-docs-header-actions .lang-switcher-btn:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] #token-status {
|
||||||
|
background: rgba(96, 165, 250, 0.12) !important;
|
||||||
|
border-left-color: var(--accent-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.api-docs-header {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-docs-header-actions {
|
||||||
|
top: auto;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -837,7 +961,21 @@
|
|||||||
<span data-i18n="apiDocs.title">API 文档</span>
|
<span data-i18n="apiDocs.title">API 文档</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p data-i18n="apiDocs.subtitle">CyberStrikeAI 平台 API 接口文档,支持在线测试</p>
|
<p data-i18n="apiDocs.subtitle">CyberStrikeAI 平台 API 接口文档,支持在线测试</p>
|
||||||
<div class="api-docs-lang-switcher" style="position: absolute; top: 24px; right: 24px;">
|
<div class="api-docs-header-actions">
|
||||||
|
<button id="theme-toggle-btn" class="theme-toggle-btn btn-secondary" type="button" onclick="window.cycleThemePreference && window.cycleThemePreference()" data-i18n="theme.toggle" data-i18n-attr="title" title="切换主题" aria-label="切换主题">
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--system" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--light" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--dark" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a7 7 0 1 0 11.5 11.5z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span id="theme-toggle-label">跟随系统</span>
|
||||||
|
</button>
|
||||||
<div class="lang-switcher">
|
<div class="lang-switcher">
|
||||||
<button type="button" class="btn-secondary lang-switcher-btn" onclick="typeof toggleLangDropdown === 'function' && toggleLangDropdown()" title="界面语言">
|
<button type="button" class="btn-secondary lang-switcher-btn" onclick="typeof toggleLangDropdown === 'function' && toggleLangDropdown()" title="界面语言">
|
||||||
<span class="lang-switcher-icon">🌐</span>
|
<span class="lang-switcher-icon">🌐</span>
|
||||||
|
|||||||
+254
-8
@@ -6,6 +6,23 @@
|
|||||||
<title>CyberStrikeAI</title>
|
<title>CyberStrikeAI</title>
|
||||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('cyberstrike-theme') || 'system';
|
||||||
|
if (stored !== 'system' && stored !== 'light' && stored !== 'dark') {
|
||||||
|
stored = 'system';
|
||||||
|
}
|
||||||
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var resolved = stored === 'dark' || (stored !== 'light' && prefersDark) ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme-preference', stored);
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
document.documentElement.style.colorScheme = resolved;
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="/static/css/c2.css">
|
<link rel="stylesheet" href="/static/css/c2.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||||
@@ -57,12 +74,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="header.apiDocs">API 文档</span>
|
<span data-i18n="header.apiDocs">API 文档</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="openapi-doc-btn" onclick="window.open('https://github.com/Ed1s0nZ/CyberStrikeAI', '_blank')" data-i18n="header.github" data-i18n-attr="title" data-i18n-skip-text="true" title="GitHub">
|
<button class="openapi-doc-btn" onclick="window.open('https://github.com/Ed1s0nZ/CyberStrikeAI', '_blank')" data-i18n="header.github" data-i18n-attr="title" data-i18n-skip-text="true" title="GitHub">
|
||||||
<svg width="16" height="16" viewBox="0 0 98 96" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 98 96" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
|
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="header.github">GitHub</span>
|
<span data-i18n="header.github">GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="theme-toggle-btn" class="theme-toggle-btn" type="button" onclick="window.cycleThemePreference && window.cycleThemePreference()" data-i18n="theme.toggle" data-i18n-attr="title" title="切换主题" aria-label="切换主题">
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--system" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--light" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-toggle-icon theme-toggle-icon--dark" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a7 7 0 1 0 11.5 11.5z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span id="theme-toggle-label">跟随系统</span>
|
||||||
|
</button>
|
||||||
<div class="lang-switcher">
|
<div class="lang-switcher">
|
||||||
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language" data-i18n-attr="title" data-i18n-skip-text="true" title="界面语言">
|
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language" data-i18n-attr="title" data-i18n-skip-text="true" title="界面语言">
|
||||||
<span class="lang-switcher-icon">🌐</span>
|
<span class="lang-switcher-icon">🌐</span>
|
||||||
@@ -942,6 +973,19 @@
|
|||||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||||
|
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||||
|
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||||
|
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||||
|
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||||
|
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||||
|
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||||
|
</div>
|
||||||
<div class="hitl-config-field hitl-config-field--tools">
|
<div class="hitl-config-field hitl-config-field--tools">
|
||||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||||
@@ -1039,6 +1083,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-project-panel-body">
|
<div class="chat-project-panel-body">
|
||||||
|
<div class="chat-project-panel-search">
|
||||||
|
<input type="search" id="chat-project-search" class="chat-project-panel-search-input" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索项目…">
|
||||||
|
</div>
|
||||||
<div id="chat-project-list" class="role-selection-list-main"></div>
|
<div id="chat-project-list" class="role-selection-list-main"></div>
|
||||||
<div class="chat-project-panel-footer">
|
<div class="chat-project-panel-footer">
|
||||||
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
|
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
|
||||||
@@ -1160,13 +1207,177 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
|
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-secondary" onclick="refreshHitlPending()" data-i18n="common.refresh">刷新</button>
|
<button type="button" class="btn-secondary" id="hitl-refresh-btn" onclick="refreshHitlActivePanel()" data-i18n="common.refresh">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="settings-section">
|
<div class="hitl-page-reviewer-bar" id="hitl-page-reviewer-bar">
|
||||||
<h3 data-i18n="hitl.pendingTitle">待处理审批</h3>
|
<div class="hitl-page-reviewer-main">
|
||||||
|
<span class="hitl-page-reviewer-label" data-i18n="hitl.pageReviewerLabel">当前审批方</span>
|
||||||
|
<div class="hitl-reviewer-toggle hitl-reviewer-toggle--page" role="group" aria-label="Reviewer">
|
||||||
|
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||||
|
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||||
|
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hitl-page-reviewer-hint" data-i18n="hitl.pageReviewerHint">作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-page-tabs" role="tablist">
|
||||||
|
<button type="button" class="hitl-page-tab hitl-page-tab--active" id="hitl-tab-pending" role="tab" aria-selected="true" onclick="switchHitlPageTab('pending')">
|
||||||
|
<span data-i18n="hitl.tabPending">待审计</span>
|
||||||
|
<span class="hitl-tab-badge" id="hitl-pending-count" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hitl-page-tab" id="hitl-tab-logs" role="tab" aria-selected="false" onclick="switchHitlPageTab('logs')">
|
||||||
|
<span data-i18n="hitl.tabLogs">审计日志</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hitl-page-tab" id="hitl-tab-strategy" role="tab" aria-selected="false" onclick="switchHitlPageTab('strategy')">
|
||||||
|
<span data-i18n="hitl.tabStrategy">审计策略</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hitl-page-tab" id="hitl-tab-whitelist" role="tab" aria-selected="false" onclick="switchHitlPageTab('whitelist')">
|
||||||
|
<span data-i18n="hitl.tabWhitelist">工具白名单</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hitl-panel-pending" class="hitl-page-panel">
|
||||||
|
<div class="hitl-filters">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="hitl.searchLabel">搜索</span>
|
||||||
|
<input type="search" id="hitl-pending-search" class="hitl-filter-input" data-i18n="hitl.searchPlaceholder" data-i18n-attr="placeholder" placeholder="" onkeydown="if(event.key==='Enter')filterHitlPending()" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-secondary" onclick="filterHitlPending()" data-i18n="hitl.searchApply">搜索</button>
|
||||||
|
</div>
|
||||||
<div id="hitl-pending-list" class="hitl-pending-list"></div>
|
<div id="hitl-pending-list" class="hitl-pending-list"></div>
|
||||||
|
<div id="hitl-pending-pagination" class="hitl-pending-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hitl-panel-logs" class="hitl-page-panel" hidden>
|
||||||
|
<div class="hitl-filters hitl-filters--logs">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="hitl.searchLabel">搜索</span>
|
||||||
|
<input type="search" id="hitl-logs-search" class="hitl-filter-input" data-i18n="hitl.searchPlaceholder" data-i18n-attr="placeholder" placeholder="" onkeydown="if(event.key==='Enter')filterHitlLogs()" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="hitl.filterDecision">决策</span>
|
||||||
|
<select id="hitl-logs-decision-filter" class="hitl-filter-select" onchange="filterHitlLogs()">
|
||||||
|
<option value="all" data-i18n="hitl.filterAll">全部</option>
|
||||||
|
<option value="approve" data-i18n="hitl.decisionApprove">通过</option>
|
||||||
|
<option value="reject" data-i18n="hitl.decisionReject">拒绝</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="hitl.filterDecidedBy">审批方</span>
|
||||||
|
<select id="hitl-logs-decidedby-filter" class="hitl-filter-select" onchange="filterHitlLogs()">
|
||||||
|
<option value="all" data-i18n="hitl.filterAll">全部</option>
|
||||||
|
<option value="human" data-i18n="hitl.reviewerHuman">人工</option>
|
||||||
|
<option value="audit_agent" data-i18n="hitl.reviewerAgent">审计 Agent</option>
|
||||||
|
<option value="system" data-i18n="hitl.reviewerSystem">系统</option>
|
||||||
|
<option value="manual" data-i18n="hitl.reviewerManual">手动录入</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-secondary" onclick="filterHitlLogs()" data-i18n="hitl.searchApply">搜索</button>
|
||||||
|
<button type="button" class="btn-secondary btn-delete" onclick="clearHitlLogs()" data-i18n="hitl.clearAll">清空</button>
|
||||||
|
</div>
|
||||||
|
<p id="hitl-logs-retention-hint" class="hitl-logs-retention-hint" hidden></p>
|
||||||
|
<div id="hitl-logs-batch-actions" class="monitor-batch-actions" style="display: none;">
|
||||||
|
<div class="batch-actions-info">
|
||||||
|
<span id="hitl-logs-selected-count" data-i18n="hitl.selectedCount" data-i18n-params='{"count":0}'>已选择 0 项</span>
|
||||||
|
</div>
|
||||||
|
<div class="batch-actions-buttons">
|
||||||
|
<button type="button" class="btn-secondary" onclick="selectAllHitlLogs()" data-i18n="hitl.selectAll">全选</button>
|
||||||
|
<button type="button" class="btn-secondary" onclick="deselectAllHitlLogs()" data-i18n="hitl.deselectAll">取消全选</button>
|
||||||
|
<button type="button" class="btn-secondary btn-delete" onclick="batchDeleteHitlLogs()" data-i18n="hitl.batchDelete">批量删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="hitl-logs-table-wrap" class="hitl-logs-table-wrap">
|
||||||
|
<div class="loading-spinner" data-i18n="hitl.loading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
<div id="hitl-logs-pagination" class="hitl-logs-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hitl-panel-strategy" class="hitl-page-panel" hidden>
|
||||||
|
<div class="hitl-page-strategy-bar" id="hitl-page-strategy-bar">
|
||||||
|
<div class="hitl-page-strategy-header">
|
||||||
|
<span class="hitl-page-strategy-label" data-i18n="hitl.strategyLabel">审计策略</span>
|
||||||
|
<div class="hitl-page-strategy-actions">
|
||||||
|
<button type="button" class="btn-link" id="hitl-strategy-reset-btn" onclick="resetHitlAuditStrategy()" data-i18n="hitl.strategyReset">恢复默认</button>
|
||||||
|
<button type="button" class="btn-secondary" id="hitl-strategy-save-btn" onclick="saveHitlAuditStrategy()" data-i18n="common.save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-strategy-subtabs" role="tablist" aria-label="Audit strategy mode">
|
||||||
|
<button type="button" class="hitl-strategy-subtab hitl-strategy-subtab--active" id="hitl-strategy-tab-approval" role="tab" aria-selected="true" data-strategy-mode="approval" onclick="switchHitlStrategyMode('approval')" data-i18n="hitl.strategyTabApproval">审批模式</button>
|
||||||
|
<button type="button" class="hitl-strategy-subtab" id="hitl-strategy-tab-review-edit" role="tab" aria-selected="false" data-strategy-mode="review_edit" onclick="switchHitlStrategyMode('review_edit')" data-i18n="hitl.strategyTabReviewEdit">审查编辑模式</button>
|
||||||
|
</div>
|
||||||
|
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-approval" data-i18n="hitl.strategyHintApproval">白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。</p>
|
||||||
|
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-review-edit" hidden data-i18n="hitl.strategyHintReviewEdit">审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。</p>
|
||||||
|
<textarea id="hitl-audit-agent-prompt" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off"></textarea>
|
||||||
|
<textarea id="hitl-audit-agent-prompt-review-edit" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off" hidden></textarea>
|
||||||
|
<div id="hitl-strategy-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hitl-panel-whitelist" class="hitl-page-panel" hidden>
|
||||||
|
<div class="hitl-page-whitelist-bar" id="hitl-page-whitelist-bar">
|
||||||
|
<div class="hitl-page-whitelist-header">
|
||||||
|
<span class="hitl-page-whitelist-label" data-i18n="hitl.whitelistLabel">免审批工具白名单</span>
|
||||||
|
<button type="button" class="btn-secondary" id="hitl-page-whitelist-save-btn" onclick="saveHitlPageWhitelist()" data-i18n="common.save">保存</button>
|
||||||
|
</div>
|
||||||
|
<p class="hitl-page-whitelist-hint" data-i18n="hitl.whitelistHint">每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。</p>
|
||||||
|
<textarea id="hitl-page-sensitive-tools" class="hitl-page-whitelist-textarea" rows="6" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||||
|
<div id="hitl-page-whitelist-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hitl-log-modal" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="hitl-log-modal-title">
|
||||||
|
<div class="modal-content hitl-log-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="hitl-log-modal-title" data-i18n="hitl.logModalView">审计日志详情</h3>
|
||||||
|
<button type="button" class="modal-close" onclick="closeHitlLogModal()" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="hitl-log-context-readonly" class="hitl-log-readonly-section" hidden></div>
|
||||||
|
<div id="hitl-log-execution-readonly" class="hitl-log-readonly-section" hidden></div>
|
||||||
|
<dl class="hitl-log-detail-meta">
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colId">ID</dt>
|
||||||
|
<dd id="hitl-log-detail-id" class="hitl-log-detail-mono">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colTool">工具</dt>
|
||||||
|
<dd id="hitl-log-detail-tool">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colConversation">会话</dt>
|
||||||
|
<dd id="hitl-log-detail-conversation" class="hitl-log-detail-mono">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colDecision">决策</dt>
|
||||||
|
<dd id="hitl-log-detail-decision">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colDecidedBy">审批方</dt>
|
||||||
|
<dd id="hitl-log-detail-decided-by">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row">
|
||||||
|
<dt data-i18n="hitl.colTime">时间</dt>
|
||||||
|
<dd id="hitl-log-detail-time">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="hitl-log-detail-row hitl-log-detail-row--full" id="hitl-log-detail-comment-row" hidden>
|
||||||
|
<dt data-i18n="hitl.fieldComment">备注</dt>
|
||||||
|
<dd id="hitl-log-detail-comment">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="hitl-log-detail-payload" id="hitl-log-detail-payload-wrap" hidden>
|
||||||
|
<div class="hitl-context-label" data-i18n="hitl.fieldPayload">载荷</div>
|
||||||
|
<pre id="hitl-log-detail-payload" class="hitl-context-text"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeHitlLogModal()" data-i18n="common.close">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1533,10 +1744,13 @@
|
|||||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
|
||||||
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-detail-header-actions">
|
<div class="projects-detail-header-actions">
|
||||||
|
<span id="projects-detail-meta" class="projects-detail-meta" aria-live="polite">
|
||||||
|
<svg class="projects-detail-meta-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<span id="projects-detail-meta-time" class="projects-detail-meta-time"></span>
|
||||||
|
</span>
|
||||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
||||||
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2826,14 +3040,46 @@
|
|||||||
<small class="form-hint" data-i18n="settingsBasic.subIndexFilterHint">留空表示不过滤;非空时仅检索 sub_indexes 含该标签的向量行(未打标旧数据仍会命中)。</small>
|
<small class="form-hint" data-i18n="settingsBasic.subIndexFilterHint">留空表示不过滤;非空时仅检索 sub_indexes 含该标签的向量行(未打标旧数据仍会命中)。</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h5 data-i18n="settingsBasic.ragPipelineHeader">RAG 管线(MultiQuery + Rerank)</h5>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint" style="margin: 0 0 12px 0;" data-i18n="settingsBasic.ragPipelineHint">MultiQuery 与精排始终启用:LLM 改写多路检索 → 向量预取与融合 → HTTP 精排 → 去重与预算截断。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="knowledge-multi-query-max-queries" data-i18n="settingsBasic.multiQueryMaxQueries">MultiQuery 改写变体上限</label>
|
||||||
|
<input type="number" id="knowledge-multi-query-max-queries" min="1" max="8" data-i18n="settingsBasic.multiQueryMaxQueriesPlaceholder" data-i18n-attr="placeholder" placeholder="4" />
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.multiQueryMaxQueriesHint">LLM 生成的检索变体数量上限(含原问语义覆盖);建议 3~4,最大 8。</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="knowledge-rerank-provider" data-i18n="settingsBasic.rerankProvider">精排提供商</label>
|
||||||
|
<select id="knowledge-rerank-provider">
|
||||||
|
<option value="" data-i18n="settingsBasic.rerankProviderAuto">自动(按 Base URL 推断)</option>
|
||||||
|
<option value="dashscope">DashScope</option>
|
||||||
|
<option value="cohere" data-i18n="settingsBasic.rerankProviderCohere">Cohere 兼容 API</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.rerankProviderHint">DashScope 使用 gte-rerank;其他兼容端点走 /v1/rerank。留空时按下方 Base URL 自动推断。</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="knowledge-rerank-model" data-i18n="settingsBasic.rerankModel">精排模型(可选)</label>
|
||||||
|
<input type="text" id="knowledge-rerank-model" data-i18n="settingsBasic.rerankModelPlaceholder" data-i18n-attr="placeholder" placeholder="留空:DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="knowledge-rerank-base-url" data-i18n="settingsBasic.rerankBaseUrl">精排 Base URL(可选)</label>
|
||||||
|
<input type="text" id="knowledge-rerank-base-url" data-i18n="settingsBasic.rerankBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="留空则复用嵌入 / OpenAI 的 base_url" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="knowledge-rerank-api-key" data-i18n="settingsBasic.rerankApiKey">精排 API Key(可选)</label>
|
||||||
|
<input type="password" id="knowledge-rerank-api-key" data-i18n="settingsBasic.rerankApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="留空则复用嵌入 / OpenAI 的 api_key" />
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.rerankApiKeyHint">精排失败时自动降级为融合排序,检索仍可用。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h5 data-i18n="settingsBasic.postRetrieveHeader">检索后处理(去重 / 预算)</h5>
|
<h5 data-i18n="settingsBasic.postRetrieveHeader">检索后处理(去重 / 预算)</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint" style="margin: 0 0 12px 0;" data-i18n="settingsBasic.postRetrieveDedupeAuto">检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。</p>
|
<p class="form-hint" style="margin: 0 0 12px 0;" data-i18n="settingsBasic.postRetrieveDedupeAuto">检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="knowledge-post-retrieve-prefetch-top-k" data-i18n="settingsBasic.prefetchTopK">预取候选数(向量阶段)</label>
|
<label for="knowledge-post-retrieve-prefetch-top-k" data-i18n="settingsBasic.prefetchTopK">预取候选数(向量阶段)</label>
|
||||||
<input type="number" id="knowledge-post-retrieve-prefetch-top-k" min="0" max="200" data-i18n="settingsBasic.prefetchTopKPlaceholder" data-i18n-attr="placeholder" placeholder="0" />
|
<input type="number" id="knowledge-post-retrieve-prefetch-top-k" min="0" max="200" data-i18n="settingsBasic.prefetchTopKPlaceholder" data-i18n-attr="placeholder" placeholder="20" />
|
||||||
<small class="form-hint" data-i18n="settingsBasic.prefetchTopKHint">0 表示与 Top-K 相同;大于 Top-K 时先多取候选再经去重/截断回到 Top-K(上限 200)。</small>
|
<small class="form-hint" data-i18n="settingsBasic.prefetchTopKHint">每条 MultiQuery 变体的向量候选数;0 表示内置 max(top_k×4, 20)(上限 200)。</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="knowledge-post-retrieve-max-chars" data-i18n="settingsBasic.maxContextChars">返回内容最大字符数(Unicode)</label>
|
<label for="knowledge-post-retrieve-max-chars" data-i18n="settingsBasic.maxContextChars">返回内容最大字符数(Unicode)</label>
|
||||||
@@ -4491,6 +4737,7 @@
|
|||||||
|
|
||||||
<script src="/static/vendor/i18next.min.js"></script>
|
<script src="/static/vendor/i18next.min.js"></script>
|
||||||
<script src="/static/js/i18n.js"></script>
|
<script src="/static/js/i18n.js"></script>
|
||||||
|
<script src="/static/js/theme.js"></script>
|
||||||
<script src="/static/js/builtin-tools.js"></script>
|
<script src="/static/js/builtin-tools.js"></script>
|
||||||
<script src="/static/js/auth.js"></script>
|
<script src="/static/js/auth.js"></script>
|
||||||
<script src="/static/js/modal.js"></script>
|
<script src="/static/js/modal.js"></script>
|
||||||
@@ -4521,4 +4768,3 @@
|
|||||||
<script src="/static/js/c2.js"></script>
|
<script src="/static/js/c2.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user