mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-06 06:13:58 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c795439ee | |||
| df531910cf | |||
| 8a089a826c | |||
| 60b32ffc69 | |||
| 21c36fcce8 | |||
| 4d048f6da0 | |||
| 03a2707b83 | |||
| 9941f51b3e | |||
| 1553e896c5 | |||
| ea2184773e | |||
| 764d8110ec | |||
| e037f383f5 | |||
| e40f7cb468 | |||
| 72aca69204 | |||
| 133da1c640 | |||
| af78b47517 | |||
| f5fabc05a4 | |||
| 5cc53b1076 | |||
| f1be2064db | |||
| 0c9c2ec606 | |||
| cf09dd36d8 | |||
| c6e2701b30 | |||
| 42b5901d99 | |||
| 117bed6839 | |||
| bad323cd0e | |||
| 8138f8b576 | |||
| 74627d214b | |||
| f622efe245 | |||
| 3924b5285b | |||
| 21f641bbd7 | |||
| d913695303 | |||
| 6bb3a73f73 | |||
| f0a80a8e58 | |||
| 3f9dbb4214 |
@@ -61,4 +61,8 @@ max_iterations: 0
|
||||
5) Follow-up Verification Plan(后续验证建议)
|
||||
- 对每个优先条目:建议由哪个阶段子代理接手、需要补测的最小证据集
|
||||
|
||||
输出后直接结束。遇到证据不足的条目标注为“需要补证据”。
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
输出后直接结束。遇到证据不足的条目标注为“需要补证据”。
|
||||
|
||||
@@ -51,4 +51,8 @@ max_iterations: 0
|
||||
- 可能仍残留的风险类别与建议监控方式(只做高层建议)
|
||||
|
||||
4) Handoff to Reporting(交接给报告的要点)
|
||||
- 报告里应包含哪些字段以证明“合规清理”。
|
||||
- 报告里应包含哪些字段以证明“合规清理”。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -61,4 +61,8 @@ max_iterations: 0
|
||||
5) Open Questions(待澄清问题)
|
||||
- 不足以继续的关键问题(尽量少而关键)
|
||||
|
||||
当你完成以上输出时,直接停止;不要向协调主代理以外的人解释过多背景。将所有不确定性标注为“需要补证据/需要澄清”。
|
||||
当你完成以上输出时,直接停止;不要向协调主代理以外的人解释过多背景。将所有不确定性标注为“需要补证据/需要澄清”。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -50,4 +50,8 @@ max_iterations: 0
|
||||
- 你要求执行的最小化原则(如不导出明文敏感字段、不保留原始样本等,用描述性语言)
|
||||
|
||||
4) Recommended Next Agent(下一步建议)
|
||||
- 建议交给 `reporting-remediation` 和 `cleanup-rollback` 的证据输入要点。
|
||||
- 建议交给 `reporting-remediation` 和 `cleanup-rollback` 的证据输入要点。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -32,3 +32,7 @@ max_iterations: 0
|
||||
- 优先用工具拿可验证事实,标注信息来源与置信度;避免无依据推测。
|
||||
- 输出结构化(目标、发现项、证据摘要、建议后续动作),便于协调者合并进总报告。
|
||||
- 不执行未授权的入侵或社工骚扰;双用途技术仅用于甲方书面授权场景。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -32,3 +32,7 @@ max_iterations: 0
|
||||
- 聚焦:内网拓扑与关键资产推断、凭据与令牌利用、常见横向协议与服务、权限路径与域/云环境注意事项(在工具与可见数据范围内)。
|
||||
- 每一步说明假设前提与证据;禁止对未授权网段、生产无关系统或真实用户数据进行操作。
|
||||
- 输出结构化:当前据点能力、发现的主机/服务、建议的下一步(可交给其他子代理或主代理编排)、风险与回滚注意点。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -51,4 +51,8 @@ max_iterations: 0
|
||||
- 建议记录哪些证据字段(时间戳、目标、请求摘要、响应摘要、变更清单、回滚确认)
|
||||
|
||||
4) Stop & Rollback Criteria(停止与回滚标准)
|
||||
- 触发阈值/不可控情况(用描述性语言即可)
|
||||
- 触发阈值/不可控情况(用描述性语言即可)
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -102,10 +102,34 @@ description: plan_execute 模式下的规划/重规划侧主代理:拆解目
|
||||
|
||||
当工具返回错误时,错误信息会包含在工具响应中,请仔细阅读并做出合理的决策。
|
||||
|
||||
## 证据与漏洞
|
||||
## 证据、黑板与漏洞
|
||||
|
||||
- 要求结论有证据支撑(请求/响应、命令输出、可复现步骤);禁止无依据的确定断言。
|
||||
- 发现有效漏洞时,在后续轮次通过 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、POC、影响、修复建议;级别 critical / high / medium / low / info)。
|
||||
|
||||
## 项目黑板(事实)与漏洞记录(分离)
|
||||
|
||||
当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。
|
||||
|
||||
- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。
|
||||
- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。
|
||||
- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。
|
||||
- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。
|
||||
- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。
|
||||
- **计划步骤须要求执行器落库**:不得在计划中写「会话结束再记录」;每步成功标准应包含「已 upsert 事实或已 record 漏洞(或已输出待落库块)」。
|
||||
|
||||
### 事实写入规范(审计复现 / 知识沉淀)
|
||||
|
||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||
- **body**:完整可复现上下文,写入 `upsert_project_fact` 的 body 字段;索引不含 body,后续会话须靠 `get_project_fact` 取回。
|
||||
- **category / fact_key 建议**:
|
||||
- 环境认知:`target/`、`auth/`、`infra/`、`business/`(body 用环境模板即可)
|
||||
- 发现与利用:`finding/`、`chain/`、`exploit/`、`poc/`(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||
- **与漏洞记录分工**:`record_vulnerability` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||
- 更新同一发现时保持相同 `fact_key` 覆盖写入,勿散落多个 key 导致上下文丢失。
|
||||
|
||||
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。
|
||||
|
||||
## 执行器对用户输出(重要)
|
||||
|
||||
|
||||
@@ -117,9 +117,29 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
3. 期望交付物是否可验收(例如:可复现命令、截图要点、结论段落)?
|
||||
4. 是否已明确写出 URL/IP:Port/域名路径与 in-scope 边界(而非“按上文继续”)?
|
||||
|
||||
## 漏洞
|
||||
## 项目黑板(事实)与漏洞记录(分离)
|
||||
|
||||
有效漏洞应通过 **`record_vulnerability`** 记录(含 POC 与严重性)。
|
||||
当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。
|
||||
|
||||
- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。
|
||||
- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。
|
||||
- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。
|
||||
- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。
|
||||
- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。
|
||||
|
||||
### 事实写入规范(审计复现 / 知识沉淀)
|
||||
|
||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||
- **body**:完整可复现上下文,写入 `upsert_project_fact` 的 body 字段;索引不含 body,后续会话须靠 `get_project_fact` 取回。
|
||||
- **category / fact_key 建议**:
|
||||
- 环境认知:`target/`、`auth/`、`infra/`、`business/`(body 用环境模板即可)
|
||||
- 发现与利用:`finding/`、`chain/`、`exploit/`、`poc/`(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||
- **与漏洞记录分工**:`record_vulnerability` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||
- 更新同一发现时保持相同 `fact_key` 覆盖写入,勿散落多个 key 导致上下文丢失。
|
||||
|
||||
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。
|
||||
|
||||
## 表达
|
||||
|
||||
|
||||
+23
-6
@@ -127,12 +127,29 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
|
||||
## 工具与 MCP
|
||||
|
||||
- **工具调用失败时**:1) 仔细分析错误信息,理解失败的具体原因;2) 如果工具不存在或未启用,尝试使用其他替代工具完成相同目标;3) 如果参数错误,根据错误提示修正参数后重试;4) 如果工具执行失败但输出了有用信息,可以基于这些信息继续分析;5) 如果确实无法使用某个工具,向用户说明问题,并建议替代方案或手动操作;6) 不要因为单个工具失败就停止整个测试流程,尝试其他方法继续完成任务。工具返回的错误信息会包含在工具响应中,请仔细阅读并做出合理决策。
|
||||
- **项目黑板(事实)与漏洞记录(分离)**:当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**
|
||||
- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。
|
||||
- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。
|
||||
- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。
|
||||
- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。
|
||||
- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。
|
||||
## 项目黑板(事实)与漏洞记录(分离)
|
||||
|
||||
当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。
|
||||
|
||||
- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。
|
||||
- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。
|
||||
- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。
|
||||
- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。
|
||||
- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。
|
||||
|
||||
### 事实写入规范(审计复现 / 知识沉淀)
|
||||
|
||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||
- **body**:完整可复现上下文,写入 `upsert_project_fact` 的 body 字段;索引不含 body,后续会话须靠 `get_project_fact` 取回。
|
||||
- **category / fact_key 建议**:
|
||||
- 环境认知:`target/`、`auth/`、`infra/`、`business/`(body 用环境模板即可)
|
||||
- 发现与利用:`finding/`、`chain/`、`exploit/`、`poc/`(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||
- **与漏洞记录分工**:`record_vulnerability` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||
- 更新同一发现时保持相同 `fact_key` 覆盖写入,勿散落多个 key 导致上下文丢失。
|
||||
|
||||
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。
|
||||
- **编排进度(待办)**:当你的任务包含 3 个或以上步骤,或你准备委派多个子目标并行/串行推进时,优先使用 `write_todos` 来向用户展示“当前在做什么/接下来做什么”。维护约束:同一时刻最多一个条目处于 `in_progress`;完成后立刻标记 `completed`;遇到阻塞就保留为 `in_progress` 并继续推进。
|
||||
- **强触发建议(提升多 agent 使用率)**:如果你将要进行任何“证据收集/枚举/扫描/验证/复现/整理报告”这类实质执行动作,且不只是单步查询,请优先在第一个工具调用前就用 `write_todos` 建立计划;随后用 `task` 委派至少一个子代理获取结构化证据,而不是自己把全部步骤做完。
|
||||
- **技能库(Skills)与知识库**:技能包位于服务器 `skills/` 目录(各子目录 `SKILL.md`,遵循 agentskills.io);知识库用于向量检索片段,Skills 为可执行工作流指令。多代理本会话通过内置 **`skill`** 工具渐进加载;子代理同样挂载 skill + 可选本机文件工具时,可在委派说明中提示按需加载。若当前无 skill 工具,需要完整 Skill 工作流时请使用多代理模式或切换为 Eino 编排会话。
|
||||
|
||||
@@ -31,5 +31,9 @@ max_iterations: 0
|
||||
- 禁止自行猜测目标、替换为历史目标或擅自发起全量探索。
|
||||
|
||||
- 以证据为中心:请求/响应、Payload、命令输出、截图说明等,便于审计与复现。
|
||||
- 先确认边界与禁止项(如拒绝 DoS、数据破坏);发现有效漏洞时按协调者要求使用 `record_vulnerability` 等流程(若你的工具集中包含)。
|
||||
- 先确认边界与禁止项(如拒绝 DoS、数据破坏)。
|
||||
- 输出包含:攻击路径摘要、关键步骤、影响评估、修复与缓解建议;语言简洁,便于主代理汇总。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -51,4 +51,8 @@ max_iterations: 0
|
||||
- 列出需要清理/验证的痕迹类型(配置、会话、日志、服务变更等层级描述即可)
|
||||
|
||||
4) Recommended Next Steps(下一步建议)
|
||||
- 建议由哪个阶段子代理接手,以及需要哪些证据输入。
|
||||
- 建议由哪个阶段子代理接手,以及需要哪些证据输入。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -53,4 +53,8 @@ max_iterations: 0
|
||||
4) Recommended Next Agent(下一步建议)
|
||||
- 明确建议由哪个子代理接手(例如 `lateral-movement` / `persistence-maintenance` / `impact-exfiltration` / `reporting-remediation`)
|
||||
|
||||
输出后直接结束。
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
输出后直接结束。
|
||||
|
||||
@@ -34,3 +34,7 @@ max_iterations: 0
|
||||
|
||||
- 若 **`description` / 用户消息 / 上文交接包** 中已给出资产列表、枚举结论或明确写「跳过全量枚举 / 仅做增量 / 从端口扫描或验证开始」,则**不得**为走完整流程而重新执行等价的广域子域爆破或相同参数集的枚举;仅在交接包声明的**缺口**上补充侦察。
|
||||
- 若子目标实为**漏洞验证、协议利用、权限提升**等而非攻击面扩展,应**极短说明**「当前角色为侦察;建议协调者改派专项代理」并仅提供与侦察相关的最小补充信息,避免擅自把任务扩写成新一轮全盘资产收集。
|
||||
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
@@ -55,4 +55,8 @@ max_iterations: 0
|
||||
5) Appendix(附录)
|
||||
- 术语、假设、证据清单索引(按证据类型列出即可)
|
||||
|
||||
输出后直接结束。
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
输出后直接结束。
|
||||
|
||||
@@ -57,4 +57,8 @@ max_iterations: 0
|
||||
4) Uncertainties & Missing Evidence(不确定性与缺口)
|
||||
- 列出最关键的缺口(尽量少,但要关键)
|
||||
|
||||
输出后直接结束。
|
||||
## 边渗透边记录
|
||||
|
||||
- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。
|
||||
|
||||
输出后直接结束。
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.24"
|
||||
version: "v1.6.26"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/project"
|
||||
)
|
||||
|
||||
@@ -108,17 +107,7 @@ func DefaultSingleAgentSystemPrompt() string {
|
||||
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
|
||||
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
|
||||
|
||||
## 项目黑板(事实)与漏洞记录(分离)
|
||||
|
||||
当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ` + builtin.ToolGetProjectFact + `(fact_key) 获取 body,禁止凭摘要臆造细节。**
|
||||
|
||||
- **环境/目标/认证等认知**(非正式漏洞条目):使用 ` + builtin.ToolUpsertProjectFact + `,fact_key 建议 ` + "`category/slug`" + `(如 target/primary_domain),同 key 覆盖更新。
|
||||
- **可交付漏洞**:使用 ` + builtin.ToolRecordVulnerability + `,含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ` + builtin.ToolListVulnerabilities + ` 查重,详情用 ` + builtin.ToolGetVulnerability + `(id)(默认仅当前项目/会话)。
|
||||
- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ` + builtin.ToolDeprecateProjectFact + ` 或漏洞状态 false_positive。
|
||||
|
||||
` + project.FactRecordingGuidanceBlock() + `
|
||||
|
||||
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。
|
||||
` + project.FactRecordingBlackboardSection(false) + `
|
||||
|
||||
## 技能库(Skills)与知识库
|
||||
|
||||
|
||||
@@ -1076,10 +1076,14 @@ func setupRoutes(
|
||||
// 项目管理与事实黑板
|
||||
protected.GET("/projects", projectHandler.ListProjects)
|
||||
protected.POST("/projects", projectHandler.CreateProject)
|
||||
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
||||
protected.GET("/projects/:id/conversations", projectHandler.ListProjectConversations)
|
||||
protected.GET("/projects/:id", projectHandler.GetProject)
|
||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
||||
protected.GET("/projects/:id/facts/:factId/previous-version", projectHandler.GetFactPreviousVersion)
|
||||
protected.GET("/projects/:id/facts/:factId/versions", projectHandler.ListFactVersions)
|
||||
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
|
||||
|
||||
@@ -49,6 +49,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
||||
upsertTool := mcp.Tool{
|
||||
Name: builtin.ToolUpsertProjectFact,
|
||||
Description: "写入或更新项目黑板事实,用于跨会话沉淀可复现上下文(非正式漏洞条目;可交付漏洞另用 record_vulnerability)。" +
|
||||
"边渗透边记录:每确认新认知(端口/入口/凭据/可利用点)后立即调用,同 fact_key 覆盖更新,勿等会话结束。" +
|
||||
"禁止仅写结论:summary 须含什么+在哪+如何验证;body 须含攻击链/请求响应/命令等复现细节。" +
|
||||
"发现类建议 fact_key 为 finding|chain|exploit|poc/<slug>,category 对应 finding|chain|exploit|poc,body 按攻击链模板填写。" +
|
||||
"环境类用 target|auth|infra|business/<slug>。同 fact_key 覆盖更新。需当前对话已绑定项目。",
|
||||
|
||||
@@ -163,7 +163,7 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *
|
||||
func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
|
||||
tool := mcp.Tool{
|
||||
Name: builtin.ToolRecordVulnerability,
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。当发现有效漏洞时,使用此工具记录漏洞信息,包括标题、描述、严重程度、类型、目标、证明、影响和建议等。记录前可先 list_vulnerabilities 避免重复。",
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。边渗透边记录:每验证出一条可复现漏洞(含 POC/影响)后立即调用,勿等会话结束。包括标题、描述、严重程度、类型、目标、证明、影响和建议等。记录前可先 list_vulnerabilities 避免重复。",
|
||||
ShortDescription: "记录发现的漏洞详情到漏洞管理系统",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
|
||||
@@ -247,6 +247,25 @@ func (db *DB) initTables() error {
|
||||
UNIQUE(project_id, fact_key)
|
||||
);`
|
||||
|
||||
createProjectFactVersionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fact_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
fact_key TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'note',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
body TEXT,
|
||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||
source_conversation_id TEXT,
|
||||
source_message_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
related_vulnerability_id TEXT,
|
||||
archived_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建漏洞表
|
||||
createVulnerabilitiesTable := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||
@@ -483,6 +502,8 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||
@@ -564,6 +585,10 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createProjectFactVersionsTable); err != nil {
|
||||
return fmt.Errorf("创建project_fact_versions表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||
}
|
||||
@@ -634,6 +659,9 @@ func (db *DB) initTables() error {
|
||||
if err := db.migrateProjectsTable(); err != nil {
|
||||
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
||||
}
|
||||
if err := db.migrateProjectFactVersionsTable(); err != nil {
|
||||
db.logger.Warn("迁移project_fact_versions表失败", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
||||
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
|
||||
@@ -1030,6 +1058,34 @@ func (db *DB) migrateProjectsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateProjectFactVersionsTable 为已有库创建事实版本表。
|
||||
func (db *DB) migrateProjectFactVersionsTable() error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fact_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
fact_key TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'note',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
body TEXT,
|
||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||
source_conversation_id TEXT,
|
||||
source_message_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
related_vulnerability_id TEXT,
|
||||
archived_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);`
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id)`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id)`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
||||
func (db *DB) migrateVulnerabilitiesTable() error {
|
||||
columns := []struct {
|
||||
|
||||
@@ -59,9 +59,11 @@ type ProjectFact struct {
|
||||
|
||||
// ProjectFactListFilter 事实列表筛选。
|
||||
type ProjectFactListFilter struct {
|
||||
Category string
|
||||
Confidence string
|
||||
Search string
|
||||
Category string
|
||||
Confidence string
|
||||
Search string
|
||||
RelatedVulnerabilityID string
|
||||
ExcludeDeprecated bool // 为 true 时排除 confidence=deprecated
|
||||
}
|
||||
|
||||
// CreateProject 创建项目。
|
||||
@@ -160,8 +162,11 @@ func (db *DB) UpdateProject(p *Project) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理)。
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;漏洞 project_id 置空)。
|
||||
func (db *DB) DeleteProject(id string) error {
|
||||
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除项目失败: %w", err)
|
||||
@@ -243,6 +248,13 @@ func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, l
|
||||
query += " AND confidence = ?"
|
||||
args = append(args, c)
|
||||
}
|
||||
if filter.ExcludeDeprecated {
|
||||
query += " AND confidence != 'deprecated'"
|
||||
}
|
||||
if rid := strings.TrimSpace(filter.RelatedVulnerabilityID); rid != "" {
|
||||
query += " AND related_vulnerability_id = ?"
|
||||
args = append(args, rid)
|
||||
}
|
||||
if s := strings.TrimSpace(filter.Search); s != "" {
|
||||
pat := "%" + s + "%"
|
||||
query += " AND (fact_key LIKE ? OR summary LIKE ? OR body LIKE ?)"
|
||||
@@ -309,10 +321,26 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
||||
f.CreatedAt = existing.CreatedAt
|
||||
f.UpdatedAt = now
|
||||
f.Body = mergeFactBodyOnUpdate(f.Body, existing.Body)
|
||||
if strings.TrimSpace(f.Category) == "" {
|
||||
f.Category = existing.Category
|
||||
}
|
||||
if strings.TrimSpace(f.Confidence) == "" {
|
||||
f.Confidence = existing.Confidence
|
||||
}
|
||||
if projectFactContentChanged(existing, f) {
|
||||
versionID, verr := db.InsertProjectFactVersion(existing)
|
||||
if verr != nil {
|
||||
return nil, verr
|
||||
}
|
||||
f.SupersedesFactID = versionID
|
||||
} else if f.SupersedesFactID == "" {
|
||||
f.SupersedesFactID = existing.SupersedesFactID
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
||||
source_conversation_id = ?, source_message_id = ?, pinned = ?,
|
||||
supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ?
|
||||
source_conversation_id = COALESCE(?, source_conversation_id),
|
||||
source_message_id = COALESCE(?, source_message_id),
|
||||
pinned = ?, supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
|
||||
@@ -135,6 +135,54 @@ func TestRestoreProjectFact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProjectFact_createsVersionOnContentChange(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&Project{Name: "version-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/xss",
|
||||
Category: "finding",
|
||||
Summary: "v1",
|
||||
Body: "body v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.SupersedesFactID != "" {
|
||||
t.Fatalf("expected no supersedes on create, got %q", created.SupersedesFactID)
|
||||
}
|
||||
|
||||
updated, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/xss",
|
||||
Summary: "v2",
|
||||
Body: "body v2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SupersedesFactID == "" {
|
||||
t.Fatal("expected supersedes_fact_id after content change")
|
||||
}
|
||||
prev, err := db.GetProjectFactVersion(updated.SupersedesFactID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prev.Summary != "v1" || prev.Body != "body v1" {
|
||||
t.Fatalf("previous version mismatch: summary=%q body=%q", prev.Summary, prev.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
||||
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
||||
t.Fatalf("empty incoming: got %q", got)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ProjectFactVersion 事实历史快照(同 fact_key 更新前归档)。
|
||||
type ProjectFactVersion struct {
|
||||
ID string `json:"id"`
|
||||
FactID string `json:"fact_id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Body string `json:"body"`
|
||||
Confidence string `json:"confidence"`
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||
ArchivedAt time.Time `json:"archived_at"`
|
||||
}
|
||||
|
||||
// InsertProjectFactVersion 将当前事实行快照写入版本表。
|
||||
func (db *DB) InsertProjectFactVersion(f *ProjectFact) (string, error) {
|
||||
if f == nil || f.ID == "" {
|
||||
return "", fmt.Errorf("无效的事实记录")
|
||||
}
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_versions (
|
||||
id, fact_id, project_id, fact_key, category, summary, body, confidence,
|
||||
source_conversation_id, source_message_id, pinned, related_vulnerability_id, archived_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.RelatedVulnerabilityID), now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("归档事实版本失败: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetProjectFactVersion 按版本 ID 获取快照。
|
||||
func (db *DB) GetProjectFactVersion(versionID string) (*ProjectFactVersion, error) {
|
||||
row := db.QueryRow(
|
||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), archived_at
|
||||
FROM project_fact_versions WHERE id = ?`, versionID,
|
||||
)
|
||||
return scanProjectFactVersionRow(row)
|
||||
}
|
||||
|
||||
// ListProjectFactVersions 列出某条事实的全部历史版本(新→旧)。
|
||||
func (db *DB) ListProjectFactVersions(factID string, limit int) ([]*ProjectFactVersion, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), archived_at
|
||||
FROM project_fact_versions WHERE fact_id = ? ORDER BY archived_at DESC LIMIT ?`,
|
||||
factID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*ProjectFactVersion
|
||||
for rows.Next() {
|
||||
v, err := scanProjectFactVersionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func projectFactContentChanged(existing, incoming *ProjectFact) bool {
|
||||
if existing == nil || incoming == nil {
|
||||
return false
|
||||
}
|
||||
mergedBody := mergeFactBodyOnUpdate(incoming.Body, existing.Body)
|
||||
inCat := stringsTrimDefault(incoming.Category, existing.Category)
|
||||
inConf := stringsTrimDefault(incoming.Confidence, existing.Confidence)
|
||||
return existing.Summary != incoming.Summary ||
|
||||
existing.Body != mergedBody ||
|
||||
existing.Category != inCat ||
|
||||
existing.Confidence != inConf
|
||||
}
|
||||
|
||||
func stringsTrimDefault(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func scanProjectFactVersionRow(row *sql.Row) (*ProjectFactVersion, error) {
|
||||
var v ProjectFactVersion
|
||||
var pinned int
|
||||
var archivedAt string
|
||||
err := row.Scan(
|
||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
||||
&v.RelatedVulnerabilityID, &archivedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("事实版本不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.Pinned = pinned != 0
|
||||
v.ArchivedAt = parseDBTime(archivedAt)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func scanProjectFactVersionFromRows(rows *sql.Rows) (*ProjectFactVersion, error) {
|
||||
var v ProjectFactVersion
|
||||
var pinned int
|
||||
var archivedAt string
|
||||
err := rows.Scan(
|
||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
||||
&v.RelatedVulnerabilityID, &archivedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Pinned = pinned != 0
|
||||
v.ArchivedAt = parseDBTime(archivedAt)
|
||||
return &v, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProjectStats 项目聚合统计。
|
||||
type ProjectStats struct {
|
||||
FactCount int `json:"fact_count"`
|
||||
VulnCount int `json:"vuln_count"`
|
||||
ConversationCount int `json:"conversation_count"`
|
||||
SparseFactCount int `json:"sparse_fact_count"`
|
||||
}
|
||||
|
||||
// GetProjectStatsCounts 统计项目下事实、漏洞、对话数量(不含 sparse,由 project 包补全)。
|
||||
func (db *DB) GetProjectStatsCounts(projectID string) (*ProjectStats, error) {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("project_id 不能为空")
|
||||
}
|
||||
if _, err := db.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := &ProjectStats{}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM project_facts WHERE project_id = ? AND confidence != 'deprecated'`,
|
||||
projectID,
|
||||
).Scan(&stats.FactCount); err != nil {
|
||||
return nil, fmt.Errorf("统计事实失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM vulnerabilities WHERE project_id = ?`,
|
||||
projectID,
|
||||
).Scan(&stats.VulnCount); err != nil {
|
||||
return nil, fmt.Errorf("统计漏洞失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM conversations WHERE project_id = ?`,
|
||||
projectID,
|
||||
).Scan(&stats.ConversationCount); err != nil {
|
||||
return nil, fmt.Errorf("统计对话失败: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ListProjectFactsForSparseCheck 返回用于待补全检测的事实字段(非 deprecated)。
|
||||
func (db *DB) ListProjectFactsForSparseCheck(projectID string) ([]struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT category, fact_key, COALESCE(body,'') FROM project_facts WHERE project_id = ? AND confidence != 'deprecated'`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}
|
||||
for rows.Next() {
|
||||
var row struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}
|
||||
if err := rows.Scan(&row.Category, &row.FactKey, &row.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListConversationsByProjectID 列出绑定到项目的对话。
|
||||
func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int) ([]*Conversation, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id
|
||||
FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||
projectID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询项目对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pid sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pid.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(pid.String)
|
||||
}
|
||||
conv.CreatedAt = parseDBTime(createdAt)
|
||||
conv.UpdatedAt = parseDBTime(updatedAt)
|
||||
conv.Pinned = pinned != 0
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
return conversations, rows.Err()
|
||||
}
|
||||
|
||||
// CountConversationsByProjectID 统计项目绑定对话数。
|
||||
func (db *DB) CountConversationsByProjectID(projectID string) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM conversations WHERE project_id = ?`, projectID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -265,10 +265,22 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||
|
||||
// DeleteVulnerability 删除漏洞
|
||||
func (db *DB) DeleteVulnerability(id string) error {
|
||||
_, err := db.Exec("DELETE FROM vulnerabilities WHERE id = ?", id)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
||||
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
||||
return fmt.Errorf("清理事实漏洞关联失败: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM vulnerabilities WHERE id = ?", id); err != nil {
|
||||
return fmt.Errorf("删除漏洞失败: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,17 @@ type runHandler struct {
|
||||
seq atomic.Uint64
|
||||
}
|
||||
|
||||
func safeRunInfo(info *callbacks.RunInfo) callbacks.RunInfo {
|
||||
if info == nil {
|
||||
return callbacks.RunInfo{
|
||||
Name: "unknown",
|
||||
Type: "unknown",
|
||||
Component: components.Component("unknown"),
|
||||
}
|
||||
}
|
||||
return *info
|
||||
}
|
||||
|
||||
func (h *runHandler) genSpanID() string {
|
||||
return fmt.Sprintf("%s-%d", h.runID, h.seq.Add(1))
|
||||
}
|
||||
@@ -134,6 +145,7 @@ func (h *runHandler) popMatching(want string) string {
|
||||
}
|
||||
|
||||
func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
|
||||
ri := safeRunInfo(info)
|
||||
var parentID string
|
||||
h.mu.Lock()
|
||||
if len(h.spanStack) > 0 {
|
||||
@@ -151,9 +163,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
|
||||
ctx, sp = tracer.Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(
|
||||
attribute.String("eino.component", string(info.Component)),
|
||||
attribute.String("eino.name", info.Name),
|
||||
attribute.String("eino.type", info.Type),
|
||||
attribute.String("eino.component", string(ri.Component)),
|
||||
attribute.String("eino.name", ri.Name),
|
||||
attribute.String("eino.type", ri.Type),
|
||||
attribute.String("cyberstrike.run_id", h.runID),
|
||||
attribute.String("cyberstrike.conversation_id", strings.TrimSpace(h.params.ConversationID)),
|
||||
attribute.String("cyberstrike.orchestration", strings.TrimSpace(h.params.OrchMode)),
|
||||
@@ -169,9 +181,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
|
||||
zap.String("runId", h.runID),
|
||||
zap.String("spanId", spanID),
|
||||
zap.String("parentSpanId", parentID),
|
||||
zap.String("component", string(info.Component)),
|
||||
zap.String("name", info.Name),
|
||||
zap.String("type", info.Type),
|
||||
zap.String("component", string(ri.Component)),
|
||||
zap.String("name", ri.Name),
|
||||
zap.String("type", ri.Type),
|
||||
zap.String("phase", "start"),
|
||||
}
|
||||
if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil {
|
||||
@@ -195,9 +207,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
|
||||
"parentSpanId": parentID,
|
||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||
"component": string(info.Component),
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
"component": string(ri.Component),
|
||||
"name": ri.Name,
|
||||
"type": ri.Type,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"inputSummary": inSum,
|
||||
"source": "eino_callbacks",
|
||||
@@ -208,6 +220,7 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
|
||||
}
|
||||
|
||||
func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
|
||||
ri := safeRunInfo(info)
|
||||
spanID, _ := ctx.Value(ctxSpanKey{}).(string)
|
||||
if spanID == "" {
|
||||
spanID = h.popSpan()
|
||||
@@ -226,9 +239,9 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
|
||||
fields := []zap.Field{
|
||||
zap.String("runId", h.runID),
|
||||
zap.String("spanId", spanID),
|
||||
zap.String("component", string(info.Component)),
|
||||
zap.String("name", info.Name),
|
||||
zap.String("type", info.Type),
|
||||
zap.String("component", string(ri.Component)),
|
||||
zap.String("name", ri.Name),
|
||||
zap.String("type", ri.Type),
|
||||
zap.String("phase", "end"),
|
||||
}
|
||||
if h.cfg.ZapVerbose {
|
||||
@@ -243,9 +256,9 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
|
||||
"spanId": spanID,
|
||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||
"component": string(info.Component),
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
"component": string(ri.Component),
|
||||
"name": ri.Name,
|
||||
"type": ri.Type,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"outputSummary": outSum,
|
||||
"source": "eino_callbacks",
|
||||
@@ -255,6 +268,7 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
|
||||
}
|
||||
|
||||
func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err error) context.Context {
|
||||
ri := safeRunInfo(info)
|
||||
spanID, _ := ctx.Value(ctxSpanKey{}).(string)
|
||||
if spanID == "" {
|
||||
spanID = h.popSpan()
|
||||
@@ -276,9 +290,9 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
|
||||
h.params.Logger.Warn("eino_callback_error",
|
||||
zap.String("runId", h.runID),
|
||||
zap.String("spanId", spanID),
|
||||
zap.String("component", string(info.Component)),
|
||||
zap.String("name", info.Name),
|
||||
zap.String("type", info.Type),
|
||||
zap.String("component", string(ri.Component)),
|
||||
zap.String("name", ri.Name),
|
||||
zap.String("type", ri.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
@@ -288,9 +302,9 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
|
||||
"spanId": spanID,
|
||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||
"component": string(info.Component),
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
"component": string(ri.Component),
|
||||
"name": ri.Name,
|
||||
"type": ri.Type,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"error": msg,
|
||||
"source": "eino_callbacks",
|
||||
@@ -300,28 +314,30 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
|
||||
}
|
||||
|
||||
func (h *runHandler) onStartStreamIn(ctx context.Context, info *callbacks.RunInfo, input *schema.StreamReader[callbacks.CallbackInput]) context.Context {
|
||||
ri := safeRunInfo(info)
|
||||
if input != nil {
|
||||
input.Close()
|
||||
}
|
||||
if h.params.Logger != nil {
|
||||
h.params.Logger.Debug("eino_callback_stream_in",
|
||||
zap.String("runId", h.runID),
|
||||
zap.String("component", string(info.Component)),
|
||||
zap.String("name", info.Name),
|
||||
zap.String("component", string(ri.Component)),
|
||||
zap.String("name", ri.Name),
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (h *runHandler) onEndStreamOut(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
|
||||
ri := safeRunInfo(info)
|
||||
if output != nil {
|
||||
output.Close()
|
||||
}
|
||||
if h.params.Logger != nil {
|
||||
h.params.Logger.Debug("eino_callback_stream_out",
|
||||
zap.String("runId", h.runID),
|
||||
zap.String("component", string(info.Component)),
|
||||
zap.String("name", info.Name),
|
||||
zap.String("component", string(ri.Component)),
|
||||
zap.String("name", ri.Name),
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
|
||||
@@ -87,6 +87,23 @@ func normalizeProcessDetailText(s string) string {
|
||||
// discardPlanningIfEchoesToolResult drops buffered planning text when it only repeats the
|
||||
// upcoming tool_result body. Streaming models often echo tool stdout in chunk.Content; flushing
|
||||
// that into "planning" before persisting tool_result duplicates the output after page refresh.
|
||||
// sameResponseStreamMeta 判断是否为同一段主通道流(Eino ADK 可能对同一 MessageStream 重复发 response_start)。
|
||||
func sameResponseStreamMeta(a, b map[string]interface{}) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
agentA, _ := a["einoAgent"].(string)
|
||||
agentB, _ := b["einoAgent"].(string)
|
||||
agentA = strings.TrimSpace(agentA)
|
||||
agentB = strings.TrimSpace(agentB)
|
||||
if agentA == "" || !strings.EqualFold(agentA, agentB) {
|
||||
return false
|
||||
}
|
||||
orchA, _ := a["orchestration"].(string)
|
||||
orchB, _ := b["orchestration"].(string)
|
||||
return strings.TrimSpace(orchA) == strings.TrimSpace(orchB)
|
||||
}
|
||||
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
|
||||
if respPlan == nil {
|
||||
return
|
||||
@@ -996,6 +1013,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
||||
flushedThinking := make(map[string]bool) // streamId -> flushed
|
||||
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
||||
seenToolResultSigs := make(map[string]string) // toolCallId -> payload signature
|
||||
|
||||
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
||||
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||
@@ -1058,6 +1077,29 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
|
||||
return func(eventType, message string, data interface{}) {
|
||||
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
||||
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
||||
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
toolCallID := strings.TrimSpace(fmt.Sprint(dataMap["toolCallId"]))
|
||||
if toolCallID != "" && toolCallID != "<nil>" {
|
||||
payloadJSON, _ := json.Marshal(dataMap)
|
||||
sig := eventType + "|" + message + "|" + string(payloadJSON)
|
||||
seen := seenToolCallSigs
|
||||
if eventType == "tool_result" {
|
||||
seen = seenToolResultSigs
|
||||
}
|
||||
if prev, exists := seen[toolCallID]; exists && prev == sig {
|
||||
h.logger.Debug("跳过重复工具进度事件",
|
||||
zap.String("eventType", eventType),
|
||||
zap.String("toolCallId", toolCallID))
|
||||
return
|
||||
}
|
||||
seen[toolCallID] = sig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, data)
|
||||
@@ -1251,6 +1293,17 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
|
||||
// 多代理主代理「规划中」:response_start / response_delta 仅用于 SSE,聚合落一条 planning
|
||||
if eventType == "response_start" {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if sameResponseStreamMeta(respPlan.meta, dataMap) {
|
||||
if respPlan.meta == nil {
|
||||
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||
}
|
||||
for k, v := range dataMap {
|
||||
respPlan.meta[k] = v
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
flushResponsePlan()
|
||||
respPlan.meta = nil
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
|
||||
+146
-29
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
@@ -29,12 +30,13 @@ type createProjectRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// updateProjectRequest 部分更新:字段省略表示不修改;传 null 或 "" 可清空字符串字段。
|
||||
type updateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ScopeJSON string `json:"scope_json"`
|
||||
Status string `json:"status"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ScopeJSON *string `json:"scope_json"`
|
||||
Status *string `json:"status"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// CreateProject POST /api/projects
|
||||
@@ -75,6 +77,46 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetProjectStats GET /api/projects/:id/stats
|
||||
func (h *ProjectHandler) GetProjectStats(c *gin.Context) {
|
||||
stats, err := project.GetProjectStats(h.db, c.Param("id"))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ListProjectConversations GET /api/projects/:id/conversations
|
||||
func (h *ProjectHandler) ListProjectConversations(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
if _, err := h.db.GetProject(projectID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset"))
|
||||
list, err := h.db.ListConversationsByProjectID(projectID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*database.Conversation{}
|
||||
}
|
||||
total, _ := h.db.CountConversationsByProjectID(projectID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": list,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProject GET /api/projects/:id
|
||||
func (h *ProjectHandler) GetProject(c *gin.Context) {
|
||||
p, err := h.db.GetProject(c.Param("id"))
|
||||
@@ -98,17 +140,21 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if s := strings.TrimSpace(req.Name); s != "" {
|
||||
p.Name = s
|
||||
if req.Name != nil {
|
||||
if s := strings.TrimSpace(*req.Name); s != "" {
|
||||
p.Name = s
|
||||
}
|
||||
}
|
||||
if req.Description != "" || c.Request.ContentLength > 0 {
|
||||
p.Description = req.Description
|
||||
if req.Description != nil {
|
||||
p.Description = *req.Description
|
||||
}
|
||||
if req.ScopeJSON != "" || c.GetHeader("Content-Type") != "" {
|
||||
p.ScopeJSON = req.ScopeJSON
|
||||
if req.ScopeJSON != nil {
|
||||
p.ScopeJSON = *req.ScopeJSON
|
||||
}
|
||||
if s := strings.TrimSpace(req.Status); s != "" {
|
||||
p.Status = s
|
||||
if req.Status != nil {
|
||||
if s := strings.TrimSpace(*req.Status); s != "" {
|
||||
p.Status = s
|
||||
}
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
p.Pinned = *req.Pinned
|
||||
@@ -139,6 +185,18 @@ type upsertFactRequest struct {
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
|
||||
}
|
||||
|
||||
// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。
|
||||
type updateFactRequest struct {
|
||||
FactKey *string `json:"fact_key"`
|
||||
Category *string `json:"category"`
|
||||
Summary *string `json:"summary"`
|
||||
Body *string `json:"body"`
|
||||
Confidence *string `json:"confidence"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
|
||||
ClearBody bool `json:"clear_body"`
|
||||
}
|
||||
|
||||
// ListFacts GET /api/projects/:id/facts (fact_key 查询参数可获取单条详情)
|
||||
func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
@@ -154,9 +212,13 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset"))
|
||||
filter := database.ProjectFactListFilter{
|
||||
Category: c.Query("category"),
|
||||
Confidence: c.Query("confidence"),
|
||||
Search: c.Query("search"),
|
||||
Category: c.Query("category"),
|
||||
Confidence: c.Query("confidence"),
|
||||
Search: c.Query("search"),
|
||||
RelatedVulnerabilityID: c.Query("related_vulnerability_id"),
|
||||
}
|
||||
if c.Query("exclude_deprecated") == "1" || c.Query("exclude_deprecated") == "true" {
|
||||
filter.ExcludeDeprecated = true
|
||||
}
|
||||
list, err := h.db.ListProjectFacts(projectID, filter, limit, offset)
|
||||
if err != nil {
|
||||
@@ -166,6 +228,53 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
if list == nil {
|
||||
list = []*database.ProjectFact{}
|
||||
}
|
||||
if sparseOnly := c.Query("sparse_only"); sparseOnly == "1" || sparseOnly == "true" {
|
||||
filtered := make([]*database.ProjectFact, 0, len(list))
|
||||
for _, f := range list {
|
||||
if project.IsSparseFactBody(f.Category, f.FactKey, f.Body) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetFactPreviousVersion GET /api/projects/:id/facts/:factId/previous-version
|
||||
func (h *ProjectHandler) GetFactPreviousVersion(c *gin.Context) {
|
||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||
if err != nil || existing.ProjectID != c.Param("id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(existing.SupersedesFactID) == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "无上一版本"})
|
||||
return
|
||||
}
|
||||
v, err := h.db.GetProjectFactVersion(existing.SupersedesFactID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// ListFactVersions GET /api/projects/:id/facts/:factId/versions
|
||||
func (h *ProjectHandler) ListFactVersions(c *gin.Context) {
|
||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||
if err != nil || existing.ProjectID != c.Param("id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
list, err := h.db.ListProjectFactVersions(existing.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*database.ProjectFactVersion{}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
@@ -201,28 +310,36 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
var req upsertFactRequest
|
||||
var req updateFactRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if k := strings.TrimSpace(req.FactKey); k != "" {
|
||||
existing.FactKey = k
|
||||
if req.FactKey != nil {
|
||||
if k := strings.TrimSpace(*req.FactKey); k != "" {
|
||||
existing.FactKey = k
|
||||
}
|
||||
}
|
||||
if req.Category != "" {
|
||||
existing.Category = req.Category
|
||||
if req.Category != nil && strings.TrimSpace(*req.Category) != "" {
|
||||
existing.Category = *req.Category
|
||||
}
|
||||
if req.Summary != "" {
|
||||
existing.Summary = req.Summary
|
||||
if req.Summary != nil && strings.TrimSpace(*req.Summary) != "" {
|
||||
existing.Summary = *req.Summary
|
||||
}
|
||||
if strings.TrimSpace(req.Body) != "" {
|
||||
existing.Body = req.Body
|
||||
if req.ClearBody {
|
||||
existing.Body = ""
|
||||
} else if req.Body != nil {
|
||||
existing.Body = *req.Body
|
||||
}
|
||||
if req.Confidence != "" {
|
||||
existing.Confidence = req.Confidence
|
||||
if req.Confidence != nil && strings.TrimSpace(*req.Confidence) != "" {
|
||||
existing.Confidence = *req.Confidence
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
existing.Pinned = *req.Pinned
|
||||
}
|
||||
if req.RelatedVulnerabilityID != nil {
|
||||
existing.RelatedVulnerabilityID = *req.RelatedVulnerabilityID
|
||||
}
|
||||
existing.Pinned = req.Pinned
|
||||
existing.RelatedVulnerabilityID = req.RelatedVulnerabilityID
|
||||
updated, err := h.db.UpsertProjectFact(existing)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -23,7 +23,7 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
if err != nil || projectID == "" {
|
||||
return ""
|
||||
}
|
||||
block, err := project.BuildFactIndexBlock(h.db, projectID, h.config.Project)
|
||||
block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project)
|
||||
if err != nil {
|
||||
h.logger.Warn("构建项目黑板索引失败", zap.String("conversationId", conversationID), zap.Error(err))
|
||||
return ""
|
||||
|
||||
@@ -234,7 +234,7 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
||||
_ = h.db.UpdateConversationTitle(convID, newTitle)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), h.robotMessageTimeout())
|
||||
sk := h.sessionKey(platform, userID)
|
||||
h.cancelMu.Lock()
|
||||
h.runningCancels[sk] = cancel
|
||||
@@ -252,6 +252,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "任务已取消。"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "任务执行超时,请稍后重试或精简本次请求范围。"
|
||||
}
|
||||
return "处理失败: " + err.Error()
|
||||
}
|
||||
if newConvID != convID {
|
||||
@@ -260,6 +263,11 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *RobotHandler) robotMessageTimeout() time.Duration {
|
||||
// 机器人整次消息处理超时(与单次工具超时 agent.tool_timeout_minutes 解耦)。
|
||||
return 10 * time.Hour
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdHelp() string {
|
||||
return "**【CyberStrikeAI 机器人命令】**\n\n" +
|
||||
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
|
||||
|
||||
@@ -134,6 +134,16 @@ func quoteCmdPath(p string) string {
|
||||
return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\""
|
||||
}
|
||||
|
||||
// normalizeWindowsCmdPath 把前端统一的 "/" 路径转换为 cmd 更稳定识别的 "\"。
|
||||
// 仅用于 Windows 命令构造,不改变语义(例如 "." / ".." 会保持不变)。
|
||||
func normalizeWindowsCmdPath(p string) string {
|
||||
s := strings.TrimSpace(p)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, "/", "\\")
|
||||
}
|
||||
|
||||
// quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。
|
||||
// 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。
|
||||
func quotePsSingle(s string) string {
|
||||
@@ -198,6 +208,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
p = "."
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
p = normalizeWindowsCmdPath(p)
|
||||
return "dir /a " + quoteCmdPath(p), nil
|
||||
}
|
||||
return "ls -la " + quoteShellSinglePosix(p), nil
|
||||
@@ -207,6 +218,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpPathRequired
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
return "type " + quoteCmdPath(path), nil
|
||||
}
|
||||
return "cat " + quoteShellSinglePosix(path), nil
|
||||
@@ -216,6 +228,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpPathRequired
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
return "del /q /f " + quoteCmdPath(path), nil
|
||||
}
|
||||
return "rm -f " + quoteShellSinglePosix(path), nil
|
||||
@@ -225,6 +238,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpPathRequired
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
// cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p)
|
||||
return "md " + quoteCmdPath(path), nil
|
||||
}
|
||||
@@ -237,6 +251,8 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpRenameNeedsBothPaths
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
oldPath = normalizeWindowsCmdPath(oldPath)
|
||||
newPath = normalizeWindowsCmdPath(newPath)
|
||||
return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil
|
||||
}
|
||||
return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil
|
||||
@@ -249,6 +265,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
// 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(in.Content))
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
return buildWindowsPowerShellWrite(path, b64), nil
|
||||
}
|
||||
return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil
|
||||
@@ -261,6 +278,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpUploadTooLarge
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
return buildWindowsPowerShellWrite(path, in.Content), nil
|
||||
}
|
||||
return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil
|
||||
@@ -270,6 +288,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
|
||||
return "", errFileOpPathRequired
|
||||
}
|
||||
if targetOS == "windows" {
|
||||
path = normalizeWindowsCmdPath(path)
|
||||
if in.ChunkIndex == 0 {
|
||||
return buildWindowsPowerShellWrite(path, in.Content), nil
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func BuildWebshellAssistantContext(conn *database.WebShellConnection, skillHint,
|
||||
b.WriteString(conn.ID)
|
||||
b.WriteString("\"):")
|
||||
b.WriteString(webshellAssistantToolList)
|
||||
b.WriteString("。")
|
||||
b.WriteString("。边渗透边记录:每确认新认知即 upsert_project_fact,每验证漏洞即 record_vulnerability,勿等会话结束。")
|
||||
b.WriteString(skillHint)
|
||||
b.WriteString("\n\n用户请求:")
|
||||
b.WriteString(userMsg)
|
||||
|
||||
@@ -184,14 +184,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
mainAgentToolStep := make(map[string]int)
|
||||
pendingByID := make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent := make(map[string][]string)
|
||||
var pendingMu sync.Mutex
|
||||
markPending := func(tc toolCallPendingInfo) {
|
||||
if tc.ToolCallID == "" {
|
||||
return
|
||||
}
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
pendingByID[tc.ToolCallID] = tc
|
||||
pendingQueueByAgent[tc.EinoAgent] = append(pendingQueueByAgent[tc.EinoAgent], tc.ToolCallID)
|
||||
}
|
||||
popNextPendingForAgent := func(agentName string) (toolCallPendingInfo, bool) {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
q := pendingQueueByAgent[agentName]
|
||||
for len(q) > 0 {
|
||||
id := q[0]
|
||||
@@ -208,19 +213,42 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
if toolCallID == "" {
|
||||
return
|
||||
}
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
delete(pendingByID, toolCallID)
|
||||
}
|
||||
popAnyPending := func() (toolCallPendingInfo, bool) {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
for id, tc := range pendingByID {
|
||||
delete(pendingByID, id)
|
||||
return tc, true
|
||||
}
|
||||
return toolCallPendingInfo{}, false
|
||||
}
|
||||
pendingCount := func() int {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
return len(pendingByID)
|
||||
}
|
||||
flushAllPendingAsFailed := func(err error) {
|
||||
pendingMu.Lock()
|
||||
pendingSnapshot := make([]toolCallPendingInfo, 0, len(pendingByID))
|
||||
for _, tc := range pendingByID {
|
||||
pendingSnapshot = append(pendingSnapshot, tc)
|
||||
}
|
||||
pendingByID = make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent = make(map[string][]string)
|
||||
pendingMu.Unlock()
|
||||
|
||||
if progress == nil {
|
||||
pendingByID = make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent = make(map[string][]string)
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
for _, tc := range pendingByID {
|
||||
for _, tc := range pendingSnapshot {
|
||||
toolName := tc.ToolName
|
||||
if strings.TrimSpace(toolName) == "" {
|
||||
toolName = "unknown"
|
||||
@@ -238,8 +266,6 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
pendingByID = make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent = make(map[string][]string)
|
||||
}
|
||||
|
||||
// 最近一次成功的 Eino filesystem execute 的标准输出(trim):用于抑制模型紧接着复述同一字符串时的重复「助手输出」时间线。
|
||||
@@ -319,7 +345,9 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
|
||||
runnerCfg := adk.RunnerConfig{
|
||||
Agent: da,
|
||||
Agent: da,
|
||||
// 启用 ADK 流式事件:plan_execute 也需要输出 reasoning/response 流,
|
||||
// 与 deep/supervisor/eino_single 的前端体验保持一致。
|
||||
EnableStreaming: true,
|
||||
}
|
||||
var cpStore *fileCheckPointStore
|
||||
@@ -519,8 +547,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
return takePartial(ctxErr)
|
||||
}
|
||||
if len(pendingByID) > 0 {
|
||||
orphanCount := len(pendingByID)
|
||||
if orphanCount := pendingCount(); orphanCount > 0 {
|
||||
flushAllPendingAsFailed(errors.New("pending tool call missing result before run completion"))
|
||||
if progress != nil {
|
||||
progress("eino_pending_orphaned", "pending tool calls were force-closed at run end", map[string]interface{}{
|
||||
@@ -957,12 +984,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else {
|
||||
for id := range pendingByID {
|
||||
toolCallID = id
|
||||
delete(pendingByID, id)
|
||||
break
|
||||
}
|
||||
} else if inferred, ok := popAnyPending(); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
}
|
||||
}
|
||||
if toolCallID != "" {
|
||||
|
||||
@@ -59,6 +59,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
}
|
||||
plannerCfg := &planexecute.PlannerConfig{
|
||||
ToolCallingChatModel: tcm,
|
||||
NewPlan: newLenientPlan,
|
||||
}
|
||||
if fn := planExecutePlannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers); fn != nil {
|
||||
plannerCfg.GenInputFn = fn
|
||||
@@ -70,6 +71,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
|
||||
ChatModel: tcm,
|
||||
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers),
|
||||
NewPlan: newLenientPlan,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
||||
@@ -146,14 +148,12 @@ func planExecutePlannerGenInput(
|
||||
}
|
||||
return func(ctx context.Context, userInput []adk.Message) ([]adk.Message, error) {
|
||||
userInput = capPlanExecuteUserInputMessages(userInput, appCfg, mwCfg)
|
||||
msgs := make([]adk.Message, 0, 1+len(userInput))
|
||||
if oi != "" {
|
||||
msgs = append(msgs, schema.SystemMessage(oi))
|
||||
}
|
||||
msgs := make([]adk.Message, 0, len(userInput))
|
||||
msgs = append(msgs, userInput...)
|
||||
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
|
||||
msgs = rewritten
|
||||
}
|
||||
msgs = normalizeSingleLeadingSystemMessage(msgs, oi)
|
||||
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_planner", msgs)
|
||||
return msgs, nil
|
||||
}
|
||||
@@ -182,9 +182,7 @@ func planExecuteExecutorGenInput(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oi != "" {
|
||||
userMsgs = append([]adk.Message{schema.SystemMessage(oi)}, userMsgs...)
|
||||
}
|
||||
userMsgs = normalizeSingleLeadingSystemMessage(userMsgs, oi)
|
||||
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_executor_gen_input", userMsgs)
|
||||
return userMsgs, nil
|
||||
}
|
||||
@@ -231,17 +229,54 @@ func planExecuteReplannerGenInput(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oi != "" {
|
||||
msgs = append([]adk.Message{schema.SystemMessage(oi)}, msgs...)
|
||||
}
|
||||
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
|
||||
msgs = rewritten
|
||||
}
|
||||
msgs = normalizeSingleLeadingSystemMessage(msgs, oi)
|
||||
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_replanner", msgs)
|
||||
return msgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSingleLeadingSystemMessage enforces a provider-friendly message shape:
|
||||
// exactly one system message at index 0 (when any system context exists).
|
||||
// For strict OpenAI-compatible backends (e.g. qwen/vllm templates), this avoids
|
||||
// "System message must be at the beginning" caused by multiple/disordered system messages.
|
||||
func normalizeSingleLeadingSystemMessage(msgs []adk.Message, extraSystem string) []adk.Message {
|
||||
extraSystem = strings.TrimSpace(extraSystem)
|
||||
if len(msgs) == 0 {
|
||||
if extraSystem == "" {
|
||||
return msgs
|
||||
}
|
||||
return []adk.Message{schema.SystemMessage(extraSystem)}
|
||||
}
|
||||
|
||||
systemParts := make([]string, 0, 2)
|
||||
if extraSystem != "" {
|
||||
systemParts = append(systemParts, extraSystem)
|
||||
}
|
||||
nonSystem := make([]adk.Message, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.System {
|
||||
if s := strings.TrimSpace(msg.Content); s != "" {
|
||||
systemParts = append(systemParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
nonSystem = append(nonSystem, msg)
|
||||
}
|
||||
if len(systemParts) == 0 {
|
||||
return nonSystem
|
||||
}
|
||||
out := make([]adk.Message, 0, len(nonSystem)+1)
|
||||
out = append(out, schema.SystemMessage(strings.Join(systemParts, "\n\n")))
|
||||
out = append(out, nonSystem...)
|
||||
return out
|
||||
}
|
||||
|
||||
func capPlanExecuteUserInputMessages(input []adk.Message, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
|
||||
if len(input) == 0 {
|
||||
return input
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestNormalizeSingleLeadingSystemMessage_MergesMultipleSystems(t *testing.T) {
|
||||
in := []adk.Message{
|
||||
schema.SystemMessage("sys-1"),
|
||||
schema.UserMessage("u1"),
|
||||
schema.SystemMessage("sys-2"),
|
||||
schema.AssistantMessage("a1", nil),
|
||||
}
|
||||
out := normalizeSingleLeadingSystemMessage(in, "orch")
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("unexpected output length: got %d want 3", len(out))
|
||||
}
|
||||
if out[0].Role != schema.System {
|
||||
t.Fatalf("first message role must be system, got %s", out[0].Role)
|
||||
}
|
||||
if got := out[0].Content; got != "orch\n\nsys-1\n\nsys-2" {
|
||||
t.Fatalf("unexpected merged system content: %q", got)
|
||||
}
|
||||
if out[1].Role != schema.User || out[2].Role != schema.Assistant {
|
||||
t.Fatalf("non-system message order changed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSingleLeadingSystemMessage_NoSystemKeepsFlow(t *testing.T) {
|
||||
in := []adk.Message{
|
||||
schema.UserMessage("u1"),
|
||||
schema.AssistantMessage("a1", nil),
|
||||
}
|
||||
out := normalizeSingleLeadingSystemMessage(in, "")
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("unexpected output length: got %d want 2", len(out))
|
||||
}
|
||||
if out[0].Role != schema.User || out[1].Role != schema.Assistant {
|
||||
t.Fatalf("message order changed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,10 @@ func isEinoTransientRunError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// io.EOF 常见于流式正常收尾,不应触发分段重试。
|
||||
if errors.Is(err, io.EOF) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
@@ -55,7 +60,6 @@ func isEinoTransientRunError(err error) bool {
|
||||
"no such host",
|
||||
"network is unreachable",
|
||||
"broken pipe",
|
||||
"eof",
|
||||
"read tcp",
|
||||
"write tcp",
|
||||
"dial tcp",
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,9 +19,12 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"io eof", io.EOF, false},
|
||||
{"plain eof text", errors.New("EOF"), false},
|
||||
{"429", errors.New("HTTP 429 Too Many Requests"), true},
|
||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||
{"unexpected eof", errors.New("unexpected EOF"), true},
|
||||
{"503", errors.New("upstream returned 503"), true},
|
||||
{"iteration limit", errors.New("max iteration reached"), false},
|
||||
{"canceled", context.Canceled, false},
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/project"
|
||||
)
|
||||
|
||||
@@ -107,13 +106,9 @@ func DefaultPlanExecuteOrchestratorInstruction() string {
|
||||
|
||||
当工具返回错误时,错误信息会包含在工具响应中,请仔细阅读并做出合理的决策。
|
||||
|
||||
## 项目黑板(事实)与漏洞记录(分离)
|
||||
` + project.FactRecordingBlackboardSection(true) + `
|
||||
|
||||
绑定项目时会自动注入黑板索引(fact_key + 摘要)。**摘要不足必须 ` + builtin.ToolGetProjectFact + `(fact_key) 取 body,禁止臆造。** 环境认知用 ` + builtin.ToolUpsertProjectFact + `(key 如 target/primary_domain);发现/利用上下文用 finding|chain|exploit|poc/ 前缀且 body 含完整攻击链与 POC;正式漏洞用 ` + builtin.ToolRecordVulnerability + `(记前可先 ` + builtin.ToolListVulnerabilities + ` 防重复,详情用 ` + builtin.ToolGetVulnerability + `);二者可各记一次。误报用 ` + builtin.ToolDeprecateProjectFact + `。漏洞查询默认仅当前项目(未绑项目则仅当前会话)。
|
||||
|
||||
` + project.FactRecordingGuidanceBlock() + `
|
||||
|
||||
严重程度:critical / high / medium / low / info。证明须含足够证据。
|
||||
- **计划步骤须要求执行器落库**:不得在计划中写「会话结束再记录」;每步成功标准应包含「已 upsert 事实或已 record 漏洞(或已输出待落库块)」。
|
||||
|
||||
## 技能库(Skills)与知识库
|
||||
|
||||
@@ -209,7 +204,8 @@ func DefaultSupervisorOrchestratorInstruction() string {
|
||||
- **委派优先**:可独立封装、需要专项上下文的子目标(枚举、验证、归纳、报告素材)优先 transfer 给匹配子代理,并在委派说明中写清:子目标、约束、期望交付物结构、证据要求。
|
||||
- **亲自执行**:仅当无合适专家、需全局衔接或子代理结果不足时,由你直接调用工具。
|
||||
- **汇总**:子代理输出是证据来源;你要对齐矛盾、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接。
|
||||
- **事实与漏洞**:环境认知用 ` + builtin.ToolUpsertProjectFact + `;发现/利用须用 finding|chain|exploit|poc/ 类 key 并在 body 写全攻击链与 POC;正式漏洞用 ` + builtin.ToolRecordVulnerability + `,查询用 ` + builtin.ToolListVulnerabilities + ` / ` + builtin.ToolGetVulnerability + `;索引摘要不足时必须 ` + builtin.ToolGetProjectFact + ` 取详情。
|
||||
|
||||
` + project.FactRecordingBlackboardSection(true) + `
|
||||
|
||||
## transfer 交接与防重复劳动
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
|
||||
)
|
||||
|
||||
// lenientPlan keeps plan_execute running even when model tool arguments contain minor JSON defects.
|
||||
// It first tries strict JSON, then falls back to lightweight step extraction heuristics.
|
||||
type lenientPlan struct {
|
||||
Steps []string `json:"steps"`
|
||||
}
|
||||
|
||||
func newLenientPlan(context.Context) planexecute.Plan {
|
||||
return &lenientPlan{}
|
||||
}
|
||||
|
||||
func (p *lenientPlan) FirstStep() string {
|
||||
if p == nil || len(p.Steps) == 0 {
|
||||
return ""
|
||||
}
|
||||
return p.Steps[0]
|
||||
}
|
||||
|
||||
func (p *lenientPlan) MarshalJSON() ([]byte, error) {
|
||||
type alias lenientPlan
|
||||
return json.Marshal((*alias)(p))
|
||||
}
|
||||
|
||||
func (p *lenientPlan) UnmarshalJSON(b []byte) error {
|
||||
type alias lenientPlan
|
||||
var strict alias
|
||||
if err := json.Unmarshal(b, &strict); err == nil {
|
||||
strict.Steps = normalizePlanSteps(strict.Steps)
|
||||
if len(strict.Steps) > 0 {
|
||||
*p = lenientPlan(strict)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
steps := extractPlanStepsLenient(string(b))
|
||||
if len(steps) == 0 {
|
||||
steps = []string{"继续按当前目标执行下一步,并输出可验证证据。"}
|
||||
}
|
||||
p.Steps = steps
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractPlanStepsLenient(raw string) []string {
|
||||
s := strings.TrimSpace(stripCodeFence(raw))
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if extracted, ok := sliceByStepsArray(s); ok {
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(extracted), &arr); err == nil {
|
||||
arr = normalizePlanSteps(arr)
|
||||
if len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
if arr := splitStepsHeuristically(strings.Trim(extracted, "[]")); len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
// Last-resort: treat plaintext body as one actionable step.
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{s}
|
||||
}
|
||||
|
||||
func sliceByStepsArray(s string) (string, bool) {
|
||||
lower := strings.ToLower(s)
|
||||
key := `"steps"`
|
||||
i := strings.Index(lower, key)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
start := strings.Index(s[i:], "[")
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
start += i
|
||||
depth := 0
|
||||
for j := start; j < len(s); j++ {
|
||||
switch s[j] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[start : j+1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitStepsHeuristically(body string) []string {
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
body = strings.ReplaceAll(body, "\\n", "\n")
|
||||
var parts []string
|
||||
if strings.Contains(body, "\n") {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
} else {
|
||||
for _, seg := range strings.Split(body, ",") {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
t := strings.TrimSpace(part)
|
||||
t = strings.Trim(t, "\"'`")
|
||||
t = strings.TrimLeft(t, "-*0123456789.、 \t")
|
||||
t = strings.TrimSpace(strings.ReplaceAll(t, `\"`, `"`))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return normalizePlanSteps(out)
|
||||
}
|
||||
|
||||
func normalizePlanSteps(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, step := range in {
|
||||
t := strings.TrimSpace(step)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripCodeFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimPrefix(s, "```JSON")
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
)
|
||||
|
||||
// 边渗透边记录:统一节奏文案(agents/*.md 须与 FactRecordingIncrementalRhythmMarkdown 保持一致)。
|
||||
const (
|
||||
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
|
||||
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
|
||||
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
|
||||
)
|
||||
|
||||
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
|
||||
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:")
|
||||
b.WriteString(factRhythmCore)
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。")
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
|
||||
// coordinatorDelegate 为 true 时追加「协调者代子代理落库」说明(Deep / plan_execute / supervisor)。
|
||||
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ")
|
||||
b.WriteString(builtin.ToolGetProjectFact)
|
||||
b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ")
|
||||
b.WriteString(builtin.ToolListVulnerabilities)
|
||||
b.WriteString(" 查重,详情用 ")
|
||||
b.WriteString(builtin.ToolGetVulnerability)
|
||||
b.WriteString("(id)(默认仅当前项目/会话)。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ")
|
||||
b.WriteString(builtin.ToolDeprecateProjectFact)
|
||||
b.WriteString(" 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 ")
|
||||
b.WriteString(builtin.ToolListProjectFacts)
|
||||
b.WriteString(" / ")
|
||||
b.WriteString(builtin.ToolSearchProjectFacts)
|
||||
b.WriteString(" 检索。\n\n")
|
||||
b.WriteString(FactRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。
|
||||
func FactRecordingSubAgentSection() string {
|
||||
return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n"
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。
|
||||
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n")
|
||||
b.WriteString(FactRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
// projectScopePayload 解析 projects.scope_json(约定字段,可扩展)。
|
||||
type projectScopePayload struct {
|
||||
Targets []string `json:"targets"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// BuildScopeBlock 将项目 scope_json 格式化为 Agent 可读的授权范围块。
|
||||
func BuildScopeBlock(proj *database.Project) string {
|
||||
if proj == nil {
|
||||
return ""
|
||||
}
|
||||
raw := strings.TrimSpace(proj.ScopeJSON)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var payload projectScopePayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return fmt.Sprintf("## 项目测试范围(project: %s)\n(scope_json 非合法 JSON,请人工核对配置)\n```\n%s\n```\n"+
|
||||
"仅对明确授权目标执行测试;超出范围须停止并说明。\n", proj.Name, truncateRunes(raw, 800))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("## 项目测试范围(project: %s, id: %s)\n", proj.Name, proj.ID))
|
||||
b.WriteString("以下为授权边界,**必须遵守**:仅测试列出的 targets,避开 exclude,不得擅自扩大范围。\n")
|
||||
|
||||
if len(payload.Targets) > 0 {
|
||||
b.WriteString("\n**允许测试(targets)**:\n")
|
||||
for _, t := range payload.Targets {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
b.WriteString("- " + t + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(payload.Exclude) > 0 {
|
||||
b.WriteString("\n**明确排除(exclude)**:\n")
|
||||
for _, t := range payload.Exclude {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
b.WriteString("- " + t + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := strings.TrimSpace(payload.Notes); n != "" {
|
||||
b.WriteString("\n**说明(notes)**:\n" + n + "\n")
|
||||
}
|
||||
if len(payload.Targets) == 0 && len(payload.Exclude) == 0 && strings.TrimSpace(payload.Notes) == "" {
|
||||
b.WriteString("\n(scope_json 已配置但未识别 targets/exclude/notes 字段,原始内容供参考)\n```json\n")
|
||||
b.WriteString(truncateRunes(raw, 1200))
|
||||
b.WriteString("\n```\n")
|
||||
}
|
||||
b.WriteString("\n若目标不在 targets 内或命中 exclude,不得主动扫描/利用;需用户明确扩大授权后再继续。\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
// BuildProjectBlackboardBlock 组合测试范围 + 事实黑板索引。
|
||||
func BuildProjectBlackboardBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return "", nil
|
||||
}
|
||||
proj, err := db.GetProject(projectID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := []string{}
|
||||
if scope := strings.TrimSpace(BuildScopeBlock(proj)); scope != "" {
|
||||
parts = append(parts, scope)
|
||||
}
|
||||
index, err := BuildFactIndexBlock(db, projectID, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(index) != "" {
|
||||
parts = append(parts, index)
|
||||
}
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
func TestBuildScopeBlock_targetsExcludeNotes(t *testing.T) {
|
||||
proj := &database.Project{
|
||||
ID: "p1",
|
||||
Name: "Acme",
|
||||
ScopeJSON: `{"targets":["https://app.example.com"],"exclude":["*.cdn.example.com"],"notes":"仅 Web 层"}`,
|
||||
}
|
||||
block := BuildScopeBlock(proj)
|
||||
if !strings.Contains(block, "https://app.example.com") {
|
||||
t.Fatalf("missing target: %s", block)
|
||||
}
|
||||
if !strings.Contains(block, "cdn.example.com") {
|
||||
t.Fatalf("missing exclude: %s", block)
|
||||
}
|
||||
if !strings.Contains(block, "仅 Web 层") {
|
||||
t.Fatalf("missing notes: %s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScopeBlock_empty(t *testing.T) {
|
||||
if BuildScopeBlock(&database.Project{Name: "X"}) != "" {
|
||||
t.Fatal("expected empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScopeBlock_invalidJSON(t *testing.T) {
|
||||
proj := &database.Project{Name: "X", ScopeJSON: `{not json`}
|
||||
block := BuildScopeBlock(proj)
|
||||
if !strings.Contains(block, "非合法 JSON") {
|
||||
t.Fatalf("unexpected: %s", block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package project
|
||||
|
||||
import "cyberstrike-ai/internal/database"
|
||||
|
||||
// GetProjectStats 聚合项目统计(含待补全事实数)。
|
||||
func GetProjectStats(db *database.DB, projectID string) (*database.ProjectStats, error) {
|
||||
stats, err := db.GetProjectStatsCounts(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.ListProjectFactsForSparseCheck(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if IsSparseFactBody(r.Category, r.FactKey, r.Body) {
|
||||
stats.SparseFactCount++
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
name: "fscan"
|
||||
command: "fscan"
|
||||
enabled: false
|
||||
short_description: "内网综合扫描工具,支持存活探测、端口扫描、服务识别、爆破、POC检测"
|
||||
description: |
|
||||
Fscan是一款内网综合扫描工具,支持主机发现、端口扫描、服务识别、
|
||||
|
||||
密码爆破、Web指纹识别和漏洞POC检测。
|
||||
|
||||
**主要功能:**
|
||||
- 主机存活探测(ICMP/TCP/Ping)
|
||||
- 端口扫描(默认1000常用端口)
|
||||
- 服务版本识别与指纹匹配
|
||||
- 弱口令暴力破解(SSH/SMB/Mysql/Redis等)
|
||||
- Web应用漏洞POC扫描
|
||||
- DNS探测与域名枚举
|
||||
- Redis未授权利用(写入/WebShell/反弹Shell)
|
||||
- 持久化后门生成(Linux ELF / Windows PE)
|
||||
|
||||
**使用场景:**
|
||||
- 内网资产快速梳理
|
||||
- 弱口令批量检测
|
||||
- 常见服务漏洞验证
|
||||
- 渗透测试信息收集
|
||||
- 红队内网横向
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标主机:IP地址、IP段(如192.168.1.0/24)、IP文件或域名"
|
||||
required: true
|
||||
flag: "-h"
|
||||
format: "flag"
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
description: |
|
||||
扫描端口列表,逗号分隔。默认覆盖1000个常用端口。
|
||||
示例: "22,80,443,3306,6379" 或 "1-1000"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
default: "21,22,23,25,53,80,81,88,110,111,135,139,143,161,389,443,445,465,502,512,513,514,515,548,554,587,623,636,873,902,993,995,1080,1099,1194,1433,1434,1521,1522,1525,1723,1883,2049,2121,2181,2200,2222,2375,2376,2379,2380,3000,3128,3268,3269,3306,3389,3690,4369,4444,4848,5000,5005,5044,5060,5432,5601,5631,5632,5671,5672,5900,5984,5985,5986,6000,6379,6380,6443,6666,6667,7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999,10000,10051,10250,10255,11211,15672,22222,26379,27017,27018,50000,50070,50075,61613,61614,61616"
|
||||
- name: "mode"
|
||||
type: "string"
|
||||
description: |
|
||||
扫描模式:
|
||||
- all:全功能扫描(默认)
|
||||
- icmp:仅存活探测
|
||||
- 或指定插件名称(如 ssh, smb, mysql, redis 等)
|
||||
required: false
|
||||
flag: "-m"
|
||||
format: "flag"
|
||||
default: "all"
|
||||
- name: "output_file"
|
||||
type: "string"
|
||||
description: "结果输出文件路径(默认 result.txt)"
|
||||
required: false
|
||||
flag: "-o"
|
||||
format: "flag"
|
||||
default: "result.txt"
|
||||
- name: "output_format"
|
||||
type: "string"
|
||||
description: "输出格式:txt(默认), json, csv"
|
||||
required: false
|
||||
flag: "-f"
|
||||
format: "flag"
|
||||
default: "txt"
|
||||
- name: "threads"
|
||||
type: "int"
|
||||
description: "端口扫描线程数"
|
||||
required: false
|
||||
flag: "-t"
|
||||
format: "flag"
|
||||
default: 600
|
||||
- name: "module_threads"
|
||||
type: "int"
|
||||
description: "模块并发线程数"
|
||||
required: false
|
||||
flag: "-mt"
|
||||
format: "flag"
|
||||
default: 20
|
||||
- name: "poc_num"
|
||||
type: "int"
|
||||
description: "POC扫描并发数"
|
||||
required: false
|
||||
flag: "-num"
|
||||
format: "flag"
|
||||
default: 20
|
||||
- name: "timeout"
|
||||
type: "int"
|
||||
description: "端口扫描超时时间(秒)"
|
||||
required: false
|
||||
flag: "-time"
|
||||
format: "flag"
|
||||
default: 3
|
||||
- name: "web_timeout"
|
||||
type: "int"
|
||||
description: "Web请求超时时间(秒)"
|
||||
required: false
|
||||
flag: "-wt"
|
||||
format: "flag"
|
||||
default: 5
|
||||
- name: "global_timeout"
|
||||
type: "int"
|
||||
description: "全局超时时间(秒)"
|
||||
required: false
|
||||
flag: "-gt"
|
||||
format: "flag"
|
||||
default: 180
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: "目标URL(用于Web扫描模式)"
|
||||
required: false
|
||||
flag: "-u"
|
||||
format: "flag"
|
||||
- name: "proxy"
|
||||
type: "string"
|
||||
description: "HTTP代理地址(如: http://127.0.0.1:8080)"
|
||||
required: false
|
||||
flag: "-proxy"
|
||||
format: "flag"
|
||||
- name: "socks5"
|
||||
type: "string"
|
||||
description: "SOCKS5代理地址(如: 127.0.0.1:1080)"
|
||||
required: false
|
||||
flag: "-socks5"
|
||||
format: "flag"
|
||||
- name: "cookie"
|
||||
type: "string"
|
||||
description: "HTTP Cookie值"
|
||||
required: false
|
||||
flag: "-cookie"
|
||||
format: "flag"
|
||||
- name: "domain"
|
||||
type: "string"
|
||||
description: "目标域名"
|
||||
required: false
|
||||
flag: "-domain"
|
||||
format: "flag"
|
||||
- name: "username"
|
||||
type: "string"
|
||||
description: "暴力破解用户名"
|
||||
required: false
|
||||
flag: "-user"
|
||||
format: "flag"
|
||||
- name: "password"
|
||||
type: "string"
|
||||
description: "暴力破解密码"
|
||||
required: false
|
||||
flag: "-pwd"
|
||||
format: "flag"
|
||||
- name: "user_file"
|
||||
type: "string"
|
||||
description: "用户名字典文件路径"
|
||||
required: false
|
||||
flag: "-userf"
|
||||
format: "flag"
|
||||
- name: "pass_file"
|
||||
type: "string"
|
||||
description: "密码字典文件路径"
|
||||
required: false
|
||||
flag: "-pwdf"
|
||||
format: "flag"
|
||||
- name: "host_file"
|
||||
type: "string"
|
||||
description: "目标主机文件路径(每行一个IP)"
|
||||
required: false
|
||||
flag: "-hf"
|
||||
format: "flag"
|
||||
- name: "port_file"
|
||||
type: "string"
|
||||
description: "自定义端口文件路径"
|
||||
required: false
|
||||
flag: "-pf"
|
||||
format: "flag"
|
||||
- name: "url_file"
|
||||
type: "string"
|
||||
description: "目标URL文件路径"
|
||||
required: false
|
||||
flag: "-uf"
|
||||
format: "flag"
|
||||
- name: "pocname"
|
||||
type: "string"
|
||||
description: "指定POC名称进行单点扫描"
|
||||
required: false
|
||||
flag: "-pocname"
|
||||
format: "flag"
|
||||
- name: "pocpath"
|
||||
type: "string"
|
||||
description: "自定义POC脚本路径"
|
||||
required: false
|
||||
flag: "-pocpath"
|
||||
format: "flag"
|
||||
- name: "iface"
|
||||
type: "string"
|
||||
description: "指定本地网卡IP地址(VPN场景使用)"
|
||||
required: false
|
||||
flag: "-iface"
|
||||
format: "flag"
|
||||
- name: "exclude_host"
|
||||
type: "string"
|
||||
description: "排除的主机IP"
|
||||
required: false
|
||||
flag: "-eh"
|
||||
format: "flag"
|
||||
- name: "exclude_port"
|
||||
type: "string"
|
||||
description: "排除的端口"
|
||||
required: false
|
||||
flag: "-ep"
|
||||
format: "flag"
|
||||
- name: "retry"
|
||||
type: "int"
|
||||
description: "最大重试次数"
|
||||
required: false
|
||||
flag: "-retry"
|
||||
format: "flag"
|
||||
default: 3
|
||||
- name: "rate_limit"
|
||||
type: "int"
|
||||
description: "每分钟最大发包次数(0表示不限制)"
|
||||
required: false
|
||||
flag: "-rate"
|
||||
format: "flag"
|
||||
- name: "max_redirect"
|
||||
type: "int"
|
||||
description: "HTTP最大重定向次数"
|
||||
required: false
|
||||
flag: "-max-redirect"
|
||||
format: "flag"
|
||||
default: 10
|
||||
- name: "lang"
|
||||
type: "string"
|
||||
description: "输出语言:zh(默认中文), en(英文)"
|
||||
required: false
|
||||
flag: "-lang"
|
||||
format: "flag"
|
||||
default: "zh"
|
||||
- name: "log_level"
|
||||
type: "string"
|
||||
description: "日志级别(默认 base,info,success)"
|
||||
required: false
|
||||
flag: "-log"
|
||||
format: "flag"
|
||||
default: "base,info,success"
|
||||
- name: "reverse_shell"
|
||||
type: "string"
|
||||
description: "反弹Shell目标地址:端口(如: 192.168.1.100:4444)"
|
||||
required: false
|
||||
flag: "-rsh"
|
||||
format: "flag"
|
||||
- name: "sshkey_file"
|
||||
type: "string"
|
||||
description: "SSH私钥文件路径"
|
||||
required: false
|
||||
flag: "-sshkey"
|
||||
format: "flag"
|
||||
- name: "download_url"
|
||||
type: "string"
|
||||
description: "要下载的文件URL"
|
||||
required: false
|
||||
flag: "-download-url"
|
||||
format: "flag"
|
||||
- name: "download_path"
|
||||
type: "string"
|
||||
description: "下载文件保存路径"
|
||||
required: false
|
||||
flag: "-download-path"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的fscan参数。用于传递未在参数列表中定义的fscan选项。
|
||||
|
||||
**示例值:**
|
||||
- "-nobr -nopoc" (禁用爆破和POC,仅做端口扫描)
|
||||
- "-ao" (仅进行存活探测)
|
||||
- "-silent -nocolor" (静默无颜色输出)
|
||||
- "-debug" (开启调试模式)
|
||||
- "-full" (全量POC扫描)
|
||||
- "-no" (禁用结果保存)
|
||||
- "-dns" (启用DNS日志记录)
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
+475
-62
@@ -1744,6 +1744,7 @@ header {
|
||||
background: #f5f7fa;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 会话顶部栏样式 */
|
||||
@@ -1772,6 +1773,43 @@ header {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 88px;
|
||||
z-index: 20;
|
||||
padding: 8px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(0, 102, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--accent-color);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
@@ -3692,6 +3730,13 @@ header {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 流式执行中:取消时间线内层滚动,由 #chat-messages 统一跟随 */
|
||||
.progress-container.is-streaming .progress-timeline.expanded,
|
||||
.process-details-container.is-streaming .process-details-content .progress-timeline.expanded {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
@@ -12925,6 +12970,7 @@ header {
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
margin: 2px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webshell-tree-row.active {
|
||||
@@ -12996,6 +13042,12 @@ header {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webshell-tree-row.selected-file .webshell-dir-item {
|
||||
background: rgba(0, 102, 255, 0.08);
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webshell-file-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -13192,6 +13244,11 @@ header {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.15s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webshell-col-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webshell-file-empty-state {
|
||||
@@ -13211,6 +13268,15 @@ header {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.webshell-file-table tbody tr.webshell-file-row-selected {
|
||||
background: rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
.webshell-file-table tbody tr.webshell-file-row-selected a.webshell-file-link {
|
||||
color: var(--accent-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webshell-file-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -13222,6 +13288,12 @@ header {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.webshell-file-table a.webshell-file-link:hover {
|
||||
@@ -13385,6 +13457,17 @@ header {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.webshell-file-content-path {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
word-break: break-all;
|
||||
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.webshell-file-content .btn-ghost {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
@@ -20996,6 +21079,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
}
|
||||
#page-projects .page-content.projects-page-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
min-height: calc(100vh - 128px);
|
||||
padding: 16px 20px 24px;
|
||||
@@ -21004,6 +21088,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-sidebar-card {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
@@ -21011,7 +21096,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
max-height: calc(100vh - 160px);
|
||||
min-height: 420px;
|
||||
}
|
||||
.projects-sidebar-head {
|
||||
display: flex;
|
||||
@@ -21118,6 +21203,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
@@ -21173,6 +21259,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
min-height: 420px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.projects-detail-header {
|
||||
display: flex;
|
||||
@@ -21244,6 +21331,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.projects-stat-chip--warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
.projects-detail-header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -21283,6 +21375,18 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 16px 24px 24px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.projects-panel--settings {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
/* display:flex 会覆盖 [hidden] 默认 display:none,非激活 Tab 会叠在事实黑板下方 */
|
||||
.projects-panel[hidden] {
|
||||
@@ -21300,6 +21404,185 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
}
|
||||
/* —— 事实黑板:说明 + 筛选工具栏 —— */
|
||||
.projects-fact-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.projects-fact-toolbar-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.projects-fact-toolbar-hint-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.projects-fact-toolbar-hint strong {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.projects-fact-toolbar-hint code {
|
||||
padding: 1px 5px;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #1d4ed8;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.projects-fact-toolbar-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.projects-fact-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.projects-fact-filter-field--search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
max-width: 360px;
|
||||
position: relative;
|
||||
}
|
||||
.projects-fact-filter-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.projects-fact-filter-field input,
|
||||
.projects-fact-filter-field select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.projects-fact-filter-field--search input {
|
||||
padding-left: 34px;
|
||||
background: #fff;
|
||||
}
|
||||
.projects-fact-search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 9px;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.projects-fact-filter-field input:focus,
|
||||
.projects-fact-filter-field select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
.projects-fact-filter-field select {
|
||||
min-width: 108px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.projects-fact-filter-toggles {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.projects-fact-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.projects-fact-toggle input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.projects-fact-toggle span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.projects-fact-toggle:hover span {
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
.projects-fact-toggle input:focus-visible + span {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.projects-fact-toggle input:checked + span {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.projects-fact-filter-field--search {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.projects-fact-filter-toggles {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.projects-filter-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.projects-pin-toggle {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.projects-panel-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
@@ -21374,33 +21657,28 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.projects-panel--settings {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
||||
.projects-settings-layout {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
.projects-settings-intro {
|
||||
padding: 18px 24px 14px;
|
||||
flex-shrink: 0;
|
||||
padding: 14px 20px 12px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.projects-settings-intro-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.projects-settings-intro-hint {
|
||||
margin: 4px 0 0;
|
||||
@@ -21410,35 +21688,37 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
}
|
||||
.projects-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||||
grid-template-rows: minmax(min-content, 1fr);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
padding: 20px 24px;
|
||||
padding: 14px 20px;
|
||||
flex: 1 1 auto;
|
||||
min-height: min-content;
|
||||
}
|
||||
.projects-settings-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.projects-settings-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
.projects-settings-card--basic {
|
||||
min-height: min-content;
|
||||
}
|
||||
.projects-settings-card--scope {
|
||||
min-height: 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-settings-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
}
|
||||
@@ -21476,16 +21756,14 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
.projects-settings-card-body {
|
||||
padding: 18px 20px 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.projects-settings-card-body--fill {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-scope-toolbar {
|
||||
display: flex;
|
||||
@@ -21505,25 +21783,29 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #0f172a;
|
||||
}
|
||||
.projects-scope-editor {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-height: 120px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1e293b;
|
||||
background: #0f172a;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.projects-scope-textarea {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
.projects-panel--settings .projects-scope-textarea {
|
||||
flex: 1 1 auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 132px;
|
||||
max-height: none;
|
||||
resize: vertical;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: #0f172a !important;
|
||||
color: #e2e8f0 !important;
|
||||
padding: 14px 16px !important;
|
||||
padding: 12px 14px !important;
|
||||
font-size: 0.8125rem !important;
|
||||
line-height: 1.6 !important;
|
||||
box-shadow: none !important;
|
||||
@@ -21561,32 +21843,36 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.projects-settings-card--danger {
|
||||
flex-shrink: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin: 0 24px 16px;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #fffbfb 0%, #fff 50%);
|
||||
height: auto;
|
||||
margin: 0 20px 16px;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(135deg, #fffbfb 0%, #fff 55%);
|
||||
border-color: #fecaca;
|
||||
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.06);
|
||||
}
|
||||
.projects-settings-card--danger:hover {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
.projects-settings-danger-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-icon--red {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-card-title {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-card-hint {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
.projects-settings-card-title {
|
||||
font-size: 0.9375rem;
|
||||
@@ -21602,22 +21888,28 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-settings-danger-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.projects-settings-danger-actions .btn-small {
|
||||
min-height: 34px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
.projects-settings-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 24px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 -1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.projects-settings-footer-hint {
|
||||
font-size: 0.8125rem;
|
||||
@@ -21651,33 +21943,35 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
@media (max-width: 960px) {
|
||||
.projects-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
grid-template-rows: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.projects-settings-card--scope {
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-form-row--2 {
|
||||
grid-template-columns: 1fr;
|
||||
.projects-settings-card {
|
||||
height: auto;
|
||||
}
|
||||
.projects-settings-card--danger {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0 16px 12px;
|
||||
margin: 0 16px 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.projects-settings-danger-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.projects-form-row--2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.projects-settings-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.projects-settings-footer .btn-primary {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.projects-settings-intro {
|
||||
padding: 14px 16px 12px;
|
||||
padding: 12px 16px 10px;
|
||||
}
|
||||
}
|
||||
.projects-form-field {
|
||||
@@ -21969,6 +22263,56 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
body.projects-modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
.fact-detail-prev-wrap {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
}
|
||||
.fact-detail-prev-title,
|
||||
.fact-detail-current-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fact-detail-body--muted {
|
||||
opacity: 0.85;
|
||||
max-height: 200px;
|
||||
}
|
||||
.projects-modal-footer--split {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.projects-modal-footer-left,
|
||||
.projects-modal-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.vulnerability-related-facts {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.vulnerability-related-facts ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.vulnerability-related-facts li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.vulnerability-related-facts a {
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fact-detail-body {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
@@ -22051,6 +22395,12 @@ body.projects-modal-open {
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.projects-fact-vuln-link {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.6875rem;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vulnerability-filter-field--project select {
|
||||
min-width: 120px;
|
||||
max-width: 160px;
|
||||
@@ -22064,13 +22414,76 @@ body.projects-modal-open {
|
||||
.chat-project-panel {
|
||||
width: 280px;
|
||||
}
|
||||
/* 列表 + 底部按钮共用同一内容宽度,避免滚动条缩进导致左右不齐 */
|
||||
.chat-project-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-project-panel .role-selection-list-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: min(360px, 50vh);
|
||||
padding-right: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-project-panel .role-selection-item-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-project-panel-loading,
|
||||
.chat-project-panel-empty {
|
||||
padding: 16px 14px;
|
||||
padding: 16px 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
.chat-project-panel-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
width: 100%;
|
||||
}
|
||||
.chat-project-panel .role-selection-item-main.chat-project-panel-create-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1.5px dashed rgba(99, 102, 241, 0.45);
|
||||
border-radius: 12px;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
color: #4f46e5;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.chat-project-panel .role-selection-item-main.chat-project-panel-create-btn:hover,
|
||||
.chat-project-panel .role-selection-item-main.chat-project-panel-create-btn:focus-visible,
|
||||
.chat-project-panel .role-selection-item-main.chat-project-panel-create-btn:active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.65);
|
||||
color: #4338ca;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.chat-project-panel-create-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chat-project-panel-create-label {
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
#page-projects .page-content.projects-page-layout {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"chat": "Chat",
|
||||
"infoCollect": "Recon",
|
||||
"tasks": "Tasks",
|
||||
"projects": "Projects",
|
||||
"vulnerabilities": "Vulnerabilities",
|
||||
"webshell": "WebShell Management",
|
||||
"chatFiles": "File Management",
|
||||
@@ -222,6 +223,179 @@
|
||||
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
|
||||
"startScanBtn": "Go to chat to scan"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
"showArchived": "Show archived",
|
||||
"newProjectCta": "+ New project",
|
||||
"projectList": "Project list",
|
||||
"searchProjectsPlaceholder": "Search projects…",
|
||||
"selectOrCreateTitle": "Select or create a project",
|
||||
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
|
||||
"createFirstProject": "Create first project",
|
||||
"defaultProjectName": "Project",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"vulnerabilityManagement": "Vulnerability management",
|
||||
"addFactCta": "+ Add fact",
|
||||
"tabFacts": "Fact board",
|
||||
"tabConversations": "Bound conversations",
|
||||
"tabVulns": "Related vulnerabilities",
|
||||
"tabSettings": "Settings",
|
||||
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
|
||||
"searchFactsSr": "Search facts",
|
||||
"searchFactsPlaceholder": "Search key, summary, body…",
|
||||
"category": "Category",
|
||||
"all": "All",
|
||||
"confidence": "Confidence",
|
||||
"confidenceConfirmed": "Confirmed",
|
||||
"confidenceTentative": "Tentative",
|
||||
"confidenceDeprecated": "Deprecated",
|
||||
"displayOptions": "Display options",
|
||||
"sparseOnly": "Sparse only",
|
||||
"hideDeprecated": "Hide deprecated",
|
||||
"summary": "Summary",
|
||||
"updated": "Updated",
|
||||
"boundConversationsHint": "Conversations bound to this project; click to open",
|
||||
"titleLabel": "Title",
|
||||
"projectVulnSummaryHint": "Vulnerability summary under this project",
|
||||
"viewInVulnerabilityManagement": "View in vulnerability management",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"modalNewTitle": "New project",
|
||||
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
|
||||
"projectName": "Project name",
|
||||
"projectNamePlaceholder": "e.g. Client A Web pentest",
|
||||
"projectDescription": "Project description",
|
||||
"projectDescriptionPlaceholder": "Scope, authorization boundary, notes…",
|
||||
"createProject": "Create project",
|
||||
"newProject": "New project",
|
||||
"chatSelectorButton": "Share fact board across chats after binding a project",
|
||||
"selectProject": "Select project",
|
||||
"noProject": "No project",
|
||||
"factBodyEnvTitle": "Environment fact",
|
||||
"factBodyHasDetail": "Has details",
|
||||
"factBodySparseTitle": "Missing attack-chain/POC structure",
|
||||
"factBodySparse": "Incomplete",
|
||||
"factBodyReproducibleTitle": "Contains reproducible structure",
|
||||
"factBodyReproducible": "Reproducible",
|
||||
"factHintAttackSparse": "Attack-chain fact: fill complete body (steps, HTTP/command, response evidence); avoid conclusion-only notes. You can insert the attack-chain template.",
|
||||
"factHintAttackReady": "Attack-chain fact: body is used for audit reproduction, keep original request/response and step-by-step flow.",
|
||||
"factHintEnv": "Environment fact: body should include evidence source; for findings/exploitation use finding|chain|exploit|poc category.",
|
||||
"confirmOverwriteBodyTemplate": "Overwrite current body content with template?",
|
||||
"loadProjectsFailed": "Failed to load projects",
|
||||
"restoreTitle": "Restore as tentative and re-index into board",
|
||||
"restore": "Restore",
|
||||
"deprecateTitle": "Mark as deprecated",
|
||||
"deprecate": "Deprecate",
|
||||
"editTitle": "Edit fields",
|
||||
"viewBodyTitle": "View full body",
|
||||
"details": "Details",
|
||||
"deleteForeverTitle": "Delete permanently",
|
||||
"noProjects": "No projects",
|
||||
"noMatchingProjects": "No matching projects",
|
||||
"pinned": "Pinned",
|
||||
"archived": "Archived",
|
||||
"statsFacts": "{{count}} facts",
|
||||
"statsVulns": "{{count}} vulnerabilities",
|
||||
"statsConversations": "{{count}} conversations",
|
||||
"statsSparse": "{{count}} incomplete",
|
||||
"projectNotFound": "Project not found",
|
||||
"updatedPrefix": "Updated {{time}}",
|
||||
"noMatchingFacts": "No matching facts, try adjusting filters",
|
||||
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
|
||||
"relatedVulnIdTitle": "Related vulnerability ID",
|
||||
"noBoundConversations": "No bound conversations yet; select this project in chat to bind",
|
||||
"untitledConversation": "Untitled conversation",
|
||||
"open": "Open",
|
||||
"unbindProjectTitle": "Unbind project",
|
||||
"unbind": "Unbind",
|
||||
"confirmUnbindConversation": "Unbind this conversation from current project?",
|
||||
"unbindFailed": "Unbind failed",
|
||||
"factMetaCategory": "Category: {{value}}",
|
||||
"factMetaConfidence": "Confidence: {{value}}",
|
||||
"factMetaUpdated": "Updated: {{time}}",
|
||||
"factMetaRelatedVuln": "Related vulnerability: {{value}}",
|
||||
"factMetaSourceConversation": "Source conversation: {{value}}",
|
||||
"factMetaHasPrevious": "Has previous version",
|
||||
"emptyBody": "(empty body)",
|
||||
"factSparseWarn": "This fact belongs to attack-chain/exploit category, but body lacks reproducible structure (steps, HTTP/command, request/response, etc.). Edit and complete it for audit reproduction.",
|
||||
"factPreviousMeta": "Archived at {{time}} · Summary: {{summary}} · Confidence: {{confidence}}",
|
||||
"loadVulnerabilityListFailed": "Failed to load vulnerability list",
|
||||
"noVulnerabilitiesInProject": "No vulnerabilities in this project yet. Create one first or let Agent record it.",
|
||||
"promptLinkFactToVuln": "Enter index to link fact \"{{factKey}}\":\n\n{{lines}}",
|
||||
"invalidIndex": "Invalid index",
|
||||
"linkFailed": "Link failed",
|
||||
"linkSuccess": "Linked vulnerability",
|
||||
"promptConversationIdForVulnCreate": "Conversation ID is required to create vulnerability (can be source conversation):",
|
||||
"cancelledNoConversationId": "Cancelled: conversation_id not provided",
|
||||
"createVulnerabilityFailed": "Failed to create vulnerability",
|
||||
"createVulnerabilityAndLinkSuccess": "Created vulnerability and linked: {{value}}",
|
||||
"confirmDeprecateFact": "Mark fact {{factKey}} as deprecated?",
|
||||
"operationFailed": "Operation failed",
|
||||
"confirmRestoreFact": "Restore fact {{factKey}}? It will re-enter board index with tentative status.",
|
||||
"noVulnerabilityRecords": "No vulnerability records in this project",
|
||||
"viewRelatedFactsTitle": "View related facts",
|
||||
"facts": "Facts",
|
||||
"loadRelatedFactsFailed": "Failed to load related facts",
|
||||
"noFactsForVulnerability": "This vulnerability has no related facts yet. Link vulnerability or generate vulnerability draft from fact detail.",
|
||||
"promptChooseFactByIndex": "This vulnerability is linked to {{count}} facts. Enter index to view:\n{{lines}}",
|
||||
"enterProjectName": "Please enter project name",
|
||||
"saveFailed": "Save failed",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"scopeNoteAuthorizedWebOnly": "Authorized for Web application layer testing only",
|
||||
"invalidScopeJson": "Invalid scope JSON, please fix it first or click Format",
|
||||
"saved": "Saved",
|
||||
"confirmArchiveProject": "After archiving, this project is hidden from active list by default. Continue?",
|
||||
"confirmRestoreProjectActive": "Restore to active?",
|
||||
"confirmDeleteProject": "Delete this project? Facts will be deleted and conversations unbound.",
|
||||
"deleteFailed": "Delete failed",
|
||||
"addFact": "Add fact",
|
||||
"saveFact": "Save fact",
|
||||
"editFact": "Edit fact",
|
||||
"saveChanges": "Save changes",
|
||||
"customCategoryOption": "{{value}} (custom)",
|
||||
"selectProjectFirst": "Please select a project first",
|
||||
"loadFactFailed": "Failed to load fact",
|
||||
"factKeySummaryRequired": "fact_key and summary are required",
|
||||
"confirmSaveSparseFact": "This fact is attack-chain/exploit related, but body does not contain reproducible structure (steps, HTTP/command, request/response).\nSave anyway? It's recommended to insert attack-chain template and fill POC first.",
|
||||
"confirmDeleteFact": "Delete this fact?",
|
||||
"notUpdatedYet": "Not updated yet",
|
||||
"clearStaleProjectBindingFailed": "Failed to clear stale project binding",
|
||||
"noProjectDescription": "No project binding",
|
||||
"noProjectsClickCreate": "No projects yet, click New project below",
|
||||
"sharedFactBoard": "Shared fact board",
|
||||
"loadFailedRetry": "Load failed, please retry later",
|
||||
"projectBound": "Project bound",
|
||||
"projectUnbound": "Project unbound",
|
||||
"updateProjectBindingFailed": "Failed to update project binding",
|
||||
"basicInfoTitle": "Basic information",
|
||||
"basicInfoHint": "Name and description are shown in project details",
|
||||
"settingsIntroTitle": "Project settings",
|
||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||
"pinProject": "Pin project (show first in list)",
|
||||
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
||||
"scopeTitle": "Test scope",
|
||||
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
||||
"formatJson": "Format",
|
||||
"example": "Example",
|
||||
"scopeJsonLabel": "Scope JSON",
|
||||
"scopeFootnote": "Supports targets, exclude, notes and more. Empty means no scope limit.",
|
||||
"dangerZoneTitle": "Danger zone",
|
||||
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
|
||||
"archiveRestore": "Archive / Restore",
|
||||
"deleteProject": "Delete project",
|
||||
"saveChangesHint": "Click save to sync changes to server",
|
||||
"saveSettings": "Save changes",
|
||||
"factModalSubtitle": "Summary is indexed on board; body stores attack chain and POC for audit reproduction (separate from vulnerability records).",
|
||||
"relatedVulnIdLabel": "Related vulnerability ID",
|
||||
"optional": "Optional",
|
||||
"factDetails": "Fact details",
|
||||
"previousVersion": "Previous version",
|
||||
"currentVersion": "Current version",
|
||||
"linkVulnerability": "Link vulnerability",
|
||||
"createVulnerabilityDraft": "Create vulnerability draft",
|
||||
"generatedFromFact": "Generated from project fact {{factKey}}"
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "New chat",
|
||||
"toggleConversationPanel": "Collapse/expand conversation list",
|
||||
@@ -296,6 +470,8 @@
|
||||
"einoAgentReplyTitle": "Sub-agent reply",
|
||||
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
||||
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
|
||||
"einoRunRetryTitle": "🔁 Transient error retry",
|
||||
"einoRunRetryErrorDetail": "Error detail",
|
||||
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
|
||||
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
|
||||
"einoPendingOrphanedTitle": "🧹 Tool call reconciliation",
|
||||
@@ -310,6 +486,9 @@
|
||||
"loadFailedRetry": "Load failed, please retry",
|
||||
"dataFormatError": "Data format error",
|
||||
"progressInProgress": "Penetration test in progress...",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"scrollToBottomHasNew": "↓ New content below",
|
||||
"scrollToBottomNew": "↓ {{count}} new update(s)",
|
||||
"executionFailed": "Execution failed",
|
||||
"penetrationTestComplete": "Penetration test complete",
|
||||
"yesterday": "Yesterday",
|
||||
@@ -2094,6 +2273,9 @@
|
||||
"role": "Role",
|
||||
"defaultRole": "Default",
|
||||
"roleHint": "Select a role; all tasks will be executed using that role's configuration (prompt and tools).",
|
||||
"project": "Project",
|
||||
"projectNone": "(Unbound)",
|
||||
"projectHint": "Optionally bind this queue to a project; leave empty to keep it unbound.",
|
||||
"agentMode": "Agent mode",
|
||||
"agentModeSingle": "Single-agent (ReAct)",
|
||||
"agentModeMulti": "Multi-agent (Eino)",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"chat": "对话",
|
||||
"infoCollect": "信息收集",
|
||||
"tasks": "任务管理",
|
||||
"projects": "项目管理",
|
||||
"vulnerabilities": "漏洞管理",
|
||||
"webshell": "WebShell管理",
|
||||
"chatFiles": "文件管理",
|
||||
@@ -211,6 +212,179 @@
|
||||
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
|
||||
"startScanBtn": "前往对话发起扫描"
|
||||
},
|
||||
"projects": {
|
||||
"title": "项目管理",
|
||||
"showArchived": "显示已归档",
|
||||
"newProjectCta": "+ 新建项目",
|
||||
"projectList": "项目列表",
|
||||
"searchProjectsPlaceholder": "搜索项目…",
|
||||
"selectOrCreateTitle": "选择或创建项目",
|
||||
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
||||
"createFirstProject": "创建第一个项目",
|
||||
"defaultProjectName": "项目",
|
||||
"statusActive": "进行中",
|
||||
"statusArchived": "已归档",
|
||||
"vulnerabilityManagement": "漏洞管理",
|
||||
"addFactCta": "+ 添加事实",
|
||||
"tabFacts": "事实黑板",
|
||||
"tabConversations": "关联对话",
|
||||
"tabVulns": "关联漏洞",
|
||||
"tabSettings": "设置",
|
||||
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
|
||||
"searchFactsSr": "搜索事实",
|
||||
"searchFactsPlaceholder": "搜索 key、摘要、body…",
|
||||
"category": "分类",
|
||||
"all": "全部",
|
||||
"confidence": "置信度",
|
||||
"confidenceConfirmed": "已确认",
|
||||
"confidenceTentative": "待确认",
|
||||
"confidenceDeprecated": "已废弃",
|
||||
"displayOptions": "显示选项",
|
||||
"sparseOnly": "仅待补全",
|
||||
"hideDeprecated": "隐藏废弃",
|
||||
"summary": "摘要",
|
||||
"updated": "更新",
|
||||
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
|
||||
"titleLabel": "标题",
|
||||
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
|
||||
"viewInVulnerabilityManagement": "在漏洞管理中查看",
|
||||
"severity": "严重度",
|
||||
"status": "状态",
|
||||
"modalNewTitle": "新建项目",
|
||||
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
|
||||
"projectName": "项目名称",
|
||||
"projectNamePlaceholder": "例如:某客户 Web 渗透",
|
||||
"projectDescription": "项目描述",
|
||||
"projectDescriptionPlaceholder": "测试范围、授权边界、注意事项…",
|
||||
"createProject": "创建项目",
|
||||
"newProject": "新建项目",
|
||||
"chatSelectorButton": "绑定项目后共享事实黑板(跨对话)",
|
||||
"selectProject": "选择项目",
|
||||
"noProject": "无项目",
|
||||
"factBodyEnvTitle": "环境类事实",
|
||||
"factBodyHasDetail": "有详情",
|
||||
"factBodySparseTitle": "缺少攻击链/POC 结构",
|
||||
"factBodySparse": "待补全",
|
||||
"factBodyReproducibleTitle": "含可复现结构",
|
||||
"factBodyReproducible": "可复现",
|
||||
"factHintAttackSparse": "⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。",
|
||||
"factHintAttackReady": "攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。",
|
||||
"factHintEnv": "环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。",
|
||||
"confirmOverwriteBodyTemplate": "将覆盖当前 body 内容为模板,是否继续?",
|
||||
"loadProjectsFailed": "加载项目失败",
|
||||
"restoreTitle": "恢复为待确认并重新进入黑板索引",
|
||||
"restore": "恢复",
|
||||
"deprecateTitle": "标记为已废弃",
|
||||
"deprecate": "废弃",
|
||||
"editTitle": "编辑各字段",
|
||||
"viewBodyTitle": "查看完整 body",
|
||||
"details": "详情",
|
||||
"deleteForeverTitle": "永久删除",
|
||||
"noProjects": "暂无项目",
|
||||
"noMatchingProjects": "无匹配项目",
|
||||
"pinned": "置顶",
|
||||
"archived": "归档",
|
||||
"statsFacts": "{{count}} 条事实",
|
||||
"statsVulns": "{{count}} 个漏洞",
|
||||
"statsConversations": "{{count}} 个对话",
|
||||
"statsSparse": "{{count}} 待补全",
|
||||
"projectNotFound": "项目不存在",
|
||||
"updatedPrefix": "更新于 {{time}}",
|
||||
"noMatchingFacts": "无匹配事实,请调整筛选条件",
|
||||
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
|
||||
"relatedVulnIdTitle": "关联漏洞 ID",
|
||||
"noBoundConversations": "暂无绑定对话;在对话页选择本项目即可关联",
|
||||
"untitledConversation": "未命名对话",
|
||||
"open": "打开",
|
||||
"unbindProjectTitle": "解除项目绑定",
|
||||
"unbind": "解绑",
|
||||
"confirmUnbindConversation": "解除该对话与当前项目的绑定?",
|
||||
"unbindFailed": "解绑失败",
|
||||
"factMetaCategory": "分类: {{value}}",
|
||||
"factMetaConfidence": "置信度: {{value}}",
|
||||
"factMetaUpdated": "更新: {{time}}",
|
||||
"factMetaRelatedVuln": "关联漏洞: {{value}}",
|
||||
"factMetaSourceConversation": "来源对话: {{value}}",
|
||||
"factMetaHasPrevious": "含上一版本",
|
||||
"emptyBody": "(无 body)",
|
||||
"factSparseWarn": "⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。",
|
||||
"factPreviousMeta": "归档于 {{time}} · 摘要: {{summary}} · 置信度: {{confidence}}",
|
||||
"loadVulnerabilityListFailed": "加载漏洞列表失败",
|
||||
"noVulnerabilitiesInProject": "本项目暂无漏洞,请先创建或让 Agent 记录漏洞",
|
||||
"promptLinkFactToVuln": "输入序号以关联事实「{{factKey}}」:\n\n{{lines}}",
|
||||
"invalidIndex": "序号无效",
|
||||
"linkFailed": "关联失败",
|
||||
"linkSuccess": "已关联漏洞",
|
||||
"promptConversationIdForVulnCreate": "创建漏洞需要对话 ID(可与来源会话一致):",
|
||||
"cancelledNoConversationId": "已取消:未提供 conversation_id",
|
||||
"createVulnerabilityFailed": "创建漏洞失败",
|
||||
"createVulnerabilityAndLinkSuccess": "已创建漏洞并关联:{{value}}",
|
||||
"confirmDeprecateFact": "将事实 {{factKey}} 标记为已废弃?",
|
||||
"operationFailed": "操作失败",
|
||||
"confirmRestoreFact": "恢复事实 {{factKey}}?将重新进入黑板索引(状态:待确认)。",
|
||||
"noVulnerabilityRecords": "本项目暂无漏洞记录",
|
||||
"viewRelatedFactsTitle": "查看关联事实",
|
||||
"facts": "事实",
|
||||
"loadRelatedFactsFailed": "加载关联事实失败",
|
||||
"noFactsForVulnerability": "该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接",
|
||||
"promptChooseFactByIndex": "该漏洞关联 {{count}} 条事实,输入序号查看:\n{{lines}}",
|
||||
"enterProjectName": "请输入项目名称",
|
||||
"saveFailed": "保存失败",
|
||||
"invalidJson": "JSON 格式无效",
|
||||
"scopeNoteAuthorizedWebOnly": "仅授权 Web 应用层测试",
|
||||
"invalidScopeJson": "测试范围 JSON 无效,请先修正或点击「格式化」",
|
||||
"saved": "已保存",
|
||||
"confirmArchiveProject": "归档后默认不再出现在活跃列表,是否继续?",
|
||||
"confirmRestoreProjectActive": "恢复为 active?",
|
||||
"confirmDeleteProject": "确定删除该项目?事实将一并删除,对话将解除绑定。",
|
||||
"deleteFailed": "删除失败",
|
||||
"addFact": "添加事实",
|
||||
"saveFact": "保存事实",
|
||||
"editFact": "编辑事实",
|
||||
"saveChanges": "保存修改",
|
||||
"customCategoryOption": "{{value}}(自定义)",
|
||||
"selectProjectFirst": "请先选择项目",
|
||||
"loadFactFailed": "加载事实失败",
|
||||
"factKeySummaryRequired": "fact_key 与 summary 必填",
|
||||
"confirmSaveSparseFact": "该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。",
|
||||
"confirmDeleteFact": "删除该事实?",
|
||||
"notUpdatedYet": "尚未更新",
|
||||
"clearStaleProjectBindingFailed": "清除失效的项目绑定失败",
|
||||
"noProjectDescription": "不绑定项目黑板",
|
||||
"noProjectsClickCreate": "暂无项目,点击下方「新建项目」",
|
||||
"sharedFactBoard": "共享事实黑板",
|
||||
"loadFailedRetry": "加载失败,请稍后重试",
|
||||
"projectBound": "已绑定项目",
|
||||
"projectUnbound": "已解除项目绑定",
|
||||
"updateProjectBindingFailed": "更新项目绑定失败",
|
||||
"basicInfoTitle": "基本信息",
|
||||
"basicInfoHint": "名称与描述会显示在项目详情中",
|
||||
"settingsIntroTitle": "项目设置",
|
||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||
"pinProject": "置顶项目(列表优先显示)",
|
||||
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
||||
"scopeTitle": "测试范围",
|
||||
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
||||
"formatJson": "格式化",
|
||||
"example": "示例",
|
||||
"scopeJsonLabel": "范围 JSON",
|
||||
"scopeFootnote": "支持 targets、exclude、notes 等字段,留空表示不限制范围。",
|
||||
"dangerZoneTitle": "危险操作",
|
||||
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
|
||||
"archiveRestore": "归档 / 恢复",
|
||||
"deleteProject": "删除项目",
|
||||
"saveChangesHint": "修改后请点击保存以同步到服务器",
|
||||
"saveSettings": "保存更改",
|
||||
"factModalSubtitle": "摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)",
|
||||
"relatedVulnIdLabel": "关联漏洞 ID",
|
||||
"optional": "可选",
|
||||
"factDetails": "事实详情",
|
||||
"previousVersion": "上一版本",
|
||||
"currentVersion": "当前版本",
|
||||
"linkVulnerability": "关联漏洞",
|
||||
"createVulnerabilityDraft": "生成漏洞草稿",
|
||||
"generatedFromFact": "由项目事实 {{factKey}} 生成"
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "新对话",
|
||||
"toggleConversationPanel": "折叠/展开对话列表",
|
||||
@@ -285,6 +459,8 @@
|
||||
"einoAgentReplyTitle": "子代理回复",
|
||||
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
||||
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
|
||||
"einoRunRetryTitle": "🔁 临时错误重试",
|
||||
"einoRunRetryErrorDetail": "具体报错",
|
||||
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
|
||||
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
|
||||
"einoPendingOrphanedTitle": "🧹 工具调用收尾补偿",
|
||||
@@ -299,6 +475,9 @@
|
||||
"loadFailedRetry": "加载失败,请重试",
|
||||
"dataFormatError": "数据格式错误",
|
||||
"progressInProgress": "渗透测试进行中...",
|
||||
"scrollToBottom": "回到底部",
|
||||
"scrollToBottomHasNew": "↓ 有新内容",
|
||||
"scrollToBottomNew": "↓ {{count}} 条新内容",
|
||||
"executionFailed": "执行失败",
|
||||
"penetrationTestComplete": "渗透测试完成",
|
||||
"yesterday": "昨天",
|
||||
@@ -2083,6 +2262,9 @@
|
||||
"role": "角色",
|
||||
"defaultRole": "默认",
|
||||
"roleHint": "选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。",
|
||||
"project": "所属项目",
|
||||
"projectNone": "(未绑定)",
|
||||
"projectHint": "可为队列绑定项目;留空则不绑定项目上下文。",
|
||||
"agentMode": "代理模式",
|
||||
"agentModeSingle": "单代理(ReAct)",
|
||||
"agentModeMulti": "多代理(Eino)",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 主对话区智能粘底滚动:流式输出时自动跟随,用户上滑阅读时不抢焦点。
|
||||
* 主 POST 流(sendMessage)与刷新后 task-events 补流共用同一策略。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
|
||||
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
/** FAB 隐藏:用户已手动滚近底部 */
|
||||
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
|
||||
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
|
||||
const DETACH_LOCK_MS = 280;
|
||||
|
||||
/** @type {'following' | 'detached'} */
|
||||
let scrollMode = 'following';
|
||||
let scrollFollowRaf = 0;
|
||||
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
|
||||
let hasPendingNewBelow = false;
|
||||
let listenersBound = false;
|
||||
let lastScrollTop = 0;
|
||||
let programmaticScroll = false;
|
||||
let detachLockUntil = 0;
|
||||
|
||||
function getChatMessagesEl() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
|
||||
function isStreamActive() {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
if (live && live.active) return true;
|
||||
const replay = window.__csTaskEventStream;
|
||||
return !!(replay && replay.active);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function distanceFromBottom(el) {
|
||||
if (!el) return 0;
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
return scrollHeight - clientHeight - scrollTop;
|
||||
}
|
||||
|
||||
function isNearBottom(thresholdPx) {
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return true;
|
||||
return distanceFromBottom(el) <= thresholdPx;
|
||||
}
|
||||
|
||||
function isChatMessagesPinnedToBottom() {
|
||||
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached) */
|
||||
function resumeFollowingIfAtBottom() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
|
||||
if (scrollMode === 'detached') setScrollFollowing();
|
||||
return true;
|
||||
}
|
||||
|
||||
function captureScrollPinState() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
return scrollMode === 'following';
|
||||
}
|
||||
|
||||
function setScrollFollowing() {
|
||||
scrollMode = 'following';
|
||||
detachLockUntil = 0;
|
||||
hasPendingNewBelow = false;
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
function markPendingNewBelow() {
|
||||
if (scrollMode !== 'detached') return;
|
||||
hasPendingNewBelow = true;
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
function setScrollDetached() {
|
||||
scrollMode = 'detached';
|
||||
detachLockUntil = Date.now() + DETACH_LOCK_MS;
|
||||
cancelAnimationFrame(scrollFollowRaf);
|
||||
if (isStreamActive()) {
|
||||
hasPendingNewBelow = true;
|
||||
}
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
function scrollChatToBottomInstant() {
|
||||
if (scrollMode !== 'following') return;
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
programmaticScroll = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTop = el.scrollTop;
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
});
|
||||
}
|
||||
|
||||
function scrollChatToBottomSmooth() {
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
programmaticScroll = true;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
const node = getChatMessagesEl();
|
||||
if (node) lastScrollTop = node.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
function updateScrollToBottomFab() {
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (!fab) return;
|
||||
|
||||
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
fab.classList.toggle('visible', show);
|
||||
|
||||
let label;
|
||||
if (hasPendingNewBelow) {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottomHasNew')
|
||||
: '↓ 有新内容';
|
||||
} else {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottom')
|
||||
: '回到底部';
|
||||
}
|
||||
fab.setAttribute('aria-label', label);
|
||||
fab.textContent = label;
|
||||
}
|
||||
|
||||
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
if (scrollMode === 'detached') return false;
|
||||
if (wasPinnedBeforeDomUpdate === true) return true;
|
||||
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
function scheduleChatScrollToBottomIfFollowing(wasPinnedBeforeDomUpdate) {
|
||||
if (!canAutoScrollNow(wasPinnedBeforeDomUpdate)) {
|
||||
markPendingNewBelow();
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(scrollFollowRaf);
|
||||
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
|
||||
}
|
||||
|
||||
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
|
||||
function scrollChatMessagesToBottomIfPinned(wasPinned) {
|
||||
scheduleChatScrollToBottomIfFollowing(wasPinned);
|
||||
}
|
||||
|
||||
function forceScrollChatToBottom(smooth) {
|
||||
setScrollFollowing();
|
||||
cancelAnimationFrame(scrollFollowRaf);
|
||||
if (smooth) {
|
||||
scrollChatToBottomSmooth();
|
||||
} else {
|
||||
scrollChatToBottomInstant();
|
||||
}
|
||||
}
|
||||
|
||||
function onUserSendMessage() {
|
||||
setScrollFollowing();
|
||||
scrollChatToBottomInstant();
|
||||
}
|
||||
|
||||
function clearAllStreamingMarkers() {
|
||||
document.querySelectorAll('.progress-container.is-streaming, .process-details-container.is-streaming').forEach(function (el) {
|
||||
el.classList.remove('is-streaming');
|
||||
});
|
||||
}
|
||||
|
||||
function markProgressStreaming(active, progressId) {
|
||||
if (!active) {
|
||||
clearAllStreamingMarkers();
|
||||
return;
|
||||
}
|
||||
if (!progressId) return;
|
||||
const progressEl = document.getElementById(progressId);
|
||||
const container = progressEl && progressEl.querySelector('.progress-container');
|
||||
if (container) container.classList.add('is-streaming');
|
||||
}
|
||||
|
||||
function markProcessDetailsStreaming(active, assistantDomId) {
|
||||
if (!active) {
|
||||
document.querySelectorAll('.process-details-container.is-streaming').forEach(function (el) {
|
||||
el.classList.remove('is-streaming');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!assistantDomId) return;
|
||||
const container = document.getElementById('process-details-' + assistantDomId);
|
||||
if (!container) return;
|
||||
container.classList.add('is-streaming');
|
||||
const timeline = container.querySelector('.progress-timeline');
|
||||
if (timeline) timeline.classList.add('expanded');
|
||||
}
|
||||
|
||||
function onStreamEnd() {
|
||||
clearAllStreamingMarkers();
|
||||
try {
|
||||
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
|
||||
} catch (e) { /* ignore */ }
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
|
||||
function onTaskEventStreamBegin(conversationId, assistantDomId, progressId) {
|
||||
try {
|
||||
window.__csTaskEventStream = {
|
||||
active: true,
|
||||
conversationId: conversationId || null,
|
||||
assistantDomId: assistantDomId || null,
|
||||
progressId: progressId || null
|
||||
};
|
||||
} catch (e) { /* ignore */ }
|
||||
markProcessDetailsStreaming(true, assistantDomId);
|
||||
resumeFollowingIfAtBottom();
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
function onTaskEventStreamEnd() {
|
||||
onStreamEnd();
|
||||
}
|
||||
|
||||
function applyMessageScrollOption(options) {
|
||||
const opt = (options && options.scroll) || 'follow';
|
||||
if (opt === 'none') return;
|
||||
if (opt === 'force') {
|
||||
forceScrollChatToBottom(false);
|
||||
return;
|
||||
}
|
||||
scheduleChatScrollToBottomIfFollowing(captureScrollPinState());
|
||||
}
|
||||
|
||||
/** 流式/用户未跟随时禁止 scrollIntoView 抢滚动 */
|
||||
function scrollElementIntoViewIfFollowing(el, options) {
|
||||
if (!el || !captureScrollPinState()) return;
|
||||
el.scrollIntoView(options || { behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function onChatMessagesScroll() {
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
if (programmaticScroll) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
const st = el.scrollTop;
|
||||
const scrolledUp = st < lastScrollTop - 1;
|
||||
|
||||
if (scrolledUp) {
|
||||
setScrollDetached();
|
||||
} else if (resumeFollowingIfAtBottom()) {
|
||||
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
|
||||
}
|
||||
|
||||
lastScrollTop = st;
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
function bindChatScrollListeners() {
|
||||
if (listenersBound) return;
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
listenersBound = true;
|
||||
lastScrollTop = el.scrollTop;
|
||||
|
||||
el.addEventListener('wheel', function (e) {
|
||||
if (e.deltaY < -1) setScrollDetached();
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('touchmove', function (e) {
|
||||
if (e.touches && e.touches.length === 1) {
|
||||
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
|
||||
if (e.touches[0].clientY > el._csTouchLastY + 4) {
|
||||
setScrollDetached();
|
||||
}
|
||||
el._csTouchLastY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
el.addEventListener('touchstart', function (e) {
|
||||
if (e.touches && e.touches.length) {
|
||||
el._csTouchLastY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
el.addEventListener('touchend', function () {
|
||||
el._csTouchLastY = null;
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
|
||||
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (fab) {
|
||||
fab.addEventListener('click', function () {
|
||||
forceScrollChatToBottom(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initChatScroll() {
|
||||
bindChatScrollListeners();
|
||||
const el = getChatMessagesEl();
|
||||
if (el) lastScrollTop = el.scrollTop;
|
||||
updateScrollToBottomFab();
|
||||
}
|
||||
|
||||
window.CyberStrikeChatScroll = {
|
||||
init: initChatScroll,
|
||||
onUserSendMessage: onUserSendMessage,
|
||||
onStreamEnd: onStreamEnd,
|
||||
onTaskEventStreamBegin: onTaskEventStreamBegin,
|
||||
onTaskEventStreamEnd: onTaskEventStreamEnd,
|
||||
captureScrollPinState: captureScrollPinState,
|
||||
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
|
||||
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
|
||||
forceScrollToBottom: forceScrollChatToBottom,
|
||||
applyMessageScroll: applyMessageScrollOption,
|
||||
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
|
||||
isPinnedToBottom: isChatMessagesPinnedToBottom,
|
||||
markProgressStreaming: markProgressStreaming,
|
||||
markProcessDetailsStreaming: markProcessDetailsStreaming,
|
||||
setScrollFollowing: setScrollFollowing,
|
||||
setScrollDetached: setScrollDetached,
|
||||
};
|
||||
|
||||
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
|
||||
window.captureScrollPinState = captureScrollPinState;
|
||||
window.scrollChatMessagesToBottomIfPinned = scrollChatMessagesToBottomIfPinned;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initChatScroll);
|
||||
} else {
|
||||
initChatScroll();
|
||||
}
|
||||
})();
|
||||
+49
-6
@@ -872,7 +872,10 @@ async function sendMessage() {
|
||||
const displayMessage = hasAttachments
|
||||
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
||||
: message;
|
||||
addMessage('user', displayMessage);
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||
}
|
||||
addMessage('user', displayMessage, null, null, null, { scroll: 'none' });
|
||||
|
||||
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
||||
if (draftSaveTimer) {
|
||||
@@ -930,6 +933,10 @@ async function sendMessage() {
|
||||
|
||||
// 创建进度消息容器(使用详细的进度展示)
|
||||
const progressId = addProgressMessage();
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.markProgressStreaming(true, progressId);
|
||||
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||
}
|
||||
const progressElement = document.getElementById(progressId);
|
||||
registerProgressTask(progressId, currentConversationId);
|
||||
loadActiveTasks();
|
||||
@@ -1007,6 +1014,9 @@ async function sendMessage() {
|
||||
}
|
||||
} finally {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.onStreamEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// 消息发送成功后,再次确保草稿被清除
|
||||
@@ -2149,7 +2159,11 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
messageDiv.setAttribute('data-system-ready-message', '1');
|
||||
}
|
||||
messagesDiv.appendChild(messageDiv);
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.applyMessageScroll(options);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -2465,6 +2479,20 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
itemTitle = agPx + execLine;
|
||||
} else if (eventType === 'eino_agent_reply') {
|
||||
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
|
||||
} else if (eventType === 'eino_run_retry') {
|
||||
itemTitle = typeof window.t === 'function'
|
||||
? window.t('chat.einoRunRetryTitle')
|
||||
: '🔁 临时错误重试';
|
||||
const errRaw = data && data.error != null ? String(data.error).trim() : '';
|
||||
if (errRaw) {
|
||||
const detailLabel = typeof window.t === 'function'
|
||||
? window.t('chat.einoRunRetryErrorDetail')
|
||||
: '错误详情';
|
||||
if (!title || String(title).indexOf(errRaw) === -1) {
|
||||
const merged = title ? (String(title) + '\n' + detailLabel + ':' + errRaw) : (detailLabel + ':' + errRaw);
|
||||
detail.message = merged;
|
||||
}
|
||||
}
|
||||
} else if (eventType === 'knowledge_retrieval') {
|
||||
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
|
||||
} else if (eventType === 'error') {
|
||||
@@ -2912,6 +2940,9 @@ async function startNewConversation() {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
currentConversationGroupId = null; // 新对话不属于任何分组
|
||||
if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
|
||||
ensureDefaultActiveProjectForNewChat().catch(() => {});
|
||||
}
|
||||
if (typeof refreshChatProjectSelector === 'function') {
|
||||
refreshChatProjectSelector();
|
||||
}
|
||||
@@ -3293,7 +3324,11 @@ async function loadConversation(conversationId) {
|
||||
if (offset < rest.length) {
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.forceScrollToBottom(false);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
@@ -3301,7 +3336,11 @@ async function loadConversation(conversationId) {
|
||||
});
|
||||
}
|
||||
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.forceScrollToBottom(false);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
addAttackChainButton(conversationId);
|
||||
await pendingMessageBatches;
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
@@ -3312,8 +3351,12 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
} else {
|
||||
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true, scroll: 'force' });
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
window.CyberStrikeChatScroll.forceScrollToBottom(false);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
addAttackChainButton(conversationId);
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
|
||||
+43
-34
@@ -2068,69 +2068,78 @@ function showToastNotification(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification toast-${type}`;
|
||||
|
||||
// 根据类型设置颜色
|
||||
const typeStyles = {
|
||||
success: {
|
||||
background: '#28a745',
|
||||
color: '#fff',
|
||||
icon: '✅'
|
||||
background: '#f4fbf6',
|
||||
border: '1px solid #cce8d4',
|
||||
color: '#3d6654',
|
||||
iconColor: '#52a06a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M5 8l2 2 4-4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
},
|
||||
error: {
|
||||
background: '#dc3545',
|
||||
color: '#fff',
|
||||
icon: '❌'
|
||||
background: '#fef7f7',
|
||||
border: '1px solid #f3d0d0',
|
||||
color: '#8b4444',
|
||||
iconColor: '#c96a6a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M6 6l4 4M10 6l-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
},
|
||||
info: {
|
||||
background: '#17a2b8',
|
||||
color: '#fff',
|
||||
icon: 'ℹ️'
|
||||
background: '#f5f9ff',
|
||||
border: '1px solid #cfe0f5',
|
||||
color: '#4a6078',
|
||||
iconColor: '#6b8fbf',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 7v4M8 5.5v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
},
|
||||
warning: {
|
||||
background: '#ffc107',
|
||||
color: '#000',
|
||||
icon: '⚠️'
|
||||
background: '#fffbf3',
|
||||
border: '1px solid #f0dfc0',
|
||||
color: '#7a6535',
|
||||
iconColor: '#c4a04a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.5l6 10.5H2L8 2.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M8 7v2.5M8 11v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const style = typeStyles[type] || typeStyles.info;
|
||||
|
||||
|
||||
toast.style.cssText = `
|
||||
background: ${style.background};
|
||||
border: ${style.border};
|
||||
color: ${style.color};
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
pointer-events: auto;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
animation: slideInRight 0.25s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
gap: 10px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
backdrop-filter: blur(8px);
|
||||
`;
|
||||
|
||||
|
||||
toast.innerHTML = `
|
||||
<span style="font-size: 1.2em; flex-shrink: 0;">${style.icon}</span>
|
||||
<span style="color: ${style.iconColor}; flex-shrink: 0; display: flex; align-items: center;">${style.icon}</span>
|
||||
<span style="flex: 1;">${escapeHtml(message)}</span>
|
||||
<button onclick="this.parentElement.remove()" style="
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${style.color};
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
opacity: 0.45;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'">×</button>
|
||||
" onmouseover="this.style.opacity='0.75'" onmouseout="this.style.opacity='0.45'">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
@@ -2156,7 +2165,7 @@ if (!document.getElementById('toast-notification-styles')) {
|
||||
style.textContent = `
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -2170,7 +2179,7 @@ if (!document.getElementById('toast-notification-styles')) {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+149
-49
@@ -208,23 +208,48 @@ if (typeof window !== 'undefined') {
|
||||
window.formatTimelineStreamBody = formatTimelineStreamBody;
|
||||
}
|
||||
|
||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态。
|
||||
// 键必须带 progressId 作用域,避免不同任务复用相同 toolCallId 时串线。
|
||||
const toolCallStatusMap = new Map();
|
||||
|
||||
function toolCallMapKey(progressId, toolCallId) {
|
||||
return String(progressId) + '::' + String(toolCallId);
|
||||
}
|
||||
|
||||
function getToolCallMapping(progressId, toolCallId) {
|
||||
if (!toolCallId) return null;
|
||||
const scoped = toolCallStatusMap.get(toolCallMapKey(progressId, toolCallId));
|
||||
if (scoped) return scoped;
|
||||
// 兼容历史遗留:若 map 中还有旧格式 key(仅 toolCallId),兜底读取。
|
||||
return toolCallStatusMap.get(String(toolCallId)) || null;
|
||||
}
|
||||
|
||||
function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
||||
if (!progressId) return;
|
||||
const pid = String(progressId);
|
||||
for (const [toolCallId, mapping] of Array.from(toolCallStatusMap.entries())) {
|
||||
for (const [mapKey, mapping] of Array.from(toolCallStatusMap.entries())) {
|
||||
if (!mapping) continue;
|
||||
if (mapping.progressId != null && String(mapping.progressId) !== pid) continue;
|
||||
updateToolCallStatus(toolCallId, finalStatus);
|
||||
toolCallStatusMap.delete(toolCallId);
|
||||
const tcid = mapping.toolCallId || (String(mapKey).includes('::') ? String(mapKey).split('::').slice(1).join('::') : String(mapKey));
|
||||
updateToolCallStatus(mapping.progressId || progressId, tcid, finalStatus);
|
||||
toolCallStatusMap.delete(mapKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||
const responseStreamStateByProgressId = new Map();
|
||||
|
||||
/** 同一段主通道流式输出(Eino 可能重复 response_start) */
|
||||
function sameMainResponseStreamMeta(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const agentA = String(a.einoAgent != null ? a.einoAgent : '').trim();
|
||||
const agentB = String(b.einoAgent != null ? b.einoAgent : '').trim();
|
||||
if (!agentA || agentA !== agentB) return false;
|
||||
const orchA = String(a.orchestration != null ? a.orchestration : '').trim();
|
||||
const orchB = String(b.orchestration != null ? b.orchestration : '').trim();
|
||||
return orchA === orchB;
|
||||
}
|
||||
|
||||
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
|
||||
const thinkingStreamStateByProgressId = new Map();
|
||||
|
||||
@@ -519,23 +544,6 @@ function isConversationTaskRunning(conversationId) {
|
||||
return conversationExecutionTracker.isRunning(conversationId);
|
||||
}
|
||||
|
||||
/** 距底部该像素内视为「跟随底部」;流式输出时仅在此情况下自动滚到底部,避免用户上滑查看历史时被强制拉回 */
|
||||
const CHAT_SCROLL_PIN_THRESHOLD_PX = 120;
|
||||
|
||||
/** wasPinned 须在 DOM 追加内容之前计算,否则 scrollHeight 变大后会误判 */
|
||||
function scrollChatMessagesToBottomIfPinned(wasPinned) {
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
if (!messagesDiv || !wasPinned) return;
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
|
||||
function isChatMessagesPinnedToBottom() {
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
if (!messagesDiv) return true;
|
||||
const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
|
||||
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
|
||||
function findProgressIdByConversationId(conversationId) {
|
||||
if (!conversationId) {
|
||||
@@ -777,8 +785,16 @@ function addProgressMessage() {
|
||||
messageDiv.appendChild(contentWrapper);
|
||||
messageDiv.dataset.conversationId = currentConversationId || '';
|
||||
messagesDiv.appendChild(messageDiv);
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
||||
bubble.classList.add('is-streaming');
|
||||
const progressWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: true;
|
||||
if (typeof window.scrollChatMessagesToBottomIfPinned === 'function') {
|
||||
window.scrollChatMessagesToBottomIfPinned(progressWasPinned);
|
||||
} else if (progressWasPinned) {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1075,11 +1091,14 @@ function toggleProcessDetails(progressId, assistantMessageId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到展开的详情位置,而不是滚动到底部
|
||||
// 滚动到展开的详情位置(流式且用户上滑阅读时不抢主列表滚动)
|
||||
if (timeline && timeline.classList.contains('expanded')) {
|
||||
setTimeout(() => {
|
||||
// 使用 scrollIntoView 滚动到详情容器位置
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
|
||||
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
|
||||
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -1166,7 +1185,9 @@ function convertProgressToDetails(progressId, assistantMessageId) {
|
||||
|
||||
// 将详情组件插入到助手消息之后
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const insertWasPinned = isChatMessagesPinnedToBottom();
|
||||
const insertWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||
// assistantElement 是消息div,需要插入到它的下一个兄弟节点之前
|
||||
if (assistantElement.nextSibling) {
|
||||
messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling);
|
||||
@@ -1250,10 +1271,28 @@ function mergeMcpExecutionIDLists(prev, next) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatEinoRunRetryMessage(message, data) {
|
||||
const d = data && typeof data === 'object' ? data : {};
|
||||
const base = String(message || '').trim();
|
||||
const errRaw = d.error != null ? String(d.error).trim() : '';
|
||||
if (!errRaw) {
|
||||
return base;
|
||||
}
|
||||
const detailLabel = typeof window.t === 'function'
|
||||
? window.t('chat.einoRunRetryErrorDetail')
|
||||
: '错误详情';
|
||||
if (base && base.indexOf(errRaw) !== -1) {
|
||||
return base;
|
||||
}
|
||||
return base ? (base + '\n' + detailLabel + ':' + errRaw) : (detailLabel + ':' + errRaw);
|
||||
}
|
||||
|
||||
// 处理流式事件
|
||||
function handleStreamEvent(event, progressElement, progressId,
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||
const streamScrollWasPinned = isChatMessagesPinnedToBottom();
|
||||
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||
|
||||
// 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID
|
||||
if (event.type === 'message_saved') {
|
||||
@@ -1559,6 +1598,20 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
}
|
||||
|
||||
case 'eino_run_retry': {
|
||||
const d = event.data || {};
|
||||
const title = typeof window.t === 'function'
|
||||
? window.t('chat.einoRunRetryTitle')
|
||||
: '🔁 临时错误重试';
|
||||
const msg = formatEinoRunRetryMessage(event.message, d);
|
||||
addTimelineItem(timeline, 'warning', {
|
||||
title: title,
|
||||
message: msg,
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'iteration_limit_reached': {
|
||||
addTimelineItem(timeline, 'warning', {
|
||||
title: typeof window.t === 'function' ? window.t('chat.iterationLimitReachedTitle') : '⛔ 达到迭代上限',
|
||||
@@ -1592,6 +1645,17 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const index = toolInfo.index || 0;
|
||||
const total = toolInfo.total || 0;
|
||||
const toolCallId = toolInfo.toolCallId || null;
|
||||
if (toolCallId) {
|
||||
const existing = getToolCallMapping(progressId, toolCallId);
|
||||
if (existing && existing.itemId) {
|
||||
const existingItem = document.getElementById(existing.itemId);
|
||||
if (existingItem) {
|
||||
// 同一 toolCallId 的重复 tool_call(重试/补发)只更新状态,不重复追加条目。
|
||||
updateToolCallStatus(progressId, toolCallId, 'running');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolCallTitle = formatToolCallTimelineTitle(toolName, index, total);
|
||||
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
|
||||
title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle,
|
||||
@@ -1602,14 +1666,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 如果有toolCallId,存储映射关系以便后续更新状态
|
||||
if (toolCallId && toolCallItemId) {
|
||||
toolCallStatusMap.set(toolCallId, {
|
||||
const mapKey = toolCallMapKey(progressId, toolCallId);
|
||||
toolCallStatusMap.set(mapKey, {
|
||||
toolCallId: toolCallId,
|
||||
itemId: toolCallItemId,
|
||||
timeline: timeline,
|
||||
progressId: progressId
|
||||
});
|
||||
|
||||
// 添加执行中状态指示器
|
||||
updateToolCallStatus(toolCallId, 'running');
|
||||
updateToolCallStatus(progressId, toolCallId, 'running');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1624,7 +1690,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (!deltaText) break;
|
||||
|
||||
if (!state) {
|
||||
const mapping = toolCallStatusMap.get(toolCallId);
|
||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
||||
let callItemId = mapping && mapping.itemId ? mapping.itemId : null;
|
||||
if (callItemId) {
|
||||
const callItem = document.getElementById(callItemId);
|
||||
@@ -1670,24 +1736,26 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
mergeToolResultIntoCallItem(streamCallItem, resultInfo);
|
||||
}
|
||||
toolResultStreamStateByKey.delete(key);
|
||||
if (toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
||||
if (toolCallStatusMap.has(mapKey)) {
|
||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(mapKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (attachToolResultToCall(resultToolCallId, resultInfo)) {
|
||||
if (toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
if (attachToolResultToCall(progressId, resultToolCallId, resultInfo)) {
|
||||
const mapKey = toolCallMapKey(progressId, resultToolCallId);
|
||||
if (toolCallStatusMap.has(mapKey)) {
|
||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(mapKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
if (resultToolCallId && toolCallStatusMap.has(toolCallMapKey(progressId, resultToolCallId))) {
|
||||
updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(toolCallMapKey(progressId, resultToolCallId));
|
||||
}
|
||||
addTimelineItem(timeline, 'tool_result', {
|
||||
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
|
||||
@@ -1889,9 +1957,19 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||
// 同一 progressId 再次 response_start 时先移除旧占位,避免多条「助手输出」卡片且仅最后一条收 delta
|
||||
// 改为保留旧占位,让每一段 response_start 都能在时间线中完整展示。
|
||||
// 创建时间线条目用于显示迭代过程中的输出
|
||||
const prevStream = responseStreamStateByProgressId.get(progressId);
|
||||
if (prevStream && prevStream.itemId && sameMainResponseStreamMeta(prevStream.streamMeta, responseData)) {
|
||||
// Eino 可能对同一段流重复发 response_start;复用已有条目与 buffer,避免多条「助手输出」
|
||||
prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData);
|
||||
responseStreamStateByProgressId.set(progressId, prevStream);
|
||||
break;
|
||||
}
|
||||
if (prevStream && prevStream.itemId) {
|
||||
const oldItem = document.getElementById(prevStream.itemId);
|
||||
if (oldItem && oldItem.parentNode) {
|
||||
oldItem.parentNode.removeChild(oldItem);
|
||||
}
|
||||
}
|
||||
const title = einoMainStreamPlanningTitle(responseData);
|
||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||
title: title,
|
||||
@@ -2256,7 +2334,11 @@ function expandProcessDetailsTimeline(assistantMessageId) {
|
||||
btn.innerHTML = '<span>' + collapseT + '</span>';
|
||||
});
|
||||
setTimeout(function () {
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
|
||||
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
|
||||
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -2442,6 +2524,10 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
const progressId = taskReplayProgressId(conversationId);
|
||||
beginCsTaskReplay(progressId, asEl.id, conversationId);
|
||||
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamBegin === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamBegin(conversationId, asEl.id, progressId);
|
||||
}
|
||||
|
||||
const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
|
||||
const response = await apiFetch(url, {
|
||||
method: 'GET',
|
||||
@@ -2452,6 +2538,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (progressTaskState.has(progressId)) {
|
||||
progressTaskState.delete(progressId);
|
||||
}
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2487,6 +2576,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
||||
}
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
@@ -2495,6 +2587,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
} catch (e) {
|
||||
console.warn('attachRunningTaskEventStream', e);
|
||||
clearCsTaskReplay();
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (taskEventReplayAttachState.inFlightPromise === attachPromise) {
|
||||
@@ -2640,9 +2735,9 @@ function findToolCallItemById(root, toolCallId) {
|
||||
}
|
||||
}
|
||||
|
||||
function attachToolResultToCall(toolCallId, data, options) {
|
||||
function attachToolResultToCall(progressId, toolCallId, data, options) {
|
||||
if (!toolCallId || !data) return false;
|
||||
const mapping = toolCallStatusMap.get(toolCallId);
|
||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
||||
let item = null;
|
||||
if (mapping && mapping.itemId) {
|
||||
item = document.getElementById(mapping.itemId);
|
||||
@@ -2719,8 +2814,8 @@ window.parseToolCallArgsFromData = parseToolCallArgsFromData;
|
||||
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
|
||||
|
||||
// 更新工具调用状态
|
||||
function updateToolCallStatus(toolCallId, status) {
|
||||
const mapping = toolCallStatusMap.get(toolCallId);
|
||||
function updateToolCallStatus(progressId, toolCallId, status) {
|
||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
||||
if (!mapping) return;
|
||||
|
||||
const item = document.getElementById(mapping.itemId);
|
||||
@@ -2901,6 +2996,11 @@ function addTimelineItem(timeline, type, options) {
|
||||
${escapeHtml(options.message || taskCancelledLabel)}
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'warning' && options.message) {
|
||||
const streamBody = typeof formatTimelineStreamBody === 'function'
|
||||
? formatTimelineStreamBody(options.message, options.data)
|
||||
: options.message;
|
||||
content += `<div class="timeline-item-content">${formatMarkdown(streamBody)}</div>`;
|
||||
} else if (type === 'progress' && options.message) {
|
||||
content += `<div class="timeline-item-content timeline-eino-trace"><pre class="tool-result">${escapeHtml(options.message)}</pre></div>`;
|
||||
} else if (type === 'user_interrupt_continue' && options.message) {
|
||||
|
||||
+484
-109
File diff suppressed because it is too large
Load Diff
+39
-1
@@ -812,12 +812,44 @@ const batchQueuesState = {
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
async function refreshBatchProjectSelectOptions() {
|
||||
const projectSelect = document.getElementById('batch-queue-project-id');
|
||||
if (!projectSelect) return;
|
||||
|
||||
const noneLabel = _t('batchImportModal.projectNone');
|
||||
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/projects?status=active&limit=200');
|
||||
if (!response.ok) {
|
||||
throw new Error(_t('projects.loadProjectsFailed'));
|
||||
}
|
||||
const projects = await response.json();
|
||||
const list = Array.isArray(projects) ? projects : [];
|
||||
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
|
||||
|
||||
list.forEach((project) => {
|
||||
if (!project || !project.id) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.name || project.id;
|
||||
if (activeProjectId && project.id === activeProjectId) {
|
||||
option.selected = true;
|
||||
}
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('加载项目列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示新建任务模态框
|
||||
async function showBatchImportModal() {
|
||||
const modal = document.getElementById('batch-import-modal');
|
||||
const input = document.getElementById('batch-tasks-input');
|
||||
const titleInput = document.getElementById('batch-queue-title');
|
||||
const roleSelect = document.getElementById('batch-queue-role');
|
||||
const projectSelect = document.getElementById('batch-queue-project-id');
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
@@ -831,6 +863,9 @@ async function showBatchImportModal() {
|
||||
if (roleSelect) {
|
||||
roleSelect.value = '';
|
||||
}
|
||||
if (projectSelect) {
|
||||
projectSelect.value = '';
|
||||
}
|
||||
if (agentModeSelect) {
|
||||
agentModeSelect.value = 'single';
|
||||
}
|
||||
@@ -872,6 +907,7 @@ async function showBatchImportModal() {
|
||||
console.error('加载角色列表失败:', error);
|
||||
}
|
||||
}
|
||||
await refreshBatchProjectSelectOptions();
|
||||
|
||||
modal.style.display = 'block';
|
||||
input.focus();
|
||||
@@ -935,6 +971,7 @@ async function createBatchQueue() {
|
||||
const input = document.getElementById('batch-tasks-input');
|
||||
const titleInput = document.getElementById('batch-queue-title');
|
||||
const roleSelect = document.getElementById('batch-queue-role');
|
||||
const projectSelect = document.getElementById('batch-queue-project-id');
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
@@ -959,6 +996,7 @@ async function createBatchQueue() {
|
||||
|
||||
// 获取角色(可选,空字符串表示默认角色)
|
||||
const role = roleSelect ? roleSelect.value || '' : '';
|
||||
const projectId = projectSelect ? (projectSelect.value || '').trim() : '';
|
||||
const rawMode = agentModeSelect ? agentModeSelect.value : 'single';
|
||||
const agentMode = isBatchQueueAgentMode(rawMode) ? rawMode : 'single';
|
||||
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
|
||||
@@ -987,7 +1025,7 @@ async function createBatchQueue() {
|
||||
scheduleMode,
|
||||
cronExpr,
|
||||
executeNow,
|
||||
projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '',
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -932,6 +932,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
${vuln.proof ? `<div class="vulnerability-proof"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailProof'))}:</strong><pre>${escapeHtml(vuln.proof)}</pre></div>` : ''}
|
||||
${vuln.impact ? `<div class="vulnerability-impact"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailImpact'))}:</strong> ${escapeHtml(vuln.impact)}</div>` : ''}
|
||||
${vuln.recommendation ? `<div class="vulnerability-recommendation"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailRecommendation'))}:</strong> ${escapeHtml(vuln.recommendation)}</div>` : ''}
|
||||
<div class="vulnerability-related-facts" id="vuln-related-facts-${vuln.id}" data-project-id="${escapeHtml(vuln.project_id || '')}" data-vuln-id="${escapeHtml(vuln.id)}" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1295,12 +1296,76 @@ function toggleVulnerabilityDetails(id) {
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch((e) => console.warn(e));
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开漏洞详情时加载关联项目事实 */
|
||||
async function loadVulnerabilityRelatedFacts(vulnId) {
|
||||
const el = document.getElementById(`vuln-related-facts-${vulnId}`);
|
||||
if (!el || el.dataset.loaded === '1') return;
|
||||
const projectId = (el.dataset.projectId || '').trim();
|
||||
if (!projectId) {
|
||||
el.hidden = true;
|
||||
el.dataset.loaded = '1';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.innerHTML = '<span>加载关联事实…</span>';
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/facts?related_vulnerability_id=${encodeURIComponent(vulnId)}&limit=20&exclude_deprecated=true`,
|
||||
);
|
||||
if (!res.ok) throw new Error('fetch failed');
|
||||
const facts = await res.json();
|
||||
if (!Array.isArray(facts) || !facts.length) {
|
||||
el.innerHTML =
|
||||
'<strong>关联事实</strong><p style="margin:6px 0 0;color:#64748b">暂无;可在「项目管理」事实详情中关联或生成漏洞草稿。</p>';
|
||||
el.dataset.loaded = '1';
|
||||
return;
|
||||
}
|
||||
const items = facts
|
||||
.map((f) => {
|
||||
const key = escapeHtml(f.fact_key);
|
||||
const sum = escapeHtml((f.summary || '').slice(0, 120));
|
||||
const pid = escapeHtml(projectId);
|
||||
const rawKey = escapeHtml(f.fact_key);
|
||||
return `<li><a role="button" href="#" data-project-id="${pid}" data-fact-key="${rawKey}" onclick="event.preventDefault();openProjectFactFromVulnerability(this.dataset.projectId,this.dataset.factKey)"><code>${key}</code></a> — ${sum}</li>`;
|
||||
})
|
||||
.join('');
|
||||
el.innerHTML = `<strong>关联事实(${facts.length})</strong><ul>${items}</ul>`;
|
||||
el.dataset.loaded = '1';
|
||||
} catch (e) {
|
||||
el.innerHTML = '<strong>关联事实</strong><p style="margin:6px 0 0;color:#b91c1c">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function openProjectFactFromVulnerability(projectId, factKey) {
|
||||
if (!projectId || !factKey) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('projects');
|
||||
}
|
||||
setTimeout(async () => {
|
||||
if (typeof window.initProjectsPage === 'function') {
|
||||
await window.initProjectsPage();
|
||||
}
|
||||
if (typeof window.selectProject === 'function') {
|
||||
await window.selectProject(projectId);
|
||||
}
|
||||
if (typeof window.switchProjectTab === 'function') {
|
||||
window.switchProjectTab('facts');
|
||||
}
|
||||
if (typeof window.viewProjectFactBody === 'function') {
|
||||
window.viewProjectFactBody(factKey);
|
||||
}
|
||||
}, 350);
|
||||
}
|
||||
|
||||
window.openProjectFactFromVulnerability = openProjectFactFromVulnerability;
|
||||
|
||||
// HTML转义
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
|
||||
+184
-47
@@ -34,6 +34,7 @@ let webshellDbConfigByConn = {};
|
||||
let webshellDirTreeByConn = {};
|
||||
let webshellDirExpandedByConn = {};
|
||||
let webshellDirLoadedByConn = {};
|
||||
let webshellSelectedFileByConn = {};
|
||||
// 流式打字机效果:当前会话的 response 序号,用于中止过期的打字
|
||||
let webshellStreamingTypingId = 0;
|
||||
let webshellProbeStatusById = {};
|
||||
@@ -70,6 +71,23 @@ function webshellConnOS(conn) {
|
||||
return normalizeWebshellOS(conn && conn.os);
|
||||
}
|
||||
|
||||
/** 生成一次性探活 token,避免固定回显值被包装时误判 */
|
||||
function buildWebshellProbeToken() {
|
||||
return '__CSAI_PROBE_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36) + '__';
|
||||
}
|
||||
|
||||
/** 构造跨 Windows/Linux 都可执行的探活命令 */
|
||||
function buildWebshellProbeCommand(token) {
|
||||
return 'echo ' + token;
|
||||
}
|
||||
|
||||
/** 探活成功判定:HTTP 成功且输出中包含本次 token */
|
||||
function isWebshellProbeOutputMatched(output, token) {
|
||||
if (!token) return false;
|
||||
var text = (output == null) ? '' : String(output);
|
||||
return text.indexOf(token) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 /api/webshell/file 的公共请求体。
|
||||
* 所有文件管理调用点都应走此函数,避免遗漏字段(如 connection_id)。
|
||||
@@ -816,6 +834,7 @@ function probeWebshellConnection(conn) {
|
||||
if (!conn || typeof apiFetch === 'undefined') {
|
||||
return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' });
|
||||
}
|
||||
var probeToken = buildWebshellProbeToken();
|
||||
return apiFetch('/api/webshell/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -827,13 +846,13 @@ function probeWebshellConnection(conn) {
|
||||
cmd_param: conn.cmdParam || '',
|
||||
encoding: webshellConnEncoding(conn),
|
||||
os: webshellConnOS(conn),
|
||||
command: 'echo 1'
|
||||
command: buildWebshellProbeCommand(probeToken)
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var output = (data && data.output != null) ? String(data.output).trim() : '';
|
||||
var ok = !!(data && data.ok && output === '1');
|
||||
var output = (data && data.output != null) ? String(data.output) : '';
|
||||
var ok = !!(data && data.ok && isWebshellProbeOutputMatched(output, probeToken));
|
||||
if (ok) return { ok: true, message: wsT('webshell.testSuccess') || '连通性正常,Shell 可访问' };
|
||||
var msg = (data && data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败');
|
||||
return { ok: false, message: msg };
|
||||
@@ -931,11 +950,61 @@ function normalizeWebshellPath(path) {
|
||||
var p = path == null ? '.' : String(path).trim();
|
||||
if (!p || p === '/') return '.';
|
||||
p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
||||
// Windows 盘符根目录保持为 "C:/",避免被裁成 "C:" 后父级计算异常
|
||||
if (/^[A-Za-z]:\/?$/.test(p)) {
|
||||
return p.slice(0, 2) + '/';
|
||||
}
|
||||
if (!p || p === '.') return '.';
|
||||
if (p.endsWith('/')) p = p.slice(0, -1);
|
||||
return p || '.';
|
||||
}
|
||||
|
||||
function getWebshellSelectedFile(conn) {
|
||||
if (!conn || !conn.id) return '';
|
||||
var p = webshellSelectedFileByConn[conn.id];
|
||||
if (!p) return '';
|
||||
return normalizeWebshellPath(p);
|
||||
}
|
||||
|
||||
function setWebshellSelectedFile(conn, path) {
|
||||
if (!conn || !conn.id) return;
|
||||
if (!path) {
|
||||
delete webshellSelectedFileByConn[conn.id];
|
||||
return;
|
||||
}
|
||||
webshellSelectedFileByConn[conn.id] = normalizeWebshellPath(path);
|
||||
}
|
||||
|
||||
function getWebshellParentPath(path) {
|
||||
var p = normalizeWebshellPath(path);
|
||||
// Windows 盘符根目录不可再上探
|
||||
if (/^[A-Za-z]:\/$/.test(p)) return p;
|
||||
// 允许从当前目录持续上探:. -> .. -> ../.. -> ../../..
|
||||
if (p === '.') return '..';
|
||||
if (/^(?:\.\.\/)*\.\.$/.test(p)) return p + '/..';
|
||||
// 已经是相对上探时,先维持链路;后续 list 成功后会用远端真实路径回填
|
||||
var idx = p.lastIndexOf('/');
|
||||
if (idx < 0) return '.';
|
||||
var parent = p.slice(0, idx) || '.';
|
||||
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
|
||||
return parent;
|
||||
}
|
||||
|
||||
function inferPathFromWindowsDirOutput(rawOutput) {
|
||||
var text = String(rawOutput || '').replace(/\r/g, '');
|
||||
var lines = text.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = String(lines[i] || '').trim();
|
||||
// 中文: C:\xxx 的目录
|
||||
var zh = line.match(/^([A-Za-z]:\\.*)\s+的目录$/);
|
||||
if (zh && zh[1]) return normalizeWebshellPath(zh[1]);
|
||||
// 英文: Directory of C:\xxx
|
||||
var en = line.match(/^Directory of\s+([A-Za-z]:\\.*)$/i);
|
||||
if (en && en[1]) return normalizeWebshellPath(en[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getWebshellTerminalSessionKey(connId, sessionId) {
|
||||
if (!connId || !sessionId) return '';
|
||||
return String(connId) + '::' + String(sessionId);
|
||||
@@ -2047,11 +2116,7 @@ function selectWebshell(id, stateReady) {
|
||||
});
|
||||
document.getElementById('webshell-parent-dir').addEventListener('click', function () {
|
||||
const p = (pathInput && pathInput.value.trim()) || '.';
|
||||
if (p === '.' || p === '/') {
|
||||
pathInput.value = '..';
|
||||
} else {
|
||||
pathInput.value = p.replace(/\/[^/]+$/, '') || '.';
|
||||
}
|
||||
pathInput.value = getWebshellParentPath(p);
|
||||
webshellFileListDir(webshellCurrentConn, pathInput.value || '.');
|
||||
});
|
||||
|
||||
@@ -3578,9 +3643,14 @@ function webshellFileListDir(conn, path) {
|
||||
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
|
||||
return;
|
||||
}
|
||||
listEl.dataset.currentPath = path;
|
||||
var normalizedPath = normalizeWebshellPath(path);
|
||||
var inferredPath = inferPathFromWindowsDirOutput(data.output || '');
|
||||
var displayPath = inferredPath || normalizedPath;
|
||||
listEl.dataset.currentPath = displayPath;
|
||||
listEl.dataset.rawOutput = data.output || '';
|
||||
renderFileList(listEl, path, data.output || '', conn);
|
||||
var pathInput = document.getElementById('webshell-file-path');
|
||||
if (pathInput) pathInput.value = displayPath;
|
||||
renderFileList(listEl, displayPath, data.output || '', conn);
|
||||
})
|
||||
.catch(function (err) {
|
||||
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '</div>';
|
||||
@@ -3619,6 +3689,27 @@ function modeToType(mode) {
|
||||
return c;
|
||||
}
|
||||
|
||||
function parseWindowsDirEntry(line) {
|
||||
var m = String(line || '').match(/^(\d{4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2})(?:\s*(AM|PM))?\s+(<[^>]+>|[\d,]+)\s+(.+?)\s*$/i);
|
||||
if (!m) return null;
|
||||
var kind = (m[4] || '').trim();
|
||||
var name = (m[5] || '').trim();
|
||||
if (!name || name === '.' || name === '..') return null;
|
||||
var isDir = /^<(dir|junction|symlinkd)>$/i.test(kind);
|
||||
var size = isDir ? '' : kind.replace(/,/g, '');
|
||||
var mtime = (m[1] + ' ' + m[2] + (m[3] ? (' ' + m[3].toUpperCase()) : '')).trim();
|
||||
return {
|
||||
name: name,
|
||||
isDir: isDir,
|
||||
size: size,
|
||||
mtime: mtime,
|
||||
mode: isDir ? 'd' : '-',
|
||||
owner: '',
|
||||
group: '',
|
||||
type: isDir ? 'dir' : 'file'
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebshellListItems(rawOutput) {
|
||||
var lines = (rawOutput || '').split(/\n/).filter(function (l) { return l.trim(); });
|
||||
var items = [];
|
||||
@@ -3627,6 +3718,12 @@ function parseWebshellListItems(rawOutput) {
|
||||
var trimmedLine = String(line || '').trim();
|
||||
// `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。
|
||||
if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue;
|
||||
// `dir` 头尾信息(中英文)与 shell 提示符,不是目录项。
|
||||
if (/^(驱动器|卷的序列号是|volume in drive|volume serial number is|directory of)/i.test(trimmedLine)) continue;
|
||||
if (/^[A-Za-z]:\\.*\s+的目录$/i.test(trimmedLine)) continue;
|
||||
if (/^\d+\s+(个文件|file\(s\))\s+[\d,]+\s+(字节|bytes?)$/i.test(trimmedLine)) continue;
|
||||
if (/^\d+\s+(个目录|dir\(s\))\s+[\d,]+\s+(可用字节|bytes free)$/i.test(trimmedLine)) continue;
|
||||
if (/^[^>\n]*>\s*dir(?:\s|$)/i.test(trimmedLine)) continue;
|
||||
var name = '';
|
||||
var isDir = false;
|
||||
var size = '';
|
||||
@@ -3646,16 +3743,38 @@ function parseWebshellListItems(rawOutput) {
|
||||
isDir = mode && mode.startsWith('d');
|
||||
type = modeToType(mode);
|
||||
} else {
|
||||
var mName = line.match(/\s*(\S+)\s*$/);
|
||||
name = mName ? mName[1].trim() : line.trim();
|
||||
if (name === '.' || name === '..') continue;
|
||||
isDir = line.startsWith('d') || line.toLowerCase().indexOf('<dir>') !== -1;
|
||||
if (line.startsWith('-') || line.startsWith('d')) {
|
||||
var parts = line.split(/\s+/);
|
||||
var winItem = parseWindowsDirEntry(line);
|
||||
if (winItem) {
|
||||
items.push({
|
||||
name: winItem.name,
|
||||
isDir: winItem.isDir,
|
||||
line: line,
|
||||
size: winItem.size,
|
||||
mode: winItem.mode,
|
||||
mtime: winItem.mtime,
|
||||
owner: winItem.owner,
|
||||
group: winItem.group,
|
||||
type: winItem.type
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 仅兜底解析 Unix 权限格式,避免把 `dir` 统计行误识别为文件。
|
||||
if (/^[-dlcbsp]/.test(line)) {
|
||||
var parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
name = parts.slice(8).join(' ').trim();
|
||||
} else {
|
||||
name = parts.length ? parts[parts.length - 1].trim() : line.trim();
|
||||
}
|
||||
if (name === '.' || name === '..') continue;
|
||||
isDir = line.startsWith('d');
|
||||
parts = line.split(/\s+/);
|
||||
if (parts.length >= 5) { mode = parts[0]; size = parts[4]; }
|
||||
if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; }
|
||||
if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) mtime = normalizeLsMtime(parts[5], parts[6], parts[7]);
|
||||
type = modeToType(mode);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (name === '.' || name === '..') continue;
|
||||
@@ -3680,7 +3799,9 @@ function fetchWebshellDirectoryItems(conn, path) {
|
||||
}
|
||||
|
||||
function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
||||
currentPath = normalizeWebshellPath(currentPath);
|
||||
var items = parseWebshellListItems(rawOutput);
|
||||
var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn);
|
||||
if (nameFilter && nameFilter.trim()) {
|
||||
var f = nameFilter.trim().toLowerCase();
|
||||
items = items.filter(function (item) { return item.name.toLowerCase().indexOf(f) !== -1; });
|
||||
@@ -3713,10 +3834,11 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
||||
}
|
||||
items.forEach(function (item) {
|
||||
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
|
||||
var pathNextNorm = normalizeWebshellPath(pathNext);
|
||||
var nameClass = item.isDir ? 'is-dir' : 'is-file';
|
||||
html += '<tr><td class="webshell-col-check">';
|
||||
html += '<tr class="' + (!item.isDir && selectedPath === pathNextNorm ? 'webshell-file-row-selected' : '') + '"><td class="webshell-col-check">';
|
||||
if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />';
|
||||
html += '</td><td><a href="#" class="webshell-file-link ' + nameClass + '" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td>';
|
||||
html += '</td><td class="webshell-col-name"><a href="#" class="webshell-file-link ' + nameClass + '" title="' + escapeHtml(item.name) + '" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td>';
|
||||
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
|
||||
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
|
||||
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
|
||||
@@ -3748,10 +3870,13 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
||||
const isDir = a.getAttribute('data-isdir') === '1';
|
||||
const pathInput = document.getElementById('webshell-file-path');
|
||||
if (isDir) {
|
||||
setWebshellSelectedFile(webshellCurrentConn, '');
|
||||
if (pathInput) pathInput.value = path;
|
||||
webshellFileListDir(webshellCurrentConn, path);
|
||||
} else {
|
||||
// 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图
|
||||
setWebshellSelectedFile(webshellCurrentConn, path);
|
||||
renderDirectoryTree(currentPath, items, conn || webshellCurrentConn);
|
||||
webshellFileRead(webshellCurrentConn, path, listEl, currentPath);
|
||||
}
|
||||
});
|
||||
@@ -3759,7 +3884,10 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
||||
listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl, currentPath);
|
||||
var filePath = btn.getAttribute('data-path');
|
||||
setWebshellSelectedFile(webshellCurrentConn, filePath);
|
||||
renderDirectoryTree(currentPath, items, conn || webshellCurrentConn);
|
||||
webshellFileRead(webshellCurrentConn, filePath, listEl, currentPath);
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) {
|
||||
@@ -3821,6 +3949,7 @@ function renderDirectoryTree(currentPath, items, conn) {
|
||||
var tree = state.tree;
|
||||
var expanded = state.expanded;
|
||||
var loaded = state.loaded;
|
||||
var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn);
|
||||
if (!tree['.']) tree['.'] = [];
|
||||
if (expanded['.'] !== false) expanded['.'] = true;
|
||||
|
||||
@@ -3844,26 +3973,29 @@ function renderDirectoryTree(currentPath, items, conn) {
|
||||
if (node.isDir && !tree[node.path]) tree[node.path] = [];
|
||||
});
|
||||
|
||||
// 确保当前路径祖先链存在并展开
|
||||
// 仅对“真实路径”补祖先链;相对上探链(../..)不构建,避免出现假层级。
|
||||
var isRelativeUpChain = /^(?:\.\.\/)*\.\.$/.test(curr);
|
||||
var parts = curr === '.' ? [] : curr.split('/');
|
||||
var parentPath = '.';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i];
|
||||
if (!tree[parentPath]) tree[parentPath] = [];
|
||||
var parentChildren = tree[parentPath];
|
||||
var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; });
|
||||
if (!hasAncestorNode) {
|
||||
parentChildren.push({ path: nextPath, name: parts[i], isDir: true });
|
||||
parentChildren.sort(function (a, b) {
|
||||
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
if (!isRelativeUpChain) {
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i];
|
||||
if (!tree[parentPath]) tree[parentPath] = [];
|
||||
var parentChildren = tree[parentPath];
|
||||
var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; });
|
||||
if (!hasAncestorNode) {
|
||||
parentChildren.push({ path: nextPath, name: parts[i], isDir: true });
|
||||
parentChildren.sort(function (a, b) {
|
||||
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
}
|
||||
if (!tree[nextPath]) tree[nextPath] = [];
|
||||
expanded[parentPath] = true;
|
||||
parentPath = nextPath;
|
||||
}
|
||||
if (!tree[nextPath]) tree[nextPath] = [];
|
||||
expanded[parentPath] = true;
|
||||
parentPath = nextPath;
|
||||
}
|
||||
expanded[curr] = true;
|
||||
if (expanded[curr] == null) expanded[curr] = true;
|
||||
|
||||
function renderNode(node, depth) {
|
||||
var path = node.path;
|
||||
@@ -3872,15 +4004,16 @@ function renderDirectoryTree(currentPath, items, conn) {
|
||||
var hasLoadedChildren = isDir ? (loaded[path] === true) : true;
|
||||
var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0);
|
||||
var hasChildren = children.length > 0;
|
||||
var isExpanded = isDir ? (expanded[path] !== false) : false;
|
||||
var isExpanded = isDir ? (expanded[path] === true) : false;
|
||||
var isActive = path === curr;
|
||||
var isSelectedFile = !isDir && path === selectedPath;
|
||||
var name = node.name;
|
||||
var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄';
|
||||
var nodeHtml =
|
||||
'<div class="webshell-tree-node" data-depth="' + depth + '">' +
|
||||
'<div class="webshell-tree-row' + (isActive ? ' active' : '') + '">' +
|
||||
'<div class="webshell-tree-row' + (isActive ? ' active' : '') + (isSelectedFile ? ' selected-file' : '') + '">' +
|
||||
'<button type="button" class="webshell-tree-toggle' + (canExpand ? '' : ' empty') + '" data-path="' + escapeHtml(path) + '">' + (canExpand ? (isExpanded ? '▾' : '▸') : '·') + '</button>' +
|
||||
'<button type="button" class="webshell-dir-item' + (isDir ? ' is-dir' : ' is-file') + '" data-path="' + escapeHtml(path) + '" data-isdir="' + (isDir ? '1' : '0') + '"><span class="webshell-tree-icon">' + icon + '</span><span class="webshell-tree-name">' + escapeHtml(name) + '</span></button>' +
|
||||
'<button type="button" class="webshell-dir-item' + (isDir ? ' is-dir' : ' is-file') + '" title="' + escapeHtml(name) + '" data-path="' + escapeHtml(path) + '" data-isdir="' + (isDir ? '1' : '0') + '"><span class="webshell-tree-icon">' + icon + '</span><span class="webshell-tree-name">' + escapeHtml(name) + '</span></button>' +
|
||||
'</div>';
|
||||
if (isDir && hasChildren && isExpanded) {
|
||||
nodeHtml += '<div class="webshell-tree-children">';
|
||||
@@ -3899,7 +4032,7 @@ function renderDirectoryTree(currentPath, items, conn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.');
|
||||
if (expanded[p] !== false) {
|
||||
if (expanded[p] === true) {
|
||||
expanded[p] = false;
|
||||
renderDirectoryTree(curr, items, conn || webshellCurrentConn);
|
||||
return;
|
||||
@@ -3939,12 +4072,15 @@ function renderDirectoryTree(currentPath, items, conn) {
|
||||
var isDir = btn.getAttribute('data-isdir') === '1';
|
||||
var pathInput = document.getElementById('webshell-file-path');
|
||||
if (isDir) {
|
||||
setWebshellSelectedFile(webshellCurrentConn, '');
|
||||
if (pathInput) pathInput.value = p;
|
||||
webshellFileListDir(webshellCurrentConn, p);
|
||||
return;
|
||||
}
|
||||
var listEl = document.getElementById('webshell-file-list');
|
||||
var browsePath = p.replace(/\/[^/]+$/, '') || '.';
|
||||
setWebshellSelectedFile(webshellCurrentConn, p);
|
||||
renderDirectoryTree(curr, items, conn || webshellCurrentConn);
|
||||
if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath);
|
||||
});
|
||||
});
|
||||
@@ -4101,7 +4237,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
|
||||
// 兜底:若路径被污染成文件路径,回退到父目录
|
||||
backPath = path.replace(/\/[^/]+$/, '') || '.';
|
||||
}
|
||||
listEl.innerHTML = '<div class="webshell-file-content"><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" id="webshell-file-back-btn" data-back-path="' + escapeHtml(backPath) + '">' + wsT('webshell.back') + '</button></div>';
|
||||
listEl.innerHTML = '<div class="webshell-file-content"><div class="webshell-file-content-path">' + escapeHtml(path) + '</div><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" id="webshell-file-back-btn" data-back-path="' + escapeHtml(backPath) + '">' + wsT('webshell.back') + '</button></div>';
|
||||
var backBtn = document.getElementById('webshell-file-back-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
@@ -4467,7 +4603,7 @@ document.addEventListener('conversation-deleted', function (e) {
|
||||
}
|
||||
});
|
||||
|
||||
// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1)
|
||||
// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行一次性探活命令)
|
||||
function testWebshellConnection() {
|
||||
var url = (document.getElementById('webshell-url') || {}).value;
|
||||
if (url && typeof url.trim === 'function') url = url.trim();
|
||||
@@ -4484,13 +4620,14 @@ function testWebshellConnection() {
|
||||
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
|
||||
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
|
||||
var btn = document.getElementById('webshell-test-btn');
|
||||
var probeToken = buildWebshellProbeToken();
|
||||
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); }
|
||||
alert(wsT('webshell.testFailed') || '连通性测试失败');
|
||||
return;
|
||||
}
|
||||
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价)
|
||||
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo token 在 cmd 和 sh 下行为等价)
|
||||
apiFetch('/api/webshell/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -4502,7 +4639,7 @@ function testWebshellConnection() {
|
||||
cmd_param: cmdParam || '',
|
||||
encoding: encoding,
|
||||
os: osTag,
|
||||
command: 'echo 1'
|
||||
command: buildWebshellProbeCommand(probeToken)
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -4512,14 +4649,14 @@ function testWebshellConnection() {
|
||||
alert(wsT('webshell.testFailed') || '连通性测试失败');
|
||||
return;
|
||||
}
|
||||
// 仅 HTTP 200 不算通过,需校验是否真的执行了 echo 1(响应体 trim 后应为 "1")
|
||||
var output = (data.output != null) ? String(data.output).trim() : '';
|
||||
var reallyOk = data.ok && output === '1';
|
||||
// 仅 HTTP 200 不算通过,需校验响应中是否包含本次一次性探活 token
|
||||
var output = (data.output != null) ? String(data.output) : '';
|
||||
var reallyOk = data.ok && isWebshellProbeOutputMatched(output, probeToken);
|
||||
if (reallyOk) {
|
||||
alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问');
|
||||
} else {
|
||||
var msg;
|
||||
if (data.ok && output !== '1')
|
||||
if (data.ok && !isWebshellProbeOutputMatched(output, probeToken))
|
||||
msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名';
|
||||
else
|
||||
msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败');
|
||||
|
||||
+176
-80
@@ -162,13 +162,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="projects">
|
||||
<div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')">
|
||||
<div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')" data-i18n="nav.projects" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
|
||||
<polyline points="2 17 12 22 22 17"></polyline>
|
||||
<polyline points="2 12 12 17 22 12"></polyline>
|
||||
</svg>
|
||||
<span>项目管理</span>
|
||||
<span data-i18n="nav.projects">项目管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="vulnerabilities">
|
||||
@@ -951,27 +951,36 @@
|
||||
</div>
|
||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||
<div id="chat-messages" class="chat-messages"></div>
|
||||
<button type="button" id="chat-scroll-to-bottom" class="chat-scroll-to-bottom" aria-label="回到底部" title="回到底部">↓ 回到底部</button>
|
||||
<div id="chat-input-container" class="chat-input-container">
|
||||
<div class="chat-input-primary-row">
|
||||
<div class="chat-input-leading">
|
||||
<div class="role-selector-wrapper project-selector-wrapper">
|
||||
<button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)">
|
||||
<button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)" data-i18n="projects.chatSelectorButton" data-i18n-attr="aria-label,title">
|
||||
<span class="role-selector-icon" aria-hidden="true">📁</span>
|
||||
<span id="chat-project-text" class="role-selector-text">无项目</span>
|
||||
<span id="chat-project-text" class="role-selector-text" data-i18n="projects.noProject">无项目</span>
|
||||
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="chat-project-panel" class="role-selection-panel chat-project-panel" style="display: none;" role="listbox" aria-labelledby="chat-project-panel-title">
|
||||
<div class="role-selection-panel-header">
|
||||
<h3 id="chat-project-panel-title" class="role-selection-panel-title">选择项目</h3>
|
||||
<h3 id="chat-project-panel-title" class="role-selection-panel-title" data-i18n="projects.selectProject">选择项目</h3>
|
||||
<button type="button" class="role-selection-panel-close" onclick="closeChatProjectPanel()" title="关闭" aria-label="关闭">
|
||||
<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 id="chat-project-list" class="role-selection-list-main"></div>
|
||||
<div class="chat-project-panel-body">
|
||||
<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()">
|
||||
<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>
|
||||
<span class="chat-project-panel-create-label" data-i18n="projects.newProject">新建项目</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="role-selector-wrapper" class="role-selector-wrapper">
|
||||
@@ -1417,74 +1426,137 @@
|
||||
<!-- 项目管理页面 -->
|
||||
<div id="page-projects" class="page projects-page">
|
||||
<div class="page-header">
|
||||
<h2>项目管理</h2>
|
||||
<h2 data-i18n="projects.title">项目管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<label class="projects-show-archived-label"><input type="checkbox" id="projects-show-archived" onchange="loadProjectsList()"> 显示已归档</label>
|
||||
<button class="btn-secondary" type="button" onclick="loadProjectsList()">刷新</button>
|
||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()">+ 新建项目</button>
|
||||
<label class="projects-show-archived-label"><input type="checkbox" id="projects-show-archived" onchange="loadProjectsList()"> <span data-i18n="projects.showArchived">显示已归档</span></label>
|
||||
<button class="btn-secondary" type="button" onclick="loadProjectsList()" data-i18n="common.refresh">刷新</button>
|
||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.newProjectCta">+ 新建项目</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content projects-page-layout">
|
||||
<aside class="projects-sidebar-card">
|
||||
<div class="projects-sidebar-head">
|
||||
<span class="projects-sidebar-title">项目列表</span>
|
||||
<span class="projects-sidebar-title" data-i18n="projects.projectList">项目列表</span>
|
||||
<span class="projects-sidebar-count" id="projects-list-count">0</span>
|
||||
</div>
|
||||
<div class="projects-sidebar-search">
|
||||
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off">
|
||||
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
<div id="projects-list" class="projects-list"></div>
|
||||
</aside>
|
||||
<main class="projects-detail" id="projects-detail-main">
|
||||
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
||||
<h3>选择或创建项目</h3>
|
||||
<p>项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
|
||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()">创建第一个项目</button>
|
||||
<h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
|
||||
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
|
||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
|
||||
</div>
|
||||
<div class="projects-detail-inner" id="projects-detail-inner" hidden>
|
||||
<header class="projects-detail-header">
|
||||
<div class="projects-detail-header-main">
|
||||
<div class="projects-detail-title-row">
|
||||
<h3 id="projects-detail-title" class="projects-detail-title">项目</h3>
|
||||
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active">进行中</span>
|
||||
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
|
||||
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
|
||||
</div>
|
||||
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
||||
<p id="projects-detail-desc" class="projects-detail-desc"></p>
|
||||
<div class="projects-detail-stats" id="projects-detail-stats">
|
||||
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
|
||||
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
|
||||
<span class="projects-stat-chip" id="project-stat-conversations">0 个对话</span>
|
||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-detail-header-actions">
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">漏洞管理</button>
|
||||
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()">+ 添加事实</button>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
||||
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="projects-tabs" role="tablist">
|
||||
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')">事实黑板</button>
|
||||
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')">关联漏洞</button>
|
||||
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')">设置</button>
|
||||
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
|
||||
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
|
||||
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
|
||||
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
|
||||
</nav>
|
||||
<div id="project-panel-facts" class="projects-panel" role="tabpanel">
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint">索引仅含 key + 摘要(须含「什么+在哪+如何验证」);攻击链/POC 写在 body,Agent 通过 get_project_fact 复现</span>
|
||||
<button class="btn-primary btn-small" type="button" onclick="showAddFactModal()">+ 添加事实</button>
|
||||
<div class="projects-fact-toolbar">
|
||||
<p class="projects-fact-toolbar-hint" role="note">
|
||||
<svg class="projects-fact-toolbar-hint-icon" 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="9" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-i18n="projects.factToolbarHint">索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现</span>
|
||||
</p>
|
||||
<div class="projects-fact-toolbar-filters" role="search">
|
||||
<label class="projects-fact-filter-field projects-fact-filter-field--search">
|
||||
<span class="sr-only" data-i18n="projects.searchFactsSr">搜索事实</span>
|
||||
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input type="search" id="project-facts-search" placeholder="搜索 key、摘要、body…" oninput="debouncedLoadProjectFacts()" autocomplete="off" data-i18n="projects.searchFactsPlaceholder" data-i18n-attr="placeholder">
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label" data-i18n="projects.category">分类</span>
|
||||
<select id="project-facts-filter-category" onchange="loadProjectFacts()">
|
||||
<option value="" data-i18n="projects.all">全部</option>
|
||||
<option value="target">target</option>
|
||||
<option value="auth">auth</option>
|
||||
<option value="infra">infra</option>
|
||||
<option value="business">business</option>
|
||||
<option value="finding">finding</option>
|
||||
<option value="chain">chain</option>
|
||||
<option value="exploit">exploit</option>
|
||||
<option value="poc">poc</option>
|
||||
<option value="note">note</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label" data-i18n="projects.confidence">置信度</span>
|
||||
<select id="project-facts-filter-confidence" onchange="loadProjectFacts()">
|
||||
<option value="" data-i18n="projects.all">全部</option>
|
||||
<option value="confirmed" data-i18n="projects.confidenceConfirmed">已确认</option>
|
||||
<option value="tentative" data-i18n="projects.confidenceTentative">待确认</option>
|
||||
<option value="deprecated" data-i18n="projects.confidenceDeprecated">已废弃</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="projects-fact-filter-toggles" role="group" aria-label="显示选项" data-i18n="projects.displayOptions" data-i18n-attr="aria-label">
|
||||
<label class="projects-fact-toggle">
|
||||
<input type="checkbox" id="project-facts-filter-sparse" onchange="loadProjectFacts()">
|
||||
<span data-i18n="projects.sparseOnly">仅待补全</span>
|
||||
</label>
|
||||
<label class="projects-fact-toggle">
|
||||
<input type="checkbox" id="project-facts-filter-hide-deprecated" checked onchange="loadProjectFacts()">
|
||||
<span data-i18n="projects.hideDeprecated">隐藏废弃</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
<thead><tr><th>Key</th><th>分类</th><th>摘要</th><th>Body</th><th>置信度</th><th>更新</th><th class="col-actions">操作</th></tr></thead>
|
||||
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||
<tbody id="project-facts-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint" data-i18n="projects.boundConversationsHint">绑定到本项目的对话;点击可打开会话</span>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
<thead><tr><th data-i18n="projects.titleLabel">标题</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||
<tbody id="project-conversations-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint">本项目下记录的漏洞汇总</span>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">在漏洞管理中查看</button>
|
||||
<span class="projects-panel-hint" data-i18n="projects.projectVulnSummaryHint">本项目下记录的漏洞汇总</span>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.viewInVulnerabilityManagement">在漏洞管理中查看</button>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
<thead><tr><th>标题</th><th>严重度</th><th>状态</th><th class="col-actions">操作</th></tr></thead>
|
||||
<thead><tr><th data-i18n="projects.titleLabel">标题</th><th data-i18n="projects.severity">严重度</th><th data-i18n="projects.status">状态</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||
<tbody id="project-vulns-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1493,8 +1565,8 @@
|
||||
<div class="projects-settings-layout">
|
||||
<header class="projects-settings-intro">
|
||||
<div class="projects-settings-intro-text">
|
||||
<h4 class="projects-settings-intro-title">项目设置</h4>
|
||||
<p class="projects-settings-intro-hint">配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。</p>
|
||||
<h4 class="projects-settings-intro-title" data-i18n="projects.settingsIntroTitle">项目设置</h4>
|
||||
<p class="projects-settings-intro-hint" data-i18n="projects.settingsIntroHint">配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="projects-settings-grid">
|
||||
@@ -1505,30 +1577,35 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="projects-settings-card-title">基本信息</h4>
|
||||
<p class="projects-settings-card-hint">名称与描述会显示在项目详情中</p>
|
||||
<h4 class="projects-settings-card-title" data-i18n="projects.basicInfoTitle">基本信息</h4>
|
||||
<p class="projects-settings-card-hint" data-i18n="projects.basicInfoHint">名称与描述会显示在项目详情中</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-settings-card-body">
|
||||
<div class="projects-form-row projects-form-row--2">
|
||||
<div class="projects-form-field">
|
||||
<label for="project-edit-name">项目名称</label>
|
||||
<input type="text" id="project-edit-name" class="form-input" placeholder="例如:某客户 Web 渗透">
|
||||
<label for="project-edit-name" data-i18n="projects.projectName">项目名称</label>
|
||||
<input type="text" id="project-edit-name" class="form-input" placeholder="例如:某客户 Web 渗透" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="project-edit-status">状态</label>
|
||||
<label for="project-edit-status" data-i18n="projects.status">状态</label>
|
||||
<div class="projects-status-select-wrap">
|
||||
<select id="project-edit-status" class="form-input projects-status-select">
|
||||
<option value="active">进行中</option>
|
||||
<option value="archived">已归档</option>
|
||||
<option value="active" data-i18n="projects.statusActive">进行中</option>
|
||||
<option value="archived" data-i18n="projects.statusArchived">已归档</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="project-edit-description">描述</label>
|
||||
<textarea id="project-edit-description" class="form-input" rows="4" placeholder="测试目标、授权范围、联系人、注意事项…"></textarea>
|
||||
<label class="projects-filter-check projects-pin-toggle">
|
||||
<input type="checkbox" id="project-edit-pinned"> <span data-i18n="projects.pinProject">置顶项目(列表优先显示)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
|
||||
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1539,21 +1616,21 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="projects-settings-card-title">测试范围</h4>
|
||||
<p class="projects-settings-card-hint">JSON 格式,供 Agent 理解授权边界与目标资产</p>
|
||||
<h4 class="projects-settings-card-title" data-i18n="projects.scopeTitle">测试范围</h4>
|
||||
<p class="projects-settings-card-hint" data-i18n="projects.scopeHint">JSON 格式,供 Agent 理解授权边界与目标资产</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-scope-toolbar">
|
||||
<button type="button" class="btn-ghost btn-small" onclick="formatProjectScopeJson()" title="格式化 JSON">格式化</button>
|
||||
<button type="button" class="btn-ghost btn-small" onclick="insertProjectScopeExample()" title="插入示例">示例</button>
|
||||
<button type="button" class="btn-ghost btn-small" onclick="formatProjectScopeJson()" title="格式化 JSON" data-i18n="projects.formatJson" data-i18n-attr="title">格式化</button>
|
||||
<button type="button" class="btn-ghost btn-small" onclick="insertProjectScopeExample()" title="插入示例" data-i18n="projects.example" data-i18n-attr="title">示例</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-settings-card-body projects-settings-card-body--fill">
|
||||
<div class="projects-scope-editor">
|
||||
<label for="project-edit-scope" class="sr-only">范围 JSON</label>
|
||||
<label for="project-edit-scope" class="sr-only" data-i18n="projects.scopeJsonLabel">范围 JSON</label>
|
||||
<textarea id="project-edit-scope" class="form-input form-input--mono projects-scope-textarea" spellcheck="false" placeholder='{"targets":["https://example.com"],"exclude":["*.cdn.example.com"]}'></textarea>
|
||||
</div>
|
||||
<p class="projects-scope-footnote">支持 <code>targets</code>、<code>exclude</code>、<code>notes</code> 等字段,留空表示不限制范围。</p>
|
||||
<p class="projects-scope-footnote" data-i18n="projects.scopeFootnote">支持 targets、exclude、notes 等字段,留空表示不限制范围。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1563,23 +1640,22 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="projects-settings-card-title">危险操作</h4>
|
||||
<p class="projects-settings-card-hint">归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。</p>
|
||||
<h4 class="projects-settings-card-title" data-i18n="projects.dangerZoneTitle">危险操作</h4>
|
||||
<p class="projects-settings-card-hint" data-i18n="projects.dangerZoneHint">归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-settings-danger-actions">
|
||||
<button class="btn-secondary btn-small" type="button" onclick="archiveCurrentProject()">归档 / 恢复</button>
|
||||
<button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()">删除项目</button>
|
||||
<button class="btn-secondary btn-small" type="button" onclick="archiveCurrentProject()" data-i18n="projects.archiveRestore">归档 / 恢复</button>
|
||||
<button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()" data-i18n="projects.deleteProject">删除项目</button>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="projects-settings-footer">
|
||||
<span class="projects-settings-footer-hint">修改后请点击保存以同步到服务器</span>
|
||||
<button class="btn-primary" type="button" onclick="saveProjectSettings()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
保存更改
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<footer class="projects-settings-footer">
|
||||
<span class="projects-settings-footer-hint" data-i18n="projects.saveChangesHint">修改后请点击保存以同步到服务器</span>
|
||||
<button class="btn-primary" type="button" onclick="saveProjectSettings()">
|
||||
<span data-i18n="projects.saveSettings">保存更改</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3602,6 +3678,13 @@
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-project-id" data-i18n="batchImportModal.project">所属项目</label>
|
||||
<select id="batch-queue-project-id" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
|
||||
<option value="" data-i18n="batchImportModal.projectNone">(未绑定)</option>
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.projectHint">可为队列绑定项目;留空则不绑定项目上下文。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-agent-mode" data-i18n="batchImportModal.agentMode">代理模式</label>
|
||||
<select id="batch-queue-agent-mode" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
|
||||
@@ -3944,25 +4027,25 @@
|
||||
<div class="projects-modal-header">
|
||||
<div class="projects-modal-header-text">
|
||||
<div>
|
||||
<h3 id="project-modal-title">新建项目</h3>
|
||||
<p id="project-modal-subtitle" class="projects-modal-subtitle">创建后可绑定对话,跨会话共享事实黑板</p>
|
||||
<h3 id="project-modal-title" data-i18n="projects.modalNewTitle">新建项目</h3>
|
||||
<p id="project-modal-subtitle" class="projects-modal-subtitle" data-i18n="projects.modalNewSubtitle">创建后可绑定对话,跨会话共享事实黑板</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="projects-modal-close" onclick="closeProjectModal()" aria-label="关闭">×</button>
|
||||
<button type="button" class="projects-modal-close" onclick="closeProjectModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">×</button>
|
||||
</div>
|
||||
<div class="projects-modal-body">
|
||||
<div class="projects-form-field">
|
||||
<label for="project-modal-name">项目名称 <span class="required">*</span></label>
|
||||
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off">
|
||||
<label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
|
||||
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="project-modal-description">项目描述</label>
|
||||
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…"></textarea>
|
||||
<label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
|
||||
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-modal-footer">
|
||||
<button class="btn-secondary" type="button" onclick="closeProjectModal()">取消</button>
|
||||
<button class="btn-primary" type="button" id="project-modal-submit-btn" onclick="saveProjectModal()">创建项目</button>
|
||||
<button class="btn-secondary" type="button" onclick="closeProjectModal()" data-i18n="common.cancel">取消</button>
|
||||
<button class="btn-primary" type="button" id="project-modal-submit-btn" onclick="saveProjectModal()" data-i18n="projects.createProject">创建项目</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3971,11 +4054,11 @@
|
||||
<div class="projects-modal-header">
|
||||
<div class="projects-modal-header-text">
|
||||
<div>
|
||||
<h3 id="fact-modal-title">添加事实</h3>
|
||||
<p class="projects-modal-subtitle">摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)</p>
|
||||
<h3 id="fact-modal-title" data-i18n="projects.addFact">添加事实</h3>
|
||||
<p class="projects-modal-subtitle" data-i18n="projects.factModalSubtitle">摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭">×</button>
|
||||
<button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">×</button>
|
||||
</div>
|
||||
<div class="projects-modal-body">
|
||||
<div class="projects-form-field">
|
||||
@@ -3985,7 +4068,7 @@
|
||||
</div>
|
||||
<div class="projects-form-row">
|
||||
<div class="projects-form-field">
|
||||
<label for="fact-modal-category">分类</label>
|
||||
<label for="fact-modal-category" data-i18n="projects.category">分类</label>
|
||||
<select id="fact-modal-category" class="form-input" onchange="updateFactFormHints()">
|
||||
<option value="target">target(目标)</option>
|
||||
<option value="auth">auth(认证)</option>
|
||||
@@ -3999,7 +4082,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="fact-modal-confidence">置信度</label>
|
||||
<label for="fact-modal-confidence" data-i18n="projects.confidence">置信度</label>
|
||||
<select id="fact-modal-confidence" class="form-input">
|
||||
<option value="tentative">待确认</option>
|
||||
<option value="confirmed">已确认</option>
|
||||
@@ -4023,13 +4106,13 @@
|
||||
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="fact-modal-related-vuln">关联漏洞 ID</label>
|
||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选">
|
||||
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-modal-footer">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactModal()">取消</button>
|
||||
<button class="btn-primary" type="button" id="fact-modal-submit-btn" onclick="saveFactModal()">保存事实</button>
|
||||
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
|
||||
<button class="btn-primary" type="button" id="fact-modal-submit-btn" onclick="saveFactModal()" data-i18n="projects.saveFact">保存事实</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4038,19 +4121,31 @@
|
||||
<div class="projects-modal-header">
|
||||
<div class="projects-modal-header-text">
|
||||
<div>
|
||||
<h3 id="fact-detail-title">事实详情</h3>
|
||||
<h3 id="fact-detail-title" data-i18n="projects.factDetails">事实详情</h3>
|
||||
<p id="fact-detail-meta" class="projects-modal-subtitle"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭">×</button>
|
||||
<button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">×</button>
|
||||
</div>
|
||||
<div class="projects-modal-body">
|
||||
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
|
||||
<div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden>
|
||||
<h4 class="fact-detail-prev-title" data-i18n="projects.previousVersion">上一版本</h4>
|
||||
<p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p>
|
||||
<pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre>
|
||||
</div>
|
||||
<h4 class="fact-detail-current-title" data-i18n="projects.currentVersion">当前版本</h4>
|
||||
<pre id="fact-detail-body" class="fact-detail-body"></pre>
|
||||
</div>
|
||||
<div class="projects-modal-footer">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()">关闭</button>
|
||||
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()">编辑</button>
|
||||
<div class="projects-modal-footer projects-modal-footer--split">
|
||||
<div class="projects-modal-footer-left">
|
||||
<button class="btn-secondary btn-small" type="button" id="fact-detail-link-vuln-btn" onclick="linkFactToExistingVulnerability()" hidden data-i18n="projects.linkVulnerability">关联漏洞</button>
|
||||
<button class="btn-secondary btn-small" type="button" id="fact-detail-create-vuln-btn" onclick="createVulnerabilityFromCurrentFact()" hidden data-i18n="projects.createVulnerabilityDraft">生成漏洞草稿</button>
|
||||
</div>
|
||||
<div class="projects-modal-footer-right">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()" data-i18n="common.close">关闭</button>
|
||||
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()" data-i18n="common.edit">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4064,6 +4159,7 @@
|
||||
<script src="/static/js/router.js"></script>
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js"></script>
|
||||
<script src="/static/js/monitor.js"></script>
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user