mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-03 03:05:57 +02:00
Compare commits
27 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 |
@@ -35,7 +35,18 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
|
||||
### 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.*
|
||||
|
||||
@@ -110,7 +121,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 📄 Large-result pagination, compression, and searchable archives
|
||||
- 🔗 Attack-chain graph, risk scoring, and step-by-step replay
|
||||
- 🔒 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
|
||||
- 📂 **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
|
||||
@@ -455,9 +466,10 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
||||
|
||||
### 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 retrieval** – cosine similarity over stored embeddings, aligned with Eino `retriever.Retriever` usage.
|
||||
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings.
|
||||
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization.
|
||||
- **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.
|
||||
- **Vector retrieval** – cosine similarity over stored embeddings with configurable threshold, aligned with Eino `retriever.Retriever` usage.
|
||||
- **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.
|
||||
|
||||
**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:
|
||||
top_k: 5
|
||||
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`).
|
||||
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:
|
||||
top_k: 5 # Number of top results to return
|
||||
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)
|
||||
skills_dir: "skills" # Skills directory (relative to config file)
|
||||
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 持久化
|
||||
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
||||
- 📚 知识库(RAG):**Eino MultiQuery** 查询改写 + 多路向量检索 + **HTTP 精排**(DashScope `gte-rerank` / Cohere 兼容)+ 后处理(去重、预算);索引侧为 **Eino Compose** 流水线
|
||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||
@@ -453,9 +464,10 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
|
||||
### 知识库功能
|
||||
- **向量检索**:AI 智能体在对话过程中可自动调用 `search_knowledge_base` 工具搜索知识库中的安全知识。
|
||||
- **向量检索**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引。
|
||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理。
|
||||
- **RAG 管线(始终启用)**:**MultiQuery**(LLM 查询改写)→ 向量预取与融合 → **HTTP 精排**(DashScope `gte-rerank` 或 Cohere 兼容 `/v1/rerank`)→ 后处理(规范化去重、字符/token 预算、最终 top_k)。精排失败时自动降级为融合排序,检索仍可用。
|
||||
- **向量相似度**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引(Eino Markdown 标题切分 + 递归分块)。
|
||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理;设置页可配置 MultiQuery / 精排 / 预取候选数。
|
||||
- **检索日志**:记录所有知识检索操作,便于审计与调试。
|
||||
|
||||
**快速开始(使用预构建知识库):**
|
||||
@@ -477,6 +489,17 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
retrieval:
|
||||
top_k: 5
|
||||
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`)。
|
||||
3. **扫描索引**:在 Web 界面中点击"扫描知识库",系统会自动导入文件并构建向量索引。
|
||||
@@ -537,6 +560,17 @@ knowledge:
|
||||
retrieval:
|
||||
top_k: 5 # 检索返回的 Top-K 结果数量
|
||||
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" # 角色配置文件目录(相对于配置文件所在目录)
|
||||
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||
|
||||
+13
-5
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.48"
|
||||
version: "v1.6.49"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -175,7 +175,6 @@ multi_agent:
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||
plan_execute_loop_max_iterations: 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_write_todos: false
|
||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||
@@ -186,7 +185,8 @@ multi_agent:
|
||||
disable: false # true:不注册 skill 渐进式披露中间件,也不挂本机 FS/Shell 工具;false:按下方开关加载
|
||||
filesystem_tools: true # true:注册 read_file/glob/grep/write/edit/execute(授权环境慎用);false:仅 skill,不暴露本机读写与 Shell
|
||||
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:
|
||||
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||
@@ -283,9 +283,17 @@ knowledge:
|
||||
retrieval:
|
||||
top_k: 5 # 检索返回的Top-K结果数量
|
||||
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:
|
||||
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_tokens: 0 # 0 不限制;tiktoken 总 token 上限
|
||||
sub_index_filter: ""
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||
| 机器人 | `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` 至少需一个子代理。 |
|
||||
| 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 )
|
||||
|
||||
@@ -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_prepare.go` — 会话准备(含 WebShell)
|
||||
- `internal/einomcp/` — MCP → Eino Tool
|
||||
@@ -59,4 +60,5 @@
|
||||
| 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-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 保留。 |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
+10
-14
@@ -207,14 +207,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建检索器
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||
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 链)
|
||||
knowledgeIndexer, err = knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, log.Logger, &cfg.Knowledge)
|
||||
@@ -1800,14 +1798,12 @@ func initializeKnowledge(
|
||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建检索器
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||
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 链)
|
||||
knowledgeIndexer, err := knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, logger, &cfg.Knowledge)
|
||||
|
||||
+57
-11
@@ -99,9 +99,6 @@ type MultiAgentConfig struct {
|
||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||
// 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"`
|
||||
// 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 MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
||||
return c.SubAgentUserContextMaxRunes
|
||||
@@ -1485,7 +1477,12 @@ func Default() *Config {
|
||||
},
|
||||
Retrieval: RetrievalConfig{
|
||||
TopK: 5,
|
||||
SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
|
||||
SimilarityThreshold: 0.65,
|
||||
MultiQuery: MultiQueryConfig{MaxQueries: 4},
|
||||
Rerank: RerankConfig{},
|
||||
PostRetrieve: PostRetrieveConfig{
|
||||
PrefetchTopK: 20,
|
||||
},
|
||||
},
|
||||
Indexing: IndexingConfig{
|
||||
ChunkStrategy: "markdown_then_recursive",
|
||||
@@ -1581,7 +1578,7 @@ type EmbeddingConfig struct {
|
||||
|
||||
// PostRetrieveConfig 检索后处理:固定对正文做规范化去重(最佳实践)、上下文预算截断;PrefetchTopK 用于多取候选再收敛到 top_k。
|
||||
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"`
|
||||
// MaxContextChars 返回文档内容总 Unicode 字符数上限(整段 chunk,不截断半段);0 表示不限制。
|
||||
MaxContextChars int `yaml:"max_context_chars,omitempty" json:"max_context_chars,omitempty"`
|
||||
@@ -1589,13 +1586,62 @@ type PostRetrieveConfig struct {
|
||||
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 检索配置
|
||||
type RetrievalConfig struct {
|
||||
TopK int `yaml:"top_k" json:"top_k"` // 检索Top-K
|
||||
SimilarityThreshold float64 `yaml:"similarity_threshold" json:"similarity_threshold"` // 余弦相似度阈值
|
||||
// SubIndexFilter 非空时仅保留 sub_indexes 含该标签(逗号分隔之一)的行;sub_indexes 为空的旧行仍返回。
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -111,19 +111,43 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CountProjects 统计项目数量。
|
||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
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 != "" {
|
||||
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)
|
||||
if pattern := projectListSearchPattern(search); pattern != "" {
|
||||
query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')`
|
||||
args = append(args, pattern, 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
|
||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
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
|
||||
FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if s := strings.TrimSpace(status); s != "" {
|
||||
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, args = appendProjectListFilters(query, args, status, search)
|
||||
query += " ORDER BY pinned DESC, updated_at DESC 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)
|
||||
}
|
||||
}
|
||||
@@ -1432,12 +1432,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
|
||||
// 更新检索器配置(如果知识库启用)
|
||||
if h.config.Knowledge.Enabled && h.retrieverUpdater != nil {
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: h.config.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: h.config.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: h.config.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: h.config.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(h.config.Knowledge.Retrieval)
|
||||
h.retrieverUpdater.UpdateConfig(retrievalConfig)
|
||||
h.logger.Info("检索器配置已更新",
|
||||
zap.Int("top_k", retrievalConfig.TopK),
|
||||
@@ -1720,6 +1715,13 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
||||
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
||||
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")
|
||||
setIntInMap(postNode, "prefetch_top_k", cfg.Retrieval.PostRetrieve.PrefetchTopK)
|
||||
setIntInMap(postNode, "max_context_chars", cfg.Retrieval.PostRetrieve.MaxContextChars)
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
||||
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||
// 用户输入由 message history 承载;压缩后由 summarization 摘要指令保留关键约束。
|
||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
var parts []string
|
||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||
@@ -16,9 +17,6 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||
parts = append(parts, bb)
|
||||
}
|
||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
||||
parts = append(parts, uv)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
@@ -70,29 +68,6 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
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;未绑定或查询失败时返回空字符串。
|
||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain,底层为 SQLite 向量检索([VectorEinoRetriever])。
|
||||
// 去重、上下文预算截断与最终 Top-K 均在 [VectorEinoRetriever.Retrieve] 内完成,与 HTTP/MCP 检索路径一致。
|
||||
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain(MultiQuery → 向量 → 重排 → 后处理)。
|
||||
func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (compose.Runnable[string, []*schema.Document], error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("retriever is nil")
|
||||
|
||||
@@ -11,19 +11,10 @@ import (
|
||||
"github.com/cloudwego/eino/components"
|
||||
"github.com/cloudwego/eino/components/retriever"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// VectorEinoRetriever implements [retriever.Retriever] on top of SQLite-stored embeddings + cosine similarity.
|
||||
//
|
||||
// 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].
|
||||
// It returns prefetch-sized vector candidates only; rerank and post-process run in [knowledgePipelineRetriever].
|
||||
type VectorEinoRetriever struct {
|
||||
inner *Retriever
|
||||
}
|
||||
@@ -119,26 +110,6 @@ func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts .
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 限制单次向量候选上限,避免误配置导致全表扫压力过大。
|
||||
const postRetrieveMaxPrefetchCap = 200
|
||||
|
||||
// DocumentReranker 可选重排(如交叉编码器 / 第三方 Rerank API),由 [Retriever.SetDocumentReranker] 注入;失败时在适配层降级为向量序。
|
||||
// DocumentReranker 精排(HTTP dashscope / Cohere 兼容 API),由 [WireRetrieverPipeline] 注入。
|
||||
type DocumentReranker interface {
|
||||
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
|
||||
}
|
||||
|
||||
// EffectivePrefetchTopK 计算向量检索应拉取的候选条数(供粗排 / 去重 / 重排)。
|
||||
// EffectivePrefetchTopK 计算每条 MultiQuery 变体在向量阶段的候选条数(供融合 / 重排 / 后处理)。
|
||||
func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
||||
if topK < 1 {
|
||||
topK = 5
|
||||
}
|
||||
fetch := topK
|
||||
if po != nil && po.PrefetchTopK > fetch {
|
||||
fetch := topK * 4
|
||||
if fetch < 20 {
|
||||
fetch = 20
|
||||
}
|
||||
if po != nil && po.PrefetchTopK > 0 {
|
||||
fetch = po.PrefetchTopK
|
||||
}
|
||||
if fetch > postRetrieveMaxPrefetchCap {
|
||||
@@ -182,7 +185,7 @@ func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
||||
return fetch
|
||||
}
|
||||
|
||||
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK。重排在 [VectorEinoRetriever] 中单独调用以便失败时降级。
|
||||
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK(精排已在流水线中完成)。
|
||||
func ApplyPostRetrieve(docs []*schema.Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*schema.Document, error) {
|
||||
if finalTopK < 1 {
|
||||
finalTopK = 5
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestDedupeByNormalizedContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEffectivePrefetchTopK(t *testing.T) {
|
||||
if g := EffectivePrefetchTopK(5, nil); g != 5 {
|
||||
t.Fatalf("got %d", g)
|
||||
if g := EffectivePrefetchTopK(5, nil); g != 20 {
|
||||
t.Fatalf("default prefetch got %d want 20", g)
|
||||
}
|
||||
if g := EffectivePrefetchTopK(5, &config.PostRetrieveConfig{PrefetchTopK: 50}); g != 50 {
|
||||
t.Fatalf("got %d", g)
|
||||
|
||||
@@ -27,15 +27,19 @@ type Retriever struct {
|
||||
|
||||
rerankMu sync.RWMutex
|
||||
reranker DocumentReranker
|
||||
|
||||
pipeline retriever.Retriever
|
||||
wireOpenAI *config.OpenAIConfig
|
||||
}
|
||||
|
||||
// RetrievalConfig 检索配置
|
||||
type RetrievalConfig struct {
|
||||
TopK int
|
||||
SimilarityThreshold float64
|
||||
// SubIndexFilter 非空时仅检索 sub_indexes 包含该标签(逗号分隔之一)的行;空 sub_indexes 的旧行仍保留以兼容。
|
||||
SubIndexFilter string
|
||||
PostRetrieve config.PostRetrieveConfig
|
||||
SubIndexFilter string
|
||||
MultiQuery config.MultiQueryConfig
|
||||
Rerank config.RerankConfig
|
||||
PostRetrieve config.PostRetrieveConfig
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if cfg != nil {
|
||||
r.config = cfg
|
||||
@@ -57,12 +61,18 @@ func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
||||
zap.Int("top_k", cfg.TopK),
|
||||
zap.Float64("similarity_threshold", cfg.SimilarityThreshold),
|
||||
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_max_context_chars", cfg.PostRetrieve.MaxContextChars),
|
||||
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 表示禁用。
|
||||
@@ -103,7 +113,7 @@ func cosineSimilarity(a, b []float32) float64 {
|
||||
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) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("请求不能为空")
|
||||
@@ -113,7 +123,7 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
|
||||
return nil, fmt.Errorf("查询不能为空")
|
||||
}
|
||||
opts := r.einoRetrieverOptions(req)
|
||||
docs, err := NewVectorEinoRetriever(r).Retrieve(ctx, q, opts...)
|
||||
docs, err := r.activeEinoRetriever().Retrieve(ctx, q, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,7 +153,19 @@ func (r *Retriever) einoRetrieverOptions(req *SearchRequest) []retriever.Option
|
||||
|
||||
// EinoRetrieve 直接返回 [schema.Document],供 Eino Graph / Chain 使用。
|
||||
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{}) {
|
||||
@@ -299,7 +321,14 @@ func (r *Retriever) vectorSearch(ctx context.Context, req *SearchRequest) ([]*Re
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AsEinoRetriever 将纯向量检索暴露为 Eino [retriever.Retriever]。
|
||||
func (r *Retriever) AsEinoRetriever() retriever.Retriever {
|
||||
return NewVectorEinoRetriever(r)
|
||||
// RetrievalConfigFromYAML maps API/YAML retrieval settings into the knowledge package.
|
||||
func RetrievalConfigFromYAML(r config.RetrievalConfig) *RetrievalConfig {
|
||||
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
|
||||
}
|
||||
@@ -80,34 +80,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
||||
}
|
||||
|
||||
// 组装 executor handler 栈,顺序与 Deep/Supervisor 主代理一致(outermost first)。
|
||||
var execHandlers []adk.ChatModelAgentMiddleware
|
||||
// 1. patchtoolcalls, reduction, toolsearch, plantask(来自 prependEinoMiddlewares)
|
||||
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,
|
||||
})
|
||||
execHandlers, err := buildPlanExecuteExecutorHandlers(ctx, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
||||
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 输入。
|
||||
// 返回 nil 时 Eino 使用内置默认 planner prompt。
|
||||
func planExecutePlannerGenInput(
|
||||
|
||||
@@ -22,15 +22,60 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||
// 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/)。
|
||||
// 触发阈值:估算 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) {
|
||||
summary = stripAnalysisFromSummarizationMessage(summary)
|
||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -414,36 +459,6 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
||||
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 {
|
||||
tc := agent.NewTikTokenCounter()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,7 @@ func RunDeepAgent(
|
||||
ProjectID: projectID,
|
||||
Logger: logger,
|
||||
ModelName: appCfg.OpenAI.Model,
|
||||
// 与 Deep/Supervisor 主代理同源:patch / reduction / toolsearch / plantask(见 buildPlanExecuteExecutorHandlers)。
|
||||
ExecPreMiddlewares: mainOrchestratorPre,
|
||||
SkillMiddleware: einoSkillMW,
|
||||
FilesystemMiddleware: peFsMw,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
+88
-1
@@ -33,6 +33,93 @@
|
||||
--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)
|
||||
============================================================================ */
|
||||
@@ -533,7 +620,7 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
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;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
+2566
-15
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,15 @@
|
||||
"version": "Current version",
|
||||
"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": {
|
||||
"title": "Notifications",
|
||||
"empty": "No new events",
|
||||
@@ -504,6 +513,12 @@
|
||||
"filterByProject": "Filter by project",
|
||||
"filterAllProjects": "All projects",
|
||||
"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",
|
||||
"unboundConversationsTitle": "Unbound conversations",
|
||||
"noProjectConversations": "No conversations in this project",
|
||||
@@ -2172,11 +2187,27 @@
|
||||
"subIndexFilter": "Sub-index filter (optional)",
|
||||
"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).",
|
||||
"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)",
|
||||
"postRetrieveDedupeAuto": "Results are always deduped by normalized text (whitespace-collapsed bodies). No setting required.",
|
||||
"prefetchTopK": "Prefetch candidates (vector stage)",
|
||||
"prefetchTopKPlaceholder": "0",
|
||||
"prefetchTopKHint": "0 = same as Top-K; larger values fetch more vector hits before dedupe/truncate (max 200).",
|
||||
"prefetchTopKPlaceholder": "20",
|
||||
"prefetchTopKHint": "Vector candidates per MultiQuery variant; 0 uses built-in max(top_k×4, 20) (max 200).",
|
||||
"maxContextChars": "Max returned characters (Unicode)",
|
||||
"maxContextCharsPlaceholder": "0",
|
||||
"maxContextCharsHint": "0 = unlimited; keeps whole chunks in rank order until the budget is exceeded.",
|
||||
@@ -2627,6 +2658,7 @@
|
||||
"conversationName": "Conversation name",
|
||||
"project": "Project",
|
||||
"noProject": "No project",
|
||||
"unknownProject": "Unknown project",
|
||||
"filterByProject": "Filter by project",
|
||||
"lastTime": "Last activity",
|
||||
"action": "Action",
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
"version": "当前版本",
|
||||
"toggleSidebar": "折叠/展开侧边栏"
|
||||
},
|
||||
"theme": {
|
||||
"system": "跟随系统",
|
||||
"light": "浅色",
|
||||
"dark": "暗色",
|
||||
"titleSystem": "当前:跟随系统主题。点击切换为浅色。",
|
||||
"titleLight": "当前:浅色主题。点击切换为暗色。",
|
||||
"titleDark": "当前:暗色主题。点击切换为跟随系统。",
|
||||
"toggle": "切换主题"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "事件通知",
|
||||
"empty": "暂无新事件",
|
||||
@@ -492,6 +501,12 @@
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
"filterProjectSearch": "搜索项目…",
|
||||
"filterProjectSearchEmpty": "没有匹配的项目",
|
||||
"filterProjectSearchHint": "输入关键字搜索项目",
|
||||
"filterProjectSearchMore": "更多项目请输入关键字搜索",
|
||||
"filterProjectSearchLoading": "搜索中…",
|
||||
"filterProjectSearchFailed": "加载项目失败,请重试",
|
||||
"projectConversationsTitle": "{{name}} · 对话",
|
||||
"unboundConversationsTitle": "未绑定项目",
|
||||
"noProjectConversations": "该项目暂无对话",
|
||||
@@ -2160,11 +2175,27 @@
|
||||
"subIndexFilter": "子索引过滤(可选)",
|
||||
"subIndexFilterPlaceholder": "如 prod,与索引 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": "检索后处理(去重 / 预算)",
|
||||
"postRetrieveDedupeAuto": "检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。",
|
||||
"prefetchTopK": "预取候选数(向量阶段)",
|
||||
"prefetchTopKPlaceholder": "0",
|
||||
"prefetchTopKHint": "0 表示与 Top-K 相同;大于 Top-K 时先多取候选再经去重/截断回到 Top-K(上限 200)。",
|
||||
"prefetchTopKPlaceholder": "20",
|
||||
"prefetchTopKHint": "每条 MultiQuery 变体的向量候选数;0 表示内置 max(top_k×4, 20)(上限 200)。",
|
||||
"maxContextChars": "返回内容最大字符数(Unicode)",
|
||||
"maxContextCharsPlaceholder": "0",
|
||||
"maxContextCharsHint": "0 表示不限制;按检索顺序整段保留 chunk,超出则丢弃后续。",
|
||||
@@ -2615,6 +2646,7 @@
|
||||
"conversationName": "对话名称",
|
||||
"project": "项目",
|
||||
"noProject": "无项目",
|
||||
"unknownProject": "未知项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"lastTime": "最近一次对话时间",
|
||||
"action": "操作",
|
||||
|
||||
+264
-106
@@ -4210,6 +4210,7 @@ function renderAttackChain(chainData) {
|
||||
const nodeCount = chainData.nodes.length;
|
||||
const edgeCount = chainData.edges.length;
|
||||
const isComplexGraph = nodeCount > 15 || edgeCount > 25;
|
||||
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
|
||||
// 优化节点标签:智能截断和换行
|
||||
chainData.nodes.forEach(node => {
|
||||
@@ -4313,6 +4314,29 @@ function renderAttackChain(chainData) {
|
||||
iconType = 'vulnerability';
|
||||
}
|
||||
|
||||
const labelTextColor = isDarkTheme ? '#E5E7EB' : '#0F172A';
|
||||
if (isDarkTheme) {
|
||||
typeColor = '#E5E7EB';
|
||||
bgGradientStart = '#111827';
|
||||
if (nodeType === 'target') {
|
||||
bgGradientEnd = '#1E1B4B';
|
||||
} else if (nodeType === 'action') {
|
||||
bgGradientEnd = accentColor === '#10B981' ? '#052E2B' : '#172033';
|
||||
} else if (nodeType === 'vulnerability') {
|
||||
if (riskScore >= 80) {
|
||||
bgGradientEnd = '#3F101C';
|
||||
} else if (riskScore >= 60) {
|
||||
bgGradientEnd = '#3B1D0D';
|
||||
} else if (riskScore >= 40) {
|
||||
bgGradientEnd = '#3A2A0A';
|
||||
} else {
|
||||
bgGradientEnd = '#063A36';
|
||||
}
|
||||
} else {
|
||||
bgGradientEnd = '#172033';
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个节点生成图标 background-image(data URL)
|
||||
const iconSvg = _acBuildNodeIconDataUrl(iconType, accentColor, accentDark);
|
||||
|
||||
@@ -4345,6 +4369,7 @@ function renderAttackChain(chainData) {
|
||||
accentDark: accentDark,
|
||||
bgGradientStart: bgGradientStart,
|
||||
bgGradientEnd: bgGradientEnd,
|
||||
labelTextColor: labelTextColor,
|
||||
iconDataUrl: iconSvg,
|
||||
badgeText: badgeText,
|
||||
riskScore: riskScore,
|
||||
@@ -4444,7 +4469,9 @@ function renderAttackChain(chainData) {
|
||||
},
|
||||
'border-opacity': 0.5,
|
||||
// 文字样式
|
||||
'color': '#0f172a',
|
||||
'color': function(ele) {
|
||||
return ele.data('labelTextColor') || '#0f172a';
|
||||
},
|
||||
'font-size': function(ele) {
|
||||
return isComplexGraph ? '13px' : '14px';
|
||||
},
|
||||
@@ -5048,7 +5075,7 @@ function showNodeDetails(nodeData) {
|
||||
if (nodeData.metadata.ai_analysis) {
|
||||
html += `
|
||||
<div class="node-detail-item">
|
||||
<strong>AI分析:</strong> <div style="margin-top: 5px; padding: 8px; background: #f5f5f5; border-radius: 4px;">${escapeHtml(nodeData.metadata.ai_analysis)}</div>
|
||||
<strong>AI分析:</strong> <div class="node-detail-ai-analysis">${escapeHtml(nodeData.metadata.ai_analysis)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -6169,49 +6196,183 @@ const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter';
|
||||
const projectFilterCustomSelectRegistry = {};
|
||||
let projectFilterCustomSelectDocBound = false;
|
||||
|
||||
function projectFilterT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
const value = window.t(key);
|
||||
if (value && value !== key) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function closeProjectFilterCustomSelect(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (reg.filterSearchTimer) {
|
||||
clearTimeout(reg.filterSearchTimer);
|
||||
reg.filterSearchTimer = null;
|
||||
}
|
||||
reg.filterSearchSeq = (reg.filterSearchSeq || 0) + 1;
|
||||
if (reg.searchInput) reg.searchInput.value = '';
|
||||
}
|
||||
|
||||
function closeAllProjectFilterCustomSelects() {
|
||||
Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect);
|
||||
}
|
||||
|
||||
function ensureProjectFilterSearchUi(reg) {
|
||||
if (reg.searchInput && reg.optionsList) return;
|
||||
const { dropdown } = reg;
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'conversation-project-filter-search';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.className = 'conversation-project-filter-search-input';
|
||||
searchInput.setAttribute('autocomplete', 'off');
|
||||
searchInput.setAttribute('data-i18n', 'chat.filterProjectSearch');
|
||||
searchInput.setAttribute('data-i18n-attr', 'placeholder');
|
||||
searchInput.placeholder = projectFilterT('chat.filterProjectSearch', '搜索项目…');
|
||||
searchWrap.appendChild(searchInput);
|
||||
dropdown.appendChild(searchWrap);
|
||||
reg.searchInput = searchInput;
|
||||
|
||||
const optionsList = document.createElement('div');
|
||||
optionsList.className = 'conversation-project-filter-options';
|
||||
dropdown.appendChild(optionsList);
|
||||
reg.optionsList = optionsList;
|
||||
reg.filterSearchSeq = 0;
|
||||
reg.filterSearchTimer = null;
|
||||
|
||||
searchInput.addEventListener('input', () => loadProjectFilterLocalOptions(reg.select.id));
|
||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') closeProjectFilterCustomSelect(reg.select.id);
|
||||
});
|
||||
}
|
||||
|
||||
function createProjectFilterOptionButton(value, label, selectedValue) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'conversation-project-filter-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', value);
|
||||
item.title = label;
|
||||
if (value === selectedValue) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'conversation-project-filter-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'conversation-project-filter-option-label';
|
||||
labelEl.textContent = label;
|
||||
labelEl.title = label;
|
||||
item.appendChild(check);
|
||||
item.appendChild(labelEl);
|
||||
return item;
|
||||
}
|
||||
|
||||
function appendProjectFilterStatusMessage(optionsList, className, text) {
|
||||
const el = document.createElement('div');
|
||||
el.className = className;
|
||||
el.textContent = text;
|
||||
optionsList.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderProjectFilterPinnedOptions(reg) {
|
||||
const { select, optionsList } = reg;
|
||||
optionsList.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, (opt) => {
|
||||
if (opt.value === '' || opt.value === CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
optionsList.appendChild(createProjectFilterOptionButton(opt.value, opt.textContent || '', select.value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureNativeProjectFilterOption(select, projectId, label) {
|
||||
if (!projectId || projectId === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||
if (Array.prototype.some.call(select.options, (opt) => opt.value === projectId)) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = projectId;
|
||||
opt.textContent = label || projectId;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
async function loadProjectFilterLocalOptions(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg || !reg.optionsList) return;
|
||||
const query = (reg.searchInput?.value || '').trim();
|
||||
const seq = ++reg.filterSearchSeq;
|
||||
|
||||
const needsFetch = typeof window.isProjectsCacheReady === 'function' && !window.isProjectsCacheReady();
|
||||
let loadingEl = null;
|
||||
if (needsFetch) {
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
loadingEl = appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-status',
|
||||
projectFilterT('common.loading', '加载中…')
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const ensureLoaded = typeof window.ensureProjectsLoaded === 'function'
|
||||
? window.ensureProjectsLoaded
|
||||
: null;
|
||||
const filterLocal = typeof window.filterActiveProjectsLocal === 'function'
|
||||
? window.filterActiveProjectsLocal
|
||||
: null;
|
||||
if (!ensureLoaded || !filterLocal) throw new Error('projects cache unavailable');
|
||||
|
||||
const all = await ensureLoaded();
|
||||
if (seq !== reg.filterSearchSeq) return;
|
||||
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
const selected = reg.select.value;
|
||||
const pinnedValues = new Set(['', CONVERSATION_PROJECT_FILTER_NONE]);
|
||||
const projects = filterLocal(all, query);
|
||||
projects.forEach((p) => {
|
||||
if (pinnedValues.has(p.id)) return;
|
||||
reg.optionsList.appendChild(
|
||||
createProjectFilterOptionButton(p.id, p.name || p.id, selected)
|
||||
);
|
||||
});
|
||||
|
||||
if (query && projects.length === 0) {
|
||||
appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-empty',
|
||||
projectFilterT('chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== reg.filterSearchSeq) return;
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-empty',
|
||||
projectFilterT('chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||
);
|
||||
} finally {
|
||||
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function syncProjectFilterCustomSelect(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg) return;
|
||||
const { select, dropdown, trigger } = reg;
|
||||
ensureProjectFilterSearchUi(reg);
|
||||
const { select, trigger } = reg;
|
||||
const valueSpan = trigger.querySelector('.conversation-project-filter-value');
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, (opt) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'conversation-project-filter-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
const labelText = opt.textContent || '';
|
||||
item.title = labelText;
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'conversation-project-filter-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'conversation-project-filter-option-label';
|
||||
label.textContent = labelText;
|
||||
label.title = labelText;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
const selectedOpt = select.options[select.selectedIndex];
|
||||
const selectedText = selectedOpt ? (selectedOpt.textContent || '') : '';
|
||||
if (valueSpan) {
|
||||
@@ -6264,6 +6425,13 @@ function initProjectFilterCustomSelect(selectId) {
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
ensureProjectFilterSearchUi(projectFilterCustomSelectRegistry[selectId]);
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (reg?.searchInput) {
|
||||
reg.searchInput.value = '';
|
||||
loadProjectFilterLocalOptions(selectId);
|
||||
requestAnimationFrame(() => reg.searchInput.focus());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6273,6 +6441,8 @@ function initProjectFilterCustomSelect(selectId) {
|
||||
e.stopPropagation();
|
||||
const val = opt.getAttribute('data-value');
|
||||
if (val === null) return;
|
||||
const label = opt.querySelector('.conversation-project-filter-option-label')?.textContent || val;
|
||||
ensureNativeProjectFilterOption(select, val, label);
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
@@ -6319,38 +6489,7 @@ function setConversationProjectFilter(projectId) {
|
||||
updateConversationSidebarFilterUI();
|
||||
}
|
||||
|
||||
function isValidConversationProjectFilter(projectId) {
|
||||
if (!projectId) return true;
|
||||
if (projectId === CONVERSATION_PROJECT_FILTER_NONE) return true;
|
||||
const map = window.projectNameById;
|
||||
if (!map || typeof map !== 'object') return true;
|
||||
return Object.prototype.hasOwnProperty.call(map, projectId);
|
||||
}
|
||||
|
||||
async function refreshConversationProjectFilter() {
|
||||
const sel = document.getElementById('conversation-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = getConversationProjectFilter();
|
||||
let projects = [];
|
||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
||||
try {
|
||||
const list = await window.ensureProjectsLoaded();
|
||||
projects = (list || []).filter((p) => p && p.id && p.status !== 'archived');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!projects.length) {
|
||||
try {
|
||||
const res = await apiFetch('/api/projects?status=active&limit=200');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = data.projects || data.items || (Array.isArray(data) ? data : []);
|
||||
projects = items.filter((p) => p && p.id);
|
||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
||||
window.rebuildProjectNameMap(items);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
function appendProjectFilterPinnedNativeOptions(sel) {
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
||||
@@ -6365,16 +6504,44 @@ async function refreshConversationProjectFilter() {
|
||||
unboundOpt.textContent = unboundLabel;
|
||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
||||
sel.appendChild(unboundOpt);
|
||||
projects
|
||||
.slice()
|
||||
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '', undefined, { sensitivity: 'base' }))
|
||||
.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name || p.id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const normalized = isValidConversationProjectFilter(saved) ? saved : '';
|
||||
}
|
||||
|
||||
async function resolveProjectFilterSelection(projectId) {
|
||||
const saved = (projectId || '').trim();
|
||||
if (!saved || saved === CONVERSATION_PROJECT_FILTER_NONE) return saved;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
if (!fetchSummary) return saved;
|
||||
const project = await fetchSummary(saved);
|
||||
if (!project || !project.id || project.status === 'archived') return '';
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async function appendSelectedProjectFilterOption(sel, projectId) {
|
||||
const id = (projectId || '').trim();
|
||||
if (!id || id === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||
if (Array.prototype.some.call(sel.options, (opt) => opt.value === id)) return;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
const project = fetchSummary ? await fetchSummary(id) : null;
|
||||
const label = (project && (project.name || project.id)) || (window.projectNameById && window.projectNameById[id]) || id;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = label;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
async function refreshConversationProjectFilter() {
|
||||
const sel = document.getElementById('conversation-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = getConversationProjectFilter();
|
||||
appendProjectFilterPinnedNativeOptions(sel);
|
||||
const normalized = await resolveProjectFilterSelection(saved);
|
||||
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
await appendSelectedProjectFilterOption(sel, normalized);
|
||||
}
|
||||
if (normalized !== saved) setConversationProjectFilter(normalized);
|
||||
sel.value = normalized;
|
||||
syncConversationProjectCustomSelect();
|
||||
@@ -8249,47 +8416,37 @@ function getConversationProjectLabel(conv) {
|
||||
if (!pid) {
|
||||
return typeof window.t === 'function' ? window.t('batchManageModal.noProject') : '无项目';
|
||||
}
|
||||
return (window.projectNameById && window.projectNameById[pid]) || pid;
|
||||
const name = window.projectNameById && window.projectNameById[pid];
|
||||
if (name) return name;
|
||||
return typeof window.t === 'function' ? window.t('batchManageModal.unknownProject') : '未知项目';
|
||||
}
|
||||
|
||||
async function prefetchProjectNamesForConversations(conversations) {
|
||||
const missing = new Set();
|
||||
for (const conv of conversations || []) {
|
||||
const pid = getConversationProjectId(conv);
|
||||
if (pid && !(window.projectNameById && window.projectNameById[pid])) {
|
||||
missing.add(pid);
|
||||
}
|
||||
}
|
||||
if (!missing.size) return;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
if (!fetchSummary) return;
|
||||
await Promise.all([...missing].map((id) => fetchSummary(id).catch(() => null)));
|
||||
}
|
||||
|
||||
async function refreshBatchProjectFilter() {
|
||||
const sel = document.getElementById('batch-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = sel.value || '';
|
||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
||||
try {
|
||||
await window.ensureProjectsLoaded();
|
||||
} catch (e) { /* ignore */ }
|
||||
appendProjectFilterPinnedNativeOptions(sel);
|
||||
const normalized = await resolveProjectFilterSelection(saved);
|
||||
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
await appendSelectedProjectFilterOption(sel, normalized);
|
||||
}
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
||||
sel.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = allLabel;
|
||||
allOpt.setAttribute('data-i18n', 'chat.filterAllProjects');
|
||||
sel.appendChild(allOpt);
|
||||
const unboundOpt = document.createElement('option');
|
||||
unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE;
|
||||
unboundOpt.textContent = unboundLabel;
|
||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
||||
sel.appendChild(unboundOpt);
|
||||
const source = window.projectNameById ? Object.keys(window.projectNameById) : [];
|
||||
source
|
||||
.sort((a, b) => {
|
||||
const na = (window.projectNameById[a] || a).toLowerCase();
|
||||
const nb = (window.projectNameById[b] || b).toLowerCase();
|
||||
return na.localeCompare(nb);
|
||||
})
|
||||
.forEach((id) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = window.projectNameById[id] || id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const valid = !saved || saved === CONVERSATION_PROJECT_FILTER_NONE || (window.projectNameById && window.projectNameById[saved]);
|
||||
sel.value = valid ? saved : '';
|
||||
sel.value = normalized;
|
||||
syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
}
|
||||
|
||||
@@ -8331,6 +8488,7 @@ async function showBatchManageModal() {
|
||||
try {
|
||||
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
allConversationsForBatch = await fetchAllConversations('');
|
||||
await prefetchProjectNamesForConversations(allConversationsForBatch);
|
||||
await refreshBatchProjectFilter();
|
||||
const sidebarFilter = getConversationProjectFilter();
|
||||
const batchSel = document.getElementById('batch-project-filter');
|
||||
|
||||
+57
-19
@@ -1707,7 +1707,7 @@ window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter
|
||||
|
||||
// 漏洞严重程度分布:半环形(donut)渲染
|
||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
||||
// 段间分隔由 gapRad 几何间隙完成,不使用描边,避免浅色/暗色下白边或黑边过重
|
||||
var SEVERITY_DONUT_CFG = {
|
||||
// viewBox 0 0 480 260:整体保持紧凑,但环厚回到「黄金比例」附近,
|
||||
// 让弧带本身有视觉分量,又不像最早那版那样占太多空间。
|
||||
@@ -1717,7 +1717,7 @@ var SEVERITY_DONUT_CFG = {
|
||||
rOuter: 165,
|
||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||
labelOffset: 14,
|
||||
gapRad: 0.012
|
||||
gapRad: 0.022
|
||||
};
|
||||
|
||||
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
||||
@@ -1759,28 +1759,66 @@ function severityLabel(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() {
|
||||
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
||||
if (!defsEl || defsEl.hasChildNodes()) return;
|
||||
if (!defsEl) return;
|
||||
var dark = isDashboardDarkTheme();
|
||||
var html = '';
|
||||
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
||||
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||
if (dark) {
|
||||
html += '<stop offset="0%" stop-color="#334155"/>';
|
||||
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 += '<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"/>';
|
||||
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
if (dark) {
|
||||
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 id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
||||
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"/>';
|
||||
if (dark) {
|
||||
html += '<stop offset="0%" stop-color="#94a3b8" stop-opacity="0.10"/>';
|
||||
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 += '<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 += '<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 += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
||||
html += '</filter>';
|
||||
@@ -1808,17 +1846,17 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
severityDonutState.total = total || 0;
|
||||
severityDonutState.hoverId = null;
|
||||
|
||||
ensureSeverityDonutThemeObserver();
|
||||
|
||||
var cfg = SEVERITY_DONUT_CFG;
|
||||
ensureSeverityDonutDefs();
|
||||
|
||||
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
||||
if (!trackEl.hasChildNodes()) {
|
||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||
trackEl.innerHTML =
|
||||
'<path class="donut-track-shadow" 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 + '"/>';
|
||||
}
|
||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||
trackEl.innerHTML =
|
||||
'<path class="donut-track-shadow" 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 + '"/>';
|
||||
|
||||
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
var severities = ids.map(function (id) {
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
}
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||
window.refreshThemeToggleLabel();
|
||||
}
|
||||
try {
|
||||
window.__locale = lang;
|
||||
} catch (e) { /* ignore */ }
|
||||
@@ -180,6 +183,9 @@
|
||||
await loadLanguageResources(initialLang);
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||
window.refreshThemeToggleLabel();
|
||||
}
|
||||
try {
|
||||
window.__locale = i18next.language || initialLang;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
+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() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||
@@ -308,7 +367,15 @@ async function ensureProjectsLoaded(force) {
|
||||
return _projectsFetchPromise;
|
||||
}
|
||||
|
||||
function isProjectsCacheReady() {
|
||||
return _projectsListReady;
|
||||
}
|
||||
|
||||
function prefetchProjectsForChat() {
|
||||
const id = (resolveChatProjectSelection() || '').trim();
|
||||
if (id && !projectNameById[id]) {
|
||||
fetchProjectSummary(id).catch(() => {});
|
||||
}
|
||||
ensureProjectsLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -655,9 +722,12 @@ function updateProjectStatusPill(status) {
|
||||
|
||||
function renderProjectDetailMeta(updatedAt) {
|
||||
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);
|
||||
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||
const full = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||
timeEl.textContent = time;
|
||||
metaEl.title = full;
|
||||
}
|
||||
|
||||
function refreshProjectDetailMetaI18n() {
|
||||
@@ -2032,27 +2102,20 @@ function getChatProjectSelection() {
|
||||
return getActiveProjectId();
|
||||
}
|
||||
|
||||
function isActiveChatProjectId(id) {
|
||||
if (!id) return false;
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
return source.some((p) => p.id === id && p.status !== 'archived');
|
||||
}
|
||||
|
||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
||||
/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */
|
||||
function resolveChatProjectSelection() {
|
||||
const raw = getChatProjectSelection();
|
||||
if (!raw) return '';
|
||||
if (!_projectsListReady) return raw;
|
||||
return isActiveChatProjectId(raw) ? raw : '';
|
||||
return getChatProjectSelection() || '';
|
||||
}
|
||||
|
||||
let _normalizingStaleProject = false;
|
||||
|
||||
/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */
|
||||
/** 清除 localStorage 或对话上残留的失效项目 ID */
|
||||
async function normalizeStaleChatProjectSelection() {
|
||||
if (!_projectsListReady || _normalizingStaleProject) return;
|
||||
const raw = getChatProjectSelection();
|
||||
if (!raw || isActiveChatProjectId(raw)) return;
|
||||
if (_normalizingStaleProject) return;
|
||||
const raw = (getChatProjectSelection() || '').trim();
|
||||
if (!raw) return;
|
||||
const project = await fetchProjectSummary(raw);
|
||||
if (project && project.id && project.status !== 'archived') return;
|
||||
|
||||
_normalizingStaleProject = true;
|
||||
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() {
|
||||
const textEl = document.getElementById('chat-project-text');
|
||||
if (!textEl) return;
|
||||
@@ -2086,56 +2314,13 @@ function updateChatProjectButtonLabel() {
|
||||
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() {
|
||||
const list = document.getElementById('chat-project-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = `<div class="chat-project-panel-loading">${escapeHtml(tp('common.loading'))}</div>`;
|
||||
try {
|
||||
await ensureProjectsLoaded();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.loadFailedRetry'))}</div>`;
|
||||
return;
|
||||
}
|
||||
renderChatProjectPanelList();
|
||||
initProjectPickerPanelSearch('chat', 'chat-project-search', () => {
|
||||
scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList());
|
||||
});
|
||||
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||
await loadChatProjectPanelList();
|
||||
requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus());
|
||||
}
|
||||
|
||||
function closeChatProjectPanel() {
|
||||
@@ -2146,6 +2331,7 @@ function closeChatProjectPanel() {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||
}
|
||||
|
||||
async function toggleChatProjectPanel() {
|
||||
@@ -2213,15 +2399,14 @@ async function applyChatProjectSelection(projectId) {
|
||||
async function refreshChatProjectSelector() {
|
||||
if (!document.getElementById('chat-project-btn')) return;
|
||||
try {
|
||||
await ensureProjectsLoaded();
|
||||
await normalizeStaleChatProjectSelection();
|
||||
await ensureChatProjectButtonLabel();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
updateChatProjectButtonLabel();
|
||||
const panel = document.getElementById('chat-project-panel');
|
||||
if (panel && panel.style.display === 'flex') {
|
||||
renderChatProjectPanelList();
|
||||
await loadChatProjectPanelList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2240,7 +2425,7 @@ function initChatProjectSelector() {
|
||||
renderProjectsPagination();
|
||||
updateChatProjectButtonLabel();
|
||||
const panel = document.getElementById('chat-project-panel');
|
||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
||||
if (panel && panel.style.display === 'flex') loadChatProjectPanelList();
|
||||
if (currentProjectId) {
|
||||
refreshProjectDetailMetaI18n();
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
@@ -2298,6 +2483,11 @@ window.onChatProjectChange = onChatProjectChange;
|
||||
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||
window.closeChatProjectPanel = closeChatProjectPanel;
|
||||
window.selectChatProject = selectChatProject;
|
||||
window.renderProjectPickerPanel = renderProjectPickerPanel;
|
||||
window.initProjectPickerPanelSearch = initProjectPickerPanelSearch;
|
||||
window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch;
|
||||
window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch;
|
||||
window.loadChatProjectPanelList = loadChatProjectPanelList;
|
||||
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
||||
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
||||
window.getActiveProjectId = getActiveProjectId;
|
||||
@@ -2334,5 +2524,10 @@ window.deleteProjectFactEdge = deleteProjectFactEdge;
|
||||
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.rememberProjectsInNameMap = rememberProjectsInNameMap;
|
||||
window.searchActiveProjects = searchActiveProjects;
|
||||
window.filterActiveProjectsLocal = filterActiveProjectsLocal;
|
||||
window.fetchProjectSummary = fetchProjectSummary;
|
||||
window.projectNameById = projectNameById;
|
||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||
window.isProjectsCacheReady = isProjectsCacheReady;
|
||||
|
||||
@@ -389,10 +389,35 @@ async function loadConfig(loadTools = true) {
|
||||
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 prefetchInput = document.getElementById('knowledge-post-retrieve-prefetch-top-k');
|
||||
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');
|
||||
if (maxCharsInput) {
|
||||
@@ -1273,8 +1298,25 @@ async function applySettings() {
|
||||
return isNaN(val) ? 0.7 : val;
|
||||
})(),
|
||||
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: {
|
||||
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_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: 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>
|
||||
<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 class="modal-footer">
|
||||
<button type="button" class="btn-secondary" data-skill-load-full>${escapeHtml(loadFullLabel)}</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!conn || !conn.id) return '';
|
||||
return webshellAiConvMap[conn.id] || '';
|
||||
@@ -409,51 +423,32 @@ function wsUpdateProjectButtonLabel() {
|
||||
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanelList() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list || !webshellCurrentConn) return;
|
||||
var conn = webshellCurrentConn;
|
||||
var selected = wsResolveWebshellAiProjectSelection(conn);
|
||||
var projects = [];
|
||||
try {
|
||||
if (typeof window.fetchAllProjects === 'function') {
|
||||
projects = await window.fetchAllProjects(false);
|
||||
}
|
||||
} 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 wsLoadProjectPanelList() {
|
||||
if (typeof window.renderProjectPickerPanel !== 'function') return;
|
||||
await window.renderProjectPickerPanel('webshell', {
|
||||
listId: 'ws-project-list',
|
||||
searchInputId: 'ws-project-search',
|
||||
getSelectedId: function () {
|
||||
return webshellCurrentConn ? wsResolveWebshellAiProjectSelection(webshellCurrentConn) : '';
|
||||
},
|
||||
onSelect: function (projectId) { wsSelectProject(projectId); },
|
||||
t: wsProjectPickerT,
|
||||
});
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanel() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
||||
await wsRenderProjectPanelList();
|
||||
if (typeof window.initProjectPickerPanelSearch === 'function') {
|
||||
window.initProjectPickerPanelSearch('webshell', 'ws-project-search', function () {
|
||||
if (typeof window.scheduleProjectPickerPanelSearch === 'function') {
|
||||
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() {
|
||||
@@ -464,6 +459,9 @@ function wsCloseProjectPanel() {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
if (typeof window.clearProjectPickerPanelSearch === 'function') {
|
||||
window.clearProjectPickerPanelSearch('webshell', 'ws-project-search');
|
||||
}
|
||||
}
|
||||
|
||||
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>' +
|
||||
'</div>' +
|
||||
'<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 class="chat-project-panel-footer">' +
|
||||
'<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>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<style>
|
||||
/* 覆盖主CSS的overflow限制,允许API文档页面滚动 */
|
||||
body {
|
||||
@@ -25,8 +26,23 @@
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
padding-right: 280px;
|
||||
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 {
|
||||
font-size: 2rem;
|
||||
@@ -822,6 +838,114 @@
|
||||
.empty-state p {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -837,7 +961,21 @@
|
||||
<span data-i18n="apiDocs.title">API 文档</span>
|
||||
</h1>
|
||||
<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">
|
||||
<button type="button" class="btn-secondary lang-switcher-btn" onclick="typeof toggleLangDropdown === 'function' && toggleLangDropdown()" title="界面语言">
|
||||
<span class="lang-switcher-icon">🌐</span>
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
<title>CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<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/c2.css">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
@@ -57,12 +74,26 @@
|
||||
</svg>
|
||||
<span data-i18n="header.apiDocs">API 文档</span>
|
||||
</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">
|
||||
<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>
|
||||
<span data-i18n="header.github">GitHub</span>
|
||||
</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">
|
||||
<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>
|
||||
@@ -1052,6 +1083,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<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 class="chat-project-panel-footer">
|
||||
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
|
||||
@@ -1710,10 +1744,13 @@
|
||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
||||
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
||||
</div>
|
||||
<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-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
||||
</div>
|
||||
@@ -3003,14 +3040,46 @@
|
||||
<small class="form-hint" data-i18n="settingsBasic.subIndexFilterHint">留空表示不过滤;非空时仅检索 sub_indexes 含该标签的向量行(未打标旧数据仍会命中)。</small>
|
||||
</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">
|
||||
<h5 data-i18n="settingsBasic.postRetrieveHeader">检索后处理(去重 / 预算)</h5>
|
||||
</div>
|
||||
<p class="form-hint" style="margin: 0 0 12px 0;" data-i18n="settingsBasic.postRetrieveDedupeAuto">检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。</p>
|
||||
<div class="form-group">
|
||||
<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" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.prefetchTopKHint">0 表示与 Top-K 相同;大于 Top-K 时先多取候选再经去重/截断回到 Top-K(上限 200)。</small>
|
||||
<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">每条 MultiQuery 变体的向量候选数;0 表示内置 max(top_k×4, 20)(上限 200)。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="knowledge-post-retrieve-max-chars" data-i18n="settingsBasic.maxContextChars">返回内容最大字符数(Unicode)</label>
|
||||
@@ -4668,6 +4737,7 @@
|
||||
|
||||
<script src="/static/vendor/i18next.min.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/auth.js"></script>
|
||||
<script src="/static/js/modal.js"></script>
|
||||
@@ -4698,4 +4768,3 @@
|
||||
<script src="/static/js/c2.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user