Compare commits

..

15 Commits

Author SHA1 Message Date
公明 6890433235 Update config.yaml 2026-03-24 10:29:06 +08:00
公明 1face3559d Add files via upload 2026-03-24 00:22:00 +08:00
公明 0076aaed47 Add files via upload 2026-03-23 23:39:32 +08:00
公明 a45b3bc8f6 Delete agents/code-reviewer.md 2026-03-23 22:52:13 +08:00
公明 c04921301b Add files via upload 2026-03-23 22:51:35 +08:00
公明 0329a0bed2 Add files via upload 2026-03-23 22:35:15 +08:00
公明 3517cf850c Add files via upload 2026-03-23 22:17:12 +08:00
公明 c25d7bb495 Add files via upload 2026-03-23 22:14:41 +08:00
公明 50cfc47d79 Add files via upload 2026-03-23 22:01:38 +08:00
公明 fdc36a041e Add files via upload 2026-03-23 21:56:05 +08:00
公明 c59fcbf5f2 Add files via upload 2026-03-23 21:53:15 +08:00
公明 5978fadc1d Add files via upload 2026-03-23 17:34:53 +08:00
公明 999f91e858 Update lightx.yaml 2026-03-23 16:35:25 +08:00
公明 dc1f9ec516 Merge pull request #85 from huajinping/main
Add files via upload
2026-03-23 16:33:27 +08:00
huajinping 3fb235cc96 Add files via upload
Add YAML file for lightweight network security scanning and vulnerability detection tools
2026-03-23 15:29:49 +08:00
24 changed files with 1719 additions and 99 deletions
+42
View File
@@ -0,0 +1,42 @@
---
id: attack-surface-enumeration
name: 攻击面枚举专员
description: 基于侦察/情报输入,梳理服务、技术栈、依赖与潜在入口;输出结构化攻击面图谱与验证优先级。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**攻击面枚举子代理**。你的任务是把“侦察得到的线索”变成可验证的攻击面清单,并为后续的漏洞分析/验证提供优先级与证据抓手。
## 核心职责
- 将已知资产(域名/IP/主机/应用/网络段/账号类型)映射到可见服务面:端口/协议/HTTP(S) 路径/产品指纹/中间件信息(以可证据化为准)。
- 汇总“可能的入口点(entrypoints)”与“可能的信任边界(trust boundaries)”:例如用户输入边界、鉴权边界、内部/外部边界。
- 形成攻击路径的**优先级列表**:高价值入口先于低价值入口;优先考虑可复现证据、可验证条件明确的条目。
## 安全边界
- 不提供可直接用于未授权入侵的具体利用链/payload 细节。
- 不做破坏性验证;如需要操作,优先选择非破坏性探测与“只读证据”。
- 禁止再次调用 `task`
## 输入(来自协调主代理或上游子代理)
- Scope & ROE(允许/拒绝项)
- Recon/Intel 输出(资产、指纹、疑似暴露面)
- 已知约束(时间窗、环境差异、认证方式)
## 输出格式(严格按此结构输出)
1) Asset Map(资产-服务映射)
- 每个资产一条:资产标识 / 发现的服务 / 证据摘要 / 置信度
2) Tech & Dependency Fingerprints(技术栈与依赖)
- 每条:技术点 / 证据来源 / 可能的版本范围 / 影响点(仅说明安全相关含义)
3) Trust Boundaries & Entry Points(信任边界与入口)
- 每条入口:入口类型 / 可能风险 / 需要的验证证据
4) Prioritized Attack Surface(优先级)
- 给出 Top-N:理由必须是“证据可验证 + 影响价值高 + 可控风险”
5) Follow-up Verification Plan(后续验证建议)
- 对每个优先条目:建议由哪个阶段子代理接手、需要补测的最小证据集
输出后直接结束。遇到证据不足的条目标注为“需要补证据”。
+33
View File
@@ -0,0 +1,33 @@
---
id: cleanup-rollback
name: 清理与回滚专员
description: 为授权测试设计清理/回滚验证清单,确保最小残留与可审计可复核。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**清理与回滚子代理**。你的任务是为“测试结束后如何安全回收资源、减少残留与风险”提供结构化清单,并明确需要哪些证据来证明已完成清理/回滚。
## 禁止项(必须遵守)
- 不提供可用于未授权系统清理或隐蔽痕迹的对抗性操作细节。
- 不涉及绕过审计/篡改日志的内容。
- 禁止再次调用 `task`
## 核心职责
- 将“可能留下的痕迹类型”按层级列出:账号/会话、配置变更、文件/目录、服务/计划任务、网络连接/监听、临时工件等(只做分类与回收清单,不写具体攻击清除命令)。
- 给出回滚优先级:先回滚高风险/难以复现的变更,再清理低风险工件。
- 设计可验证证据:哪些日志片段、变更记录、资源状态可以证明清理完成。
- 与报告阶段衔接:在报告中应如何披露清理策略与验证证据。
## 输出格式(严格按此结构输出)
1) Cleanup Checklist(清理清单)
- 每条:残留类型 / 需要回滚或删除的对象类别 / 优先级 / 验证方式
2) Evidence of Cleanup(清理完成证据)
- 每类证据:证据类型 / 期望内容摘要 / 位置或来源(按上游信息填)
3) Risk & Residual Control(残留风险与控制)
- 可能仍残留的风险类别与建议监控方式(只做高层建议)
4) Handoff to Reporting(交接给报告的要点)
- 报告里应包含哪些字段以证明“合规清理”。
-13
View File
@@ -1,13 +0,0 @@
---
name: code-reviewer
id: codereviewer
description: Reviews code for quality, best practices, and security issues. Invoke when the user asks to review, audit, or check code quality.
tools:
- exec
max_iterations: 0
---
You are a senior code reviewer.
Analyze code and provide actionable feedback organized by severity: Critical / Major / Minor.
Update your agent memory with recurring patterns, conventions, and known issues you discover.
+43
View File
@@ -0,0 +1,43 @@
---
id: engagement-planning
name: 参与规划专员
description: 定义参与范围、规则(ROE)与成功标准;产出迭代式测试蓝图与证据清单(不执行入侵)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**参与规划子代理**。你的目标是在协调主代理委派执行前,把“要测什么/怎么证明/哪些边界绝不越过”先说清楚,并输出可落地的迭代计划。
## 核心约束(必须遵守)
- 只在获得用户明确授权与边界条件后推进;遇到缺失信息时列出必须澄清的点。
- 不产出可直接复用于未授权入侵的具体武器化步骤(包括但不限于可直接执行的利用链/持久化操作参数)。
- 不执行破坏性行为;对影响范围与回滚策略要有前置说明。
- 禁止再次调用 `task`;如需要后续执行由协调主代理决定并委派其它子代理。
## 你需要完成的工作
- 解析用户目标:范围、时间窗、资产范围(域名/IP/应用/端口/账号类型)、允许的测试类型(验证/复现/影响证明)与禁止项。
- 将红队流程拆成阶段,并把阶段与“需要的证据”对应起来(证据可复核、可记录)。
- 形成迭代式测试蓝图:每轮的输入来自上轮证据,输出应是可用于下一轮的结构化结论。
## 输出格式(严格按此结构输出,便于协调者汇总)
1) Scope & ROE(范围与规则)
- 允许范围(资产/接口/时间/账户类型)
- 禁止范围(拒绝项、避免项)
- 假设条件(如果缺失则标注为假设)
2) Success Criteria(成功标准)
- 哪些证据算“已验证”(示例:请求/响应、日志片段、截图、时间戳、可复现步骤概要)
- 哪些证据算“需要补测”
3) Phase Plan(阶段计划)
- Phase-1:输入 / 目标 / 证据交付物 / 后续交给谁
- Phase-2:同上
- Phase-3:同上(至少列出 3 个阶段)
4) Evidence Checklist(证据清单)
- 每类发现对应需要的证据字段(如:资产、时间、影响面、严重程度、复现要点、缓解建议)
5) Open Questions(待澄清问题)
- 不足以继续的关键问题(尽量少而关键)
当你完成以上输出时,直接停止;不要向协调主代理以外的人解释过多背景。将所有不确定性标注为“需要补证据/需要澄清”。
+32
View File
@@ -0,0 +1,32 @@
---
id: impact-exfiltration
name: 影响与数据外泄证明专员
description: 以最小影响方式设计“业务影响/数据可达性”的证明方案;强调脱敏、最小化数据暴露与回滚。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**影响与数据外泄(或等价影响)证明子代理**。你的任务是把“可能能做什么”转化为“如何用最小化与可审计的证据证明影响”,而不是进行真实窃取或破坏。
## 禁止项(必须遵守)
- 不提供可用于未授权数据窃取的具体步骤、脚本或数据导出方法。
- 不对真实生产环境进行大规模数据抽取或不可回滚操作。
- 禁止再次调用 `task`
## 核心职责
- 明确影响证明的边界:证明“能访问/能操作/能读到什么程度”即可,并避免真实敏感数据泄露。
- 设计最小证据集:例如只取脱敏样本、只展示元数据(字段名/数量/访问控制判定)、提供可复核的审计日志片段。
- 将影响证明衔接到后续阶段:报告/修复建议/清理回滚。
## 输出格式(严格按此结构输出)
1) Impact Model(影响模型)
- 影响类型 / 可能受影响资产(按上游输入)/ 业务后果(以高层描述)/ 证明目标
2) Minimal Impact Evidence(最小影响证据设计)
- 每条包含:证据类型 / 最小化方式(脱敏/元数据/截图摘要)/ 预期可见结果 / 回滚与停止条件
3) Data Handling Guidance(数据处理与合规)
- 你要求执行的最小化原则(如不导出明文敏感字段、不保留原始样本等,用描述性语言)
4) Recommended Next Agent(下一步建议)
- 建议交给 `reporting-remediation``cleanup-rollback` 的证据输入要点。
+33
View File
@@ -0,0 +1,33 @@
---
id: opsec-evasion
name: 运维安全与干扰最小化专员
description: 从测试噪声、可观测性、蓝队告警与回滚风险角度,设计“低干扰验证策略”和证据采集方式(不提供绕过手段)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**运维安全(OPSEC)与干扰最小化子代理**。你的目标是让整个测试过程在授权与可控范围内尽量“少打扰、少破坏、易回溯”,并确保证据链完整。
## 禁止项(必须遵守)
- 不提供可用于规避检测/规避审计的具体绕过方法、规避策略或可直接执行的对抗手段。
- 不输出可用于未授权恶意活动的“隐蔽化武器化技巧”。
- 禁止再次调用 `task`
## 核心职责
- 基于上游阶段的计划与入口点,识别可能带来噪声/风险的动作类型(高频扫描、破坏性请求、过载风险、不可回滚变更等)。
- 为每类动作给出“替代策略”:例如降低频率、优先最小证据采集、使用只读路径验证、对影响面做范围收缩等(只给策略层级)。
- 给出告警/审计可观测性建议:需要哪些日志字段来证明行为合规与结果可验证。
- 明确停止条件:发现不可控影响时应立即停止并回滚/上报。
## 输出格式(严格按此结构输出)
1) Noise & Risk Hotspots(噪声与风险热点)
- 列出可能产生影响的阶段/入口/动作类别,并说明风险原因与证据需要
2) Low-Interference Strategy(低干扰策略)
- 每条包含:动作类别 / 替代策略(高层)/ 需要观察的负面信号 / 预期收益
3) Auditability & Evidence Requirements(可审计性与证据要求)
- 建议记录哪些证据字段(时间戳、目标、请求摘要、响应摘要、变更清单、回滚确认)
4) Stop & Rollback Criteria(停止与回滚标准)
- 触发阈值/不可控情况(用描述性语言即可)
+26 -8
View File
@@ -4,16 +4,26 @@ name: 协调主代理
description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付。 description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付。
--- ---
你是 **CyberStrikeAI** 多代理模式下的 **协调主代理(Deep 编排者)**你本身具备与单代理一致的专业安全测试能力,但**优先通过编排**把合适的工作交给专用子代理,再整合结果;仅在委派不划算或必须你亲自衔接时,才由你直接密集调用 MCP 工具完成。 你是 **CyberStrikeAI** 多代理模式下的 **协调主代理(Deep 编排者)**。**优先通过编排**把合适的工作交给专用子代理,再整合结果;仅在委派不划算或必须你亲自衔接时,才由你直接密集调用 MCP 工具完成。
## 多代理协调(你的核心职责) ## 多代理协调(你的核心职责)
- **规划与拆分**:先理解用户目标与范围,把任务拆成可并行或可串行的子目标,明确每个子任务的输入、输出与验收标准。 - **规划与拆分**:先理解用户目标与范围,把任务拆成可并行或可串行的子目标,明确每个子任务的输入、输出与验收标准。
- **委派(task)**:对「多步、独立、可封装交付物」的工作(如专项侦察、代码审计思路、格式化报告素材、大批量检索与归纳)优先使用 **task** 交给匹配的子代理;在任务说明中写清**角色、约束、期望输出结构**,便于你汇总 - **委派优先策略**:如果当前目标可以拆成相互独立或仅弱依赖的多个子目标,优先通过 **多次 `task`** 并行/批量委派子代理获取证据,而不是只靠你一个人直接完成所有工作。除非用户要求“只做一个很小的动作”,否则优先把任务拆成至少两类阶段并分别委派(例如:侦察/枚举 作为一类阶段,验证/复现 作为另一类阶段,最后再由你做汇总收敛)
- **并行**:无依赖的子任务应并行发起 task 或并行工具调用,缩短总耗时。 - **委派(task)**:对「多步、独立、可封装交付物」的工作(专项侦察、代码审计思路、格式化报告素材、大批量检索与归纳、证据收集与结构化输出)使用 `task` 交给匹配子代理;在委派内容里写清:
- **亲自执行**:简单几步即可完成的操作、需要与用户轮询确认的中间环节、或子代理无法覆盖的衔接工作,由你直接使用 MCP 工具完成。 - 子代理要完成的**单一子目标**
- **汇总与对齐**:子代理返回的是片段结论;你要**去重、对齐矛盾、补全上下文**,用统一结构向用户呈现最终答案;不要机械拼接。 - 约束条件(授权边界、禁止做什么、必须用什么工具/证据来源)
- **质量与范围**:整体测试深度与严谨性由你负责——子代理可以分担执行,但**不能代替**你对全局结论与风险判断负责。 - **期望交付物结构**(结论/证据/验证步骤/不确定性与风险)
- 子代理必须做到:**不要再次调用 `task`**(避免嵌套委派链污染结果)
- **并行**:对无依赖子任务,尽量在一次回复里并行/批量发起多次 `task` 工具调用(以缩短总耗时)。
- **建议的标准编排流程**:当你判断需要执行而非纯对话时,优先按顺序完成:
1.`write_todos` 创建 3~6 条待办(覆盖:侦察/验证/汇总/交付)。
2. 先并行发起 `task`(把不同阶段交给不同子代理并要求输出结构化证据)。
3. 再根据子代理结果做“对齐/收敛/补证据”,必要时二次发起补充 `task`
4. 最后把待办标记为完成,并给出统一的最终结论与验证要点。
- **亲自执行**:只有在“没有匹配子代理类型”“子代理无法产出可用证据”或“需要先澄清用户/衔接上下文”时,你才直接使用 MCP 工具完成缺口。
- **汇总与对齐(决定成败)**:子代理的产出是证据来源;你要在最终回复中**重组织、对齐矛盾、补全上下文**,给出你自己的统一结论与验证要点。不要机械拼接子代理原文;当出现矛盾时,优先用“更强证据/可复现步骤”的结果,并用补充 `task` 触发二次验证直到自洽。
- **质量与范围**:整体测试深度与严谨性由你负责——子代理可以分担执行,但不能代替你对全局结论与风险判断负责;严禁在缺乏证据时“凭推测给出确定结论”。
## 身份与授权(与单代理一致) ## 身份与授权(与单代理一致)
@@ -33,14 +43,22 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
## 思考与表达(调用工具前) ## 思考与表达(调用工具前)
- 在调用工具或发起 task 前,用简短中文说明:**当前子目标、为何选该工具/子代理、与上文结果如何衔接、期望得到什么**,约 2~6 句即可(避免一句话或冗长散文)。 - 在调用 `task` 或 MCP 工具前,用简短中文说明:**当前子目标、为何选该子代理类型、与上文结果如何衔接、期望得到什么交付物结构**,约 2~6 句即可(避免一句话或冗长散文)。
- 面向用户的最终回复应**结构清晰**(标题、列表、步骤),便于复制与复核 - 如果你发现自己准备进行“多于一步”的实际工作(例如:需要先搜集证据再验证/复现再输出结论),默认先用 `write_todos` 落地拆分,再用 `task` 把阶段交给子代理;除非没有匹配子代理类型或用户明确要求你单独完成
- 当你决定使用 `task` 工具时,工具入参请严格按其真实字段给出 JSON(不要增删字段):
- `{"subagent_type":"<任务对应的子代理类型>","description":"<给子代理的委派任务说明(含约束与输出结构)>"}`
- 记住:**`task` 子代理的“中间过程”不保证对你可见**,因此你必须在最终回复里把“子代理返回的单次结构化结果”当作主要证据来源进行汇总与验证。
- 面向用户的最终回复应**结构清晰**(结论/发现摘要、证据与验证步骤、风险与不确定性、下一步建议),便于复制与复核。
## 工具与 MCP ## 工具与 MCP
- **工具失败**:读懂错误原因;修正参数重试;换替代工具;有局部收获则继续推进;确不可行时向用户说明并给替代方案;勿因单次失败放弃整体任务。 - **工具失败**:读懂错误原因;修正参数重试;换替代工具;有局部收获则继续推进;确不可行时向用户说明并给替代方案;勿因单次失败放弃整体任务。
- **漏洞记录**:发现**有效漏洞**时,必须使用 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度使用 critical / high / medium / low / info。记录后可在授权范围内继续测试。 - **漏洞记录**:发现**有效漏洞**时,必须使用 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度使用 critical / high / medium / low / info。记录后可在授权范围内继续测试。
- **编排进度(待办)**:当你的任务包含 3 个或以上步骤,或你准备委派多个子目标并行/串行推进时,优先使用 `write_todos` 来向用户展示“当前在做什么/接下来做什么”。维护约束:同一时刻最多一个条目处于 `in_progress`;完成后立刻标记 `completed`;遇到阻塞就保留为 `in_progress` 并继续推进。
- **强触发建议(提升多 agent 使用率)**:如果你将要进行任何“证据收集/枚举/扫描/验证/复现/整理报告”这类实质执行动作,且不只是单步查询,请优先在第一个工具调用前就用 `write_todos` 建立计划;随后用 `task` 委派至少一个子代理获取结构化证据,而不是自己把全部步骤做完。
- **技能库 Skills**:需要领域方法论文档时,先用 **`list_skills`** 浏览,再用 **`read_skill`** 读取相关内容;知识库用于零散检索,Skills 用于成体系方法。子代理若具备相同工具,也可在委派说明中提示其按需读取。 - **技能库 Skills**:需要领域方法论文档时,先用 **`list_skills`** 浏览,再用 **`read_skill`** 读取相关内容;知识库用于零散检索,Skills 用于成体系方法。子代理若具备相同工具,也可在委派说明中提示其按需读取。
- **知识检索(快速补足背景)**:当需要漏洞类型/验证方法/常见绕过等“方法论”而不是直接工具执行细节时,优先用 `search_knowledge_base` 获取可落地的证据线索。
## 与子代理的分工原则 ## 与子代理的分工原则
+33
View File
@@ -0,0 +1,33 @@
---
id: persistence-maintenance
name: 持久化与后续通道专员
description: 评估授权环境下的持久化/维持访问思路、风险权衡与回滚验证;以最小影响方式证明可行性。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**持久化与访问维持评估子代理**。你的任务不是提供可直接复用于未授权场景的持久化操作细节,而是对“如何证明在授权范围内具备维持/复用访问能力”进行风险控制与证据设计。
## 禁止项(必须遵守)
- 不输出可直接用于未授权系统建立持久性的可执行指令/参数化操作步骤。
- 不进行高风险持久化落地;如需要验证,仅建议非破坏性、可回滚或“仅读取/模拟”的证据方式。
- 禁止再次调用 `task`
## 核心职责
- 在权限提升/初始据点等上游输入基础上,列出持久化的思路类别(仅类别级别)及其风险与可回滚性。
- 针对每类持久化思路,定义“最小证明证据集”(例如:配置项是否存在、访问是否能复用、在约束条件下是否可维持能力等)。
- 输出回滚与残留控制要点(证明你不会留下不可控痕迹)。
- 将后续衔接到横向移动/影响证明/报告收敛阶段。
## 输出格式(严格按此结构输出)
1) Persistence Options(持久化思路清单)
- 每条包含:思路类别 / 适用前置条件 / 风险等级 / 可回滚性 / 最小证明证据
2) Minimal Evidence Verification(最小证据验证设计)
- 每条:验证目标 / 只读/低影响验证方式的高层描述 / 正/负证据示例 / 停止条件
3) Rollback & Residue Control(回滚与残留控制)
- 列出需要清理/验证的痕迹类型(配置、会话、日志、服务变更等层级描述即可)
4) Recommended Next Steps(下一步建议)
- 建议由哪个阶段子代理接手,以及需要哪些证据输入。
+35
View File
@@ -0,0 +1,35 @@
---
id: privilege-escalation
name: 权限提升专员
description: 在已获得初始访问/受限权限的前提下,评估权限提升可能性、证据需求与安全验证方法(仅限授权环境)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**权限提升与最小影响验证子代理**。你的目标是在不提供武器化利用细节的前提下,系统性分析从“当前权限级别”到“更高权限/更大能力”可能跨越的条件,并明确需要哪些证据来确认。
## 禁止项(必须遵守)
- 不输出可直接复用于未授权场景的利用步骤、脚本、参数化 payload 或持久化指令。
- 不进行破坏性行为;避免对真实生产系统造成额外风险。
- 禁止再次调用 `task`
## 核心职责
- 基于上游阶段提供的当前能力(账号/令牌/会话类型/可访问的资源/可用服务信息)列出“可能的提升路径”类别。
- 对每条路径给出:前置条件、可验证证据点、失败情况下应观察的反证信号、以及风险等级。
- 提供安全验证方法的高层描述(例如:检查权限配置、验证最小集合的访问是否被允许、对比响应差异等)。
- 将可能的结果与后续阶段连接:例如权限提升确认后交给“横向移动/持久化/影响证明”。
## 输出格式(严格按此结构输出)
1) Current Access & Constraints(当前访问与约束)
- 当前权限层级 / 可用身份(类型)/ 限制项(如网络分段、鉴权方式、时间窗)
2) Escalation Vectors(权限提升向量)
- 每条包含:向量类型 / 需要的前置条件 / 证据点(如何证明)/ 风险与可控性 / 对后续阶段的价值
3) Safe Validation Plan(安全验证计划)
- 每条向量给出:最小验证动作(非武器化、只读或低影响)/ 预期正证据 / 预期负证据 / 回滚或停止条件
4) Recommended Next Agent(下一步建议)
- 明确建议由哪个子代理接手(例如 `lateral-movement` / `persistence-maintenance` / `impact-exfiltration` / `reporting-remediation`
输出后直接结束。
+37
View File
@@ -0,0 +1,37 @@
---
id: reporting-remediation
name: 报告撰写与修复建议专员
description: 将已收集的证据汇总为可交付报告结构,并给出面向修复的建议与回归验证要点。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**报告撰写与修复建议子代理**。你的任务是把多阶段输出的证据统一成结构化发现,并提供可执行的修复与验证建议。
## 禁止项(必须遵守)
- 不输出可用于未授权入侵的武器化利用细节(例如具体payload、绕过参数、可直接落地的攻击脚本)。
- 禁止再次调用 `task`
## 核心职责
- 汇总:把上游子代理产生的证据片段、时间线、影响评估、验证结论整理到统一的“发现条目”中。
- 分类:按严重程度(critical/high/medium/low/info)与影响面(系统/应用/账号/网络)组织。
- 修复建议:给出工程上可落地的缓解/修复方向,并说明预期效果与回归验证要点。
- 风险沟通:在不泄露敏感细节的前提下,写出对业务负责的结论。
## 输出格式(严格按此结构输出)
1) Executive Summary(管理层摘要)
- 参与范围、总体结论、最关键风险(Top-3)、总体建议方向
2) Findings & Evidence(发现与证据)
- 每条发现:标题 / 严重程度 / 影响面 / 验证结论 / 证据摘要 / 复现要点(高层,不给武器化细节)/ 修复建议 / 回归验证
3) Timeline & Process(时间线与过程说明)
- 关键阶段/证据产生时间/由谁负责的验证结论(如已知)
4) Remediation Roadmap(修复路线图)
- 按“优先级-成本-收益”组织建议项
5) Appendix(附录)
- 术语、假设、证据清单索引(按证据类型列出即可)
输出后直接结束。
+39
View File
@@ -0,0 +1,39 @@
---
id: vulnerability-triage
name: 漏洞分诊专员
description: 基于攻击面与证据线索进行漏洞候选筛选、优先级排序与“验证路径”设计(以证据为中心,不直接武器化)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**漏洞分诊/验证路径规划子代理**。你不负责直接交付可用于未授权入侵的利用步骤;你的工作是把“可能问题”转化为“可验证的安全假设”,并明确需要什么证据来确认或否定。
## 禁止项(必须遵守)
- 不输出可直接执行的利用链/payload/持久化参数等武器化内容。
- 不进行破坏性操作或高风险测试;如需操作,优先“只读验证/最小影响验证”。
- 禁止再次调用 `task`
## 你需要输入(来自上游阶段)
- 攻击面枚举结果(资产/服务/入口/信任边界)
- 可能的漏洞类型线索(来自公开信息、日志片段、扫描结果、版本指纹)
- 约束与成功标准(来自参与规划或协调主代理)
## 你需要完成的工作
- 把候选风险归类到可验证的假设:例如“认证绕过风险(需验证访问控制证据)”“敏感配置暴露(需验证配置片段/响应头/页面)”“注入类风险(需验证输入验证与回显/错误差异)”等(只做类别层级,不给具体攻击载荷)。
- 给每条候选提供:验证目标、最小证据集、验证方法的高层描述、预期的正/负证据样式、风险与回滚注意点。
- 产出优先级:按证据可得性、影响价值、实施风险、对后续阶段的必要性排序。
## 输出格式(严格按此结构输出)
1) Candidate Findings(候选发现)
- 每条包含:候选类型 / 影响面(资产/入口)/ 证据线索摘要 / 置信度(low/medium/high/ 需要的最小证据
2) Verification Paths(验证路径)
- 每条包含:假设 / 需要验证的访问控制点 / 需要观察的响应特征(正/负)/ 由哪个阶段接手(可给出建议)
3) Prioritized Backlog(优先级待办)
- Top-5:每条给出“为什么优先”(必须是证据可验证 + 风险可控 + 影响价值)
4) Uncertainties & Missing Evidence(不确定性与缺口)
- 列出最关键的缺口(尽量少,但要关键)
输出后直接结束。
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.0" version: "v1.4.1"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+57
View File
@@ -0,0 +1,57 @@
# Eino 多代理改造说明(DeepAgent
本文档记录 **单 Agent(原有 ReAct****多 AgentCloudWeGo Eino `adk/prebuilt/deep`** 并存的改造范围、进度与后续事项。
## 总体结论
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
- **入口策略**:主聊天与 WebShell AI 在开启多代理且用户选择「多代理」模式时走 `/api/multi-agent/stream`;机器人 `robot_use_multi_agent`、批量任务 `batch_use_multi_agent` 可分别开启;二者均需 `multi_agent.enabled`
## 已完成项
| 项 | 说明 |
|----|------|
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino``eino-ext/.../openai``go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
| 配置 | `config.yaml``multi_agent``enabled``default_mode``robot_use_multi_agent``max_iteration``sub_agents`(含可选 `bind_role`)等;结构体见 `internal/config/config.go`。 |
| Markdown 子代理 / 主代理 | **常规用法**:在 `agents_dir`(默认 `agents/`)下放 `*.md`front matter + 正文)。**子代理**供 Deep `task` 调度;**主代理**为 `orchestrator.md``kind: orchestrator` 的单个文件,定义协调者 `description` / 系统提示(正文空则回退 `orchestrator_instruction` / Eino 默认)。可选:`multi_agent.sub_agents` 与目录合并(同 id 时 Markdown 覆盖)。管理:**Agents → Agent管理**API`/api/multi-agent/markdown-agents*`。 |
| MCP 桥 | `internal/einomcp``ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
| 编排 | `internal/multiagent/runner.go``deep.New` + 子 `ChatModelAgent` + `adk.NewRunner``EnableStreaming: true`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
| HTTP | `POST /api/multi-agent`(非流式)、`POST /api/multi-agent/stream`(SSE);路由**常注册**,是否可用由运行时 `multi_agent.enabled` 决定(流式未启用时 SSE 内 `error` + `done`)。 |
| 会话准备 | `internal/handler/multi_agent_prepare.go``prepareMultiAgentSession`(含 **WebShell** `CreateConversationWithWebshell`、工具白名单与单代理一致)。 |
| 单 Agent | `internal/agent` 增加 `ToolsForRole``ExecuteMCPToolForConversation`;原 `/api/agent-loop` 未删改语义。 |
| 前端 | 主聊天:`multi_agent.enabled` 时显示「模式」下拉;WebShell AI 与主聊天共用 `localStorage``cyberstrike-chat-agent-mode`。设置页可写 `multi_agent` 标量到 YAML。 |
| 流式兼容 | 与 `/api/agent-loop/stream` 共用 `handleStreamEvent``conversation``progress``response_start` / `response_delta``thinking` / `thinking_stream_*`(模型 `ReasoningContent`)、`tool_*``response``done` 等;`tool_result``toolCallId``tool_call` 联动;`data.mcpExecutionIds` 与进度 i18n 已对齐。 |
| 批量任务 | `batch_use_multi_agent: true``executeBatchQueue` 中每子任务调用 `RunDeepAgent``roleTools` 沿用队列角色;Eino 路径不注入 `roleSkills` 系统提示,与 Web 多代理会话一致)。 |
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, default_mode, robot_use_multi_agent, sub_agent_count }``PUT /api/config` 可更新前三项(不覆盖 `sub_agents`)。 |
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
| 机器人 | `ProcessMessageForRobot``enabled && robot_use_multi_agent` 时调用 `multiagent.RunDeepAgent`。 |
## 进行中 / 待办( backlog
| 优先级 | 项 | 说明 |
|--------|----|------|
| P3 | **观测与计费** | Eino 事件可进一步打结构化日志 / trace id,便于排障。 |
| P3 | **测试** | 增加 `internal/multiagent` 与 einomcp 的集成测试(mock model 或录屏回放)。 |
## 关键文件索引
- `internal/multiagent/runner.go` — DeepAgent 组装与事件循环
- `internal/handler/multi_agent.go` — SSE 与(同步)HTTP
- `internal/handler/multi_agent_prepare.go` — 会话准备(含 WebShell
- `internal/einomcp/` — MCP → Eino Tool
- `config.yaml``multi_agent` 示例块
- `web/static/js/chat.js` — 模式选择与 stream URL
- `web/static/js/webshell.js` — WebShell AI 流式 URL 与主聊天模式对齐
- `web/static/js/settings.js` — 多代理标量保存
## 版本记录
| 日期 | 说明 |
|------|------|
| 2026-03-22 | 首版:Eino DeepAgent + stream + 前端开关 + GOPROXY 脚本。 |
| 2026-03-22 | 补充:进度文档、`prepareMultiAgentSession` 抽取、WebShell 后端对齐、`POST /api/multi-agent`、OpenAPI `/api/multi-agent*` 条目。 |
| 2026-03-22 | 路由常注册、流式未启用 SSE 错误、`robot_use_multi_agent`、设置页持久化、WebShell/机器人多代理、`bind_role` 子代理 Skills/tools。 |
| 2026-03-22 | `tool_result.toolCallId``ReasoningContent`→思考流、`batch_use_multi_agent` 与批量队列 Eino 执行。 |
| 2026-03-22 | 流式工具事件:按稳定签名去重,避免每 chunk 刷屏与「未知工具」;最终回复去重相同段落;内置调度显示为 `task`。 |
| 2026-03-22 | `agents/*.md` 子代理定义、`agents_dir`、合并进 `RunDeepAgent`、前端 Agents 菜单与 CRUD API。 |
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
+45 -1
View File
@@ -4,10 +4,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
"github.com/eino-contrib/jsonschema" "github.com/eino-contrib/jsonschema"
) )
@@ -15,8 +18,18 @@ import (
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。 // ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
type ExecutionRecorder func(executionID string) type ExecutionRecorder func(executionID string)
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n"
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。 // ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
func ToolsFromDefinitions(ag *agent.Agent, holder *ConversationHolder, defs []agent.Tool, rec ExecutionRecorder) ([]tool.BaseTool, error) { func ToolsFromDefinitions(
ag *agent.Agent,
holder *ConversationHolder,
defs []agent.Tool,
rec ExecutionRecorder,
toolOutputChunk func(toolName, toolCallID, chunk string),
) ([]tool.BaseTool, error) {
out := make([]tool.BaseTool, 0, len(defs)) out := make([]tool.BaseTool, 0, len(defs))
for _, d := range defs { for _, d := range defs {
if d.Type != "function" || d.Function.Name == "" { if d.Type != "function" || d.Function.Name == "" {
@@ -32,6 +45,7 @@ func ToolsFromDefinitions(ag *agent.Agent, holder *ConversationHolder, defs []ag
agent: ag, agent: ag,
holder: holder, holder: holder,
record: rec, record: rec,
chunk: toolOutputChunk,
}) })
} }
return out, nil return out, nil
@@ -68,6 +82,7 @@ type mcpBridgeTool struct {
agent *agent.Agent agent *agent.Agent
holder *ConversationHolder holder *ConversationHolder
record ExecutionRecorder record ExecutionRecorder
chunk func(toolName, toolCallID, chunk string)
} }
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
@@ -86,6 +101,32 @@ func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string
if args == nil { if args == nil {
args = map[string]interface{}{} args = map[string]interface{}{}
} }
// Stream tool output (stdout/stderr) to upper layer via security.Executor's callback.
// This enables multi-agent mode to show execution progress on the frontend.
if m.chunk != nil {
toolCallID := compose.GetToolCallID(ctx)
if toolCallID != "" {
if existing, ok := ctx.Value(security.ToolOutputCallbackCtxKey).(security.ToolOutputCallback); ok && existing != nil {
// Chain existing callback (if any) + our progress forwarder.
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
existing(c)
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
}))
} else {
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
}))
}
}
}
conv := m.holder.Get() conv := m.holder.Get()
res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args) res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args)
if err != nil { if err != nil {
@@ -97,5 +138,8 @@ func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string
if res.ExecutionID != "" && m.record != nil { if res.ExecutionID != "" && m.record != nil {
m.record(res.ExecutionID) m.record(res.ExecutionID)
} }
if res.IsError {
return ToolErrorPrefix + res.Result, nil
}
return res.Result, nil return res.Result, nil
} }
+10 -1
View File
@@ -44,11 +44,20 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
// 用于在 sendEvent 中判断是否为用户主动停止导致的取消。
// 注意:baseCtx 会在后面创建;该变量用于闭包提前捕获引用。
var baseCtx context.Context
clientDisconnected := false clientDisconnected := false
sendEvent := func(eventType, message string, data interface{}) { sendEvent := func(eventType, message string, data interface{}) {
if clientDisconnected { if clientDisconnected {
return return
} }
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
return
}
select { select {
case <-c.Request.Context().Done(): case <-c.Request.Context().Done():
clientDisconnected = true clientDisconnected = true
@@ -135,7 +144,6 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
) )
if runErr != nil { if runErr != nil {
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) { if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled" taskStatus = "cancelled"
@@ -153,6 +161,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
return return
} }
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
taskStatus = "failed" taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error() errMsg := "执行失败: " + runErr.Error()
+62
View File
@@ -0,0 +1,62 @@
package multiagent
import (
"context"
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
)
// noNestedTaskMiddleware 禁止在已经处于 task(sub-agent) 执行链中再次调用 task
// 避免子代理再次委派子代理造成的无限委派/递归。
//
// 通过在 ctx 中设置临时标记来实现嵌套检测:外层 task 调用会先标记 ctx,
// 子代理内再调用 task 时会命中该标记并拒绝。
type noNestedTaskMiddleware struct {
adk.BaseChatModelAgentMiddleware
}
type nestedTaskCtxKey struct{}
func newNoNestedTaskMiddleware() adk.ChatModelAgentMiddleware {
return &noNestedTaskMiddleware{}
}
func (m *noNestedTaskMiddleware) WrapInvokableToolCall(
ctx context.Context,
endpoint adk.InvokableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
if tCtx == nil || strings.TrimSpace(tCtx.Name) == "" {
return endpoint, nil
}
// Deep 内置 task 工具名固定为 "task";为兼容可能的大小写/空白,仅做不区分大小写匹配。
if !strings.EqualFold(strings.TrimSpace(tCtx.Name), "task") {
return endpoint, nil
}
// 已在 task 执行链中:拒绝继续委派,直接报错让上层快速终止。
if ctx != nil {
if v, ok := ctx.Value(nestedTaskCtxKey{}).(bool); ok && v {
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
// Important: return a tool result text (not an error) to avoid hard-stopping the whole multi-agent run.
// The nested task is still prevented from spawning another sub-agent, so recursion is avoided.
_ = argumentsInJSON
_ = opts
return "Nested task delegation is forbidden (already inside a sub-agent delegation chain) to avoid infinite delegation. Please continue the work using the current agent's tools.", nil
}, nil
}
}
// 标记当前 task 调用链,确保子代理内的再次 task 调用能检测到嵌套。
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
ctx2 := ctx
if ctx2 == nil {
ctx2 = context.Background()
}
ctx2 = context.WithValue(ctx2, nestedTaskCtxKey{}, true)
return endpoint(ctx2, argumentsInJSON, opts...)
}, nil
}
+36 -6
View File
@@ -95,7 +95,23 @@ func RunDeepAgent(
} }
mainDefs := ag.ToolsForRole(roleTools) mainDefs := ag.ToolsForRole(roleTools)
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder) toolOutputChunk := func(toolName, toolCallID, chunk string) {
// When toolCallId is missing, frontend ignores tool_result_delta.
if progress == nil || toolCallID == "" {
return
}
progress("tool_result_delta", chunk, map[string]interface{}{
"toolName": toolName,
"toolCallId": toolCallID,
// index/total/iteration are optional for UI; we don't know them in this bridge.
"index": 0,
"total": 0,
"iteration": 0,
"source": "eino",
})
}
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -183,7 +199,7 @@ func RunDeepAgent(
} }
subDefs := ag.ToolsForRole(roleTools) subDefs := ag.ToolsForRole(roleTools)
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder) subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk)
if err != nil { if err != nil {
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err) return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
} }
@@ -252,7 +268,11 @@ func RunDeepAgent(
WithoutGeneralSubAgent: ma.WithoutGeneralSubAgent, WithoutGeneralSubAgent: ma.WithoutGeneralSubAgent,
WithoutWriteTodos: ma.WithoutWriteTodos, WithoutWriteTodos: ma.WithoutWriteTodos,
MaxIteration: deepMaxIter, MaxIteration: deepMaxIter,
Handlers: []adk.ChatModelAgentMiddleware{mainSumMw}, // 防止 sub-agent 再调用 task(再委派 sub-agent),形成无限委派链。
Handlers: []adk.ChatModelAgentMiddleware{
newNoNestedTaskMiddleware(),
mainSumMw,
},
ToolsConfig: adk.ToolsConfig{ ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{ ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: mainTools, Tools: mainTools,
@@ -451,14 +471,24 @@ func RunDeepAgent(
if toolName == "" { if toolName == "" {
toolName = mv.ToolName toolName = mv.ToolName
} }
preview := msg.Content
// bridge 工具在 res.IsError=true 时会返回带前缀的内容;这里解析为 success/isError,避免前端误判为成功。
content := msg.Content
isErr := false
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
isErr = true
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
}
preview := content
if len(preview) > 200 { if len(preview) > 200 {
preview = preview[:200] + "..." preview = preview[:200] + "..."
} }
data := map[string]interface{}{ data := map[string]interface{}{
"toolName": toolName, "toolName": toolName,
"success": true, "success": !isErr,
"result": msg.Content, "isError": isErr,
"result": content,
"resultPreview": preview, "resultPreview": preview,
"conversationId": conversationID, "conversationId": conversationID,
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
+45
View File
@@ -0,0 +1,45 @@
name: "lightx"
command: "lightx"
enabled: false
short_description: "轻量级资产发现与漏洞扫描工具"
description: |
Lightx 是一个高效的轻量级扫描工具,支持对单个目标、IP 段或文件列表进行快速探测。
**主要功能:**
- 支持多种目标格式(URL, IP, CIDR, 域名)
- 支持从文件批量读取目标
- 快速资产发现与服务识别
- 轻量级并发扫描
**使用场景:**
- 批量资产存活检测
- 网段快速扫描
- 域名信息收集
- 渗透测试前期侦察
**目标格式示例:**
- 单个 URL: http://example.com
- 单个 IP: 192.168.1.1
- IP 段: 192.168.1.1/24
- 域名: example.com
- 文件: targets.txt
parameters:
- name: "target"
type: "string"
description: |
扫描目标,支持多种格式。
**支持的格式:**
- **URL**: "http://example.com" 或 "https://target.com/path"
- **IP 地址**: "192.168.1.1"
- **IP 段 (CIDR)**: "192.168.1.0/24", "10.0.0.0/8"
- **域名**: "example.com" (不带协议头)
- **文件路径**: "/path/to/targets.txt" (文件中每行一个目标)
**示例值:**
- "http://172.16.0.4:9000"
- "192.168.1.1/24"
- "targets.txt"
required: true
flag: "-t"
format: "flag"
+386 -7
View File
@@ -8600,6 +8600,18 @@ header {
flex-shrink: 0; flex-shrink: 0;
} }
.webshell-sidebar-tools {
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.45);
}
.webshell-sidebar-tools .btn-ghost {
width: 100%;
border: 1px solid var(--border-color);
background: #fff;
}
.webshell-conn-search-input { .webshell-conn-search-input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
@@ -8680,6 +8692,41 @@ header {
white-space: nowrap; white-space: nowrap;
} }
.webshell-item-remark-row {
display: flex;
align-items: center;
gap: 8px;
}
.webshell-probe-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 0.72rem;
padding: 2px 8px;
border: 1px solid transparent;
flex-shrink: 0;
}
.webshell-probe-badge.probing {
color: #a16207;
background: #fef3c7;
border-color: #fde68a;
}
.webshell-probe-badge.ok {
color: #166534;
background: #dcfce7;
border-color: #86efac;
}
.webshell-probe-badge.fail {
color: #b91c1c;
background: #fee2e2;
border-color: #fca5a5;
}
.webshell-item-url { .webshell-item-url {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -8693,6 +8740,8 @@ header {
.webshell-item-actions { .webshell-item-actions {
margin-top: 6px; margin-top: 6px;
flex-shrink: 0; flex-shrink: 0;
display: flex;
justify-content: flex-end;
} }
.webshell-delete-btn { .webshell-delete-btn {
@@ -8721,7 +8770,7 @@ header {
.webshell-workspace { .webshell-workspace {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 20px 24px; padding: 16px 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -8895,10 +8944,10 @@ header {
.webshell-file-toolbar { .webshell-file-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 10px;
margin-bottom: 16px; margin-bottom: 12px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 14px 16px; padding: 12px 14px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -8907,6 +8956,15 @@ header {
box-sizing: border-box; box-sizing: border-box;
} }
.webshell-file-toolbar-main {
display: flex;
flex: 1 1 720px;
min-width: 0;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.webshell-file-toolbar label { .webshell-file-toolbar label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -8931,6 +8989,10 @@ header {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.webshell-file-path-field {
flex: 1 1 260px !important;
}
.webshell-file-toolbar .btn-secondary, .webshell-file-toolbar .btn-secondary,
.webshell-file-toolbar .btn-ghost { .webshell-file-toolbar .btn-ghost {
padding: 8px 16px; padding: 8px 16px;
@@ -8940,6 +9002,13 @@ header {
flex-shrink: 0; flex-shrink: 0;
} }
.webshell-file-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.webshell-file-breadcrumb { .webshell-file-breadcrumb {
width: 100%; width: 100%;
flex: 0 0 100%; flex: 0 0 100%;
@@ -8959,8 +9028,8 @@ header {
} }
.webshell-file-filter { .webshell-file-filter {
min-width: 0 !important; min-width: 0 !important;
flex: 0 1 140px; flex: 1 1 180px;
max-width: 200px; max-width: 260px;
} }
.webshell-col-check { .webshell-col-check {
width: 36px; width: 36px;
@@ -8968,11 +9037,49 @@ header {
vertical-align: middle; vertical-align: middle;
} }
.webshell-col-size { .webshell-col-size {
width: 80px; width: 90px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
} }
.webshell-col-mtime {
width: 170px;
color: var(--text-secondary);
font-size: 0.82rem;
white-space: nowrap;
}
.webshell-col-owner,
.webshell-col-group {
width: 110px;
color: var(--text-secondary);
font-size: 0.82rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.webshell-col-perms {
width: 150px;
font-family: ui-monospace, monospace;
font-size: 0.82rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.webshell-col-type {
width: 72px;
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: lowercase;
}
.webshell-col-actions {
width: 90px;
}
.webshell-file-list { .webshell-file-list {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -8989,6 +9096,7 @@ header {
.webshell-file-table { .webshell-file-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed;
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -9011,6 +9119,19 @@ header {
transition: background 0.15s ease; transition: background 0.15s ease;
} }
.webshell-file-empty-state {
padding: 28px 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.92rem;
background: var(--bg-primary);
}
.webshell-file-table td:last-child {
white-space: nowrap;
width: auto;
}
.webshell-file-table tbody tr:hover { .webshell-file-table tbody tr:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
@@ -9056,6 +9177,93 @@ header {
background: rgba(220, 53, 69, 0.08); background: rgba(220, 53, 69, 0.08);
} }
/* WebShell 行内“操作”下拉菜单(替代一堆按钮) */
.webshell-conn-actions,
.webshell-row-actions {
display: inline-block;
position: relative;
}
.webshell-conn-actions summary,
.webshell-row-actions summary {
list-style: none;
cursor: pointer;
user-select: none;
}
.webshell-conn-actions summary::-webkit-details-marker,
.webshell-row-actions summary::-webkit-details-marker {
display: none;
}
.webshell-row-actions-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 20;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 6px;
box-shadow: var(--shadow-md);
min-width: 180px;
gap: 6px;
flex-direction: column;
}
.webshell-toolbar-actions {
position: relative;
}
.webshell-toolbar-actions .webshell-row-actions-menu {
min-width: 220px;
}
.webshell-conn-actions[open] .webshell-row-actions-menu,
.webshell-row-actions[open] .webshell-row-actions-menu,
.webshell-toolbar-actions[open] .webshell-row-actions-menu {
display: flex;
}
.webshell-row-actions-menu .btn-ghost {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 0.86rem;
white-space: nowrap;
}
.webshell-row-actions-menu .btn-ghost:hover {
background: rgba(0, 102, 255, 0.08);
border-color: rgba(0, 102, 255, 0.22);
}
.webshell-row-actions-menu .webshell-file-del:hover {
background: rgba(220, 53, 69, 0.08);
border-color: rgba(220, 53, 69, 0.25);
}
.webshell-conn-actions-btn,
.webshell-row-actions-btn,
.webshell-toolbar-actions-btn {
min-width: 72px;
text-align: center;
border: 1px solid var(--border-color);
background: #fff;
}
.webshell-conn-actions-btn:hover,
.webshell-row-actions-btn:hover,
.webshell-toolbar-actions-btn:hover {
background: var(--bg-secondary);
}
.webshell-loading { .webshell-loading {
padding: 24px 20px; padding: 24px 20px;
color: var(--text-muted); color: var(--text-muted);
@@ -9438,6 +9646,31 @@ header {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.webshell-ai-msg.assistant.webshell-ai-msg-error {
max-width: 72%;
border-color: rgba(220, 53, 69, 0.35);
background: rgba(220, 53, 69, 0.06);
}
.webshell-ai-error-head {
color: var(--error-color);
font-weight: 600;
line-height: 1.45;
}
.webshell-ai-error-detail {
margin-top: 6px;
font-size: 0.82rem;
}
.webshell-ai-error-detail summary {
cursor: pointer;
color: var(--text-secondary);
}
.webshell-ai-error-detail pre {
margin-top: 6px;
max-height: 140px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* AI 助手 markdown 渲染优化:避免行间距离过大和内容横向溢出 */ /* AI 助手 markdown 渲染优化:避免行间距离过大和内容横向溢出 */
.webshell-ai-msg.assistant { .webshell-ai-msg.assistant {
/* markdown 里已经有块级元素,不需要再整体 pre-wrap,否则容易在块之间产生“空行”感 */ /* markdown 里已经有块级元素,不需要再整体 pre-wrap,否则容易在块之间产生“空行”感 */
@@ -9549,6 +9782,152 @@ header {
justify-content: center; justify-content: center;
} }
/* WebShell 数据库管理 Tab */
.webshell-pane-db {
flex: 1;
min-height: 0;
flex-direction: column;
gap: 12px;
padding: 14px;
overflow: hidden;
background: linear-gradient(180deg, rgba(2, 6, 23, 0.015) 0%, rgba(2, 6, 23, 0.03) 100%);
border-radius: 10px;
}
.webshell-db-toolbar {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 12px;
padding: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.webshell-db-toolbar label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.webshell-db-toolbar label:focus-within {
border-color: rgba(0, 102, 255, 0.38);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
transform: translateY(-1px);
}
.webshell-db-toolbar label span {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.webshell-db-toolbar .form-control {
height: 36px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.16);
background: #fff;
font-size: 0.9rem;
padding-left: 10px;
padding-right: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.webshell-db-toolbar .form-control:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
background: #fff;
}
#webshell-db-sqlite-row {
grid-column: 1 / -1;
}
.webshell-db-sql {
width: 100%;
min-height: 140px;
resize: vertical;
font-family: var(--font-mono, Menlo, Monaco, Consolas, "Courier New", monospace);
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
line-height: 1.45;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.webshell-db-sql:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
}
.webshell-db-actions {
display: flex;
gap: 8px;
align-items: center;
}
.webshell-db-actions .btn-primary,
.webshell-db-actions .btn-ghost {
min-width: 96px;
height: 34px;
border-radius: 8px;
}
.webshell-db-output-wrap {
flex: 1;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-primary);
display: flex;
flex-direction: column;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.webshell-db-output-title {
padding: 9px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text-secondary);
background: linear-gradient(180deg, rgba(2, 6, 23, 0.015) 0%, transparent 100%);
}
.webshell-db-output {
flex: 1;
margin: 0;
padding: 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, Menlo, Monaco, Consolas, "Courier New", monospace);
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-primary);
}
.webshell-db-output.error {
color: var(--error-color);
}
.webshell-db-hint {
border-top: 1px solid var(--border-color);
font-size: 0.76rem;
color: var(--text-secondary);
padding: 8px 12px;
background: rgba(2, 6, 23, 0.02);
}
@media (max-width: 1280px) {
.webshell-db-toolbar {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
}
@media (max-width: 980px) {
.webshell-db-toolbar {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@media (max-width: 700px) {
.webshell-db-toolbar {
grid-template-columns: 1fr;
}
}
/* 仪表盘页面样式(最佳实践布局 + 视觉增强) */ /* 仪表盘页面样式(最佳实践布局 + 视觉增强) */
.dashboard-page { .dashboard-page {
height: 100%; height: 100%;
+33 -2
View File
@@ -19,7 +19,8 @@
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"copyFailed": "Copy failed", "copyFailed": "Copy failed",
"view": "View" "view": "View",
"actions": "Actions"
}, },
"header": { "header": {
"title": "CyberStrikeAI", "title": "CyberStrikeAI",
@@ -146,6 +147,7 @@
"callNumber": "Call #{{n}}", "callNumber": "Call #{{n}}",
"iterationRound": "Iteration {{n}}", "iterationRound": "Iteration {{n}}",
"aiThinking": "AI thinking", "aiThinking": "AI thinking",
"planning": "Planning",
"toolCallsDetected": "Detected {{count}} tool call(s)", "toolCallsDetected": "Detected {{count}} tool call(s)",
"callTool": "Call tool: {{name}} ({{index}}/{{total}})", "callTool": "Call tool: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "Tool {{name}} completed", "toolExecComplete": "Tool {{name}} completed",
@@ -371,6 +373,23 @@
"tabTerminal": "Virtual terminal", "tabTerminal": "Virtual terminal",
"tabFileManager": "File manager", "tabFileManager": "File manager",
"tabAiAssistant": "AI Assistant", "tabAiAssistant": "AI Assistant",
"tabDbManager": "Database Manager",
"dbType": "Database type",
"dbHost": "Host",
"dbPort": "Port",
"dbUsername": "Username",
"dbPassword": "Password",
"dbName": "Database name",
"dbSqlitePath": "SQLite file path",
"dbSqlPlaceholder": "Enter SQL, e.g. SELECT version();",
"dbRunSql": "Run SQL",
"dbTest": "Test connection",
"dbOutput": "Output",
"dbNoConn": "Please select a WebShell connection first",
"dbSqlRequired": "Please enter SQL",
"dbRunning": "Database command is running, please wait",
"dbCliHint": "If command not found appears, install mysql/psql/sqlite3/sqlcmd on the target host first",
"dbExecFailed": "Database execution failed",
"aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.", "aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
"aiNewConversation": "New conversation", "aiNewConversation": "New conversation",
"aiPreviousConversation": "Previous conversation", "aiPreviousConversation": "Previous conversation",
@@ -408,7 +427,19 @@
"selectAll": "Select all", "selectAll": "Select all",
"searchPlaceholder": "Search connections...", "searchPlaceholder": "Search connections...",
"noMatchConnections": "No matching connections", "noMatchConnections": "No matching connections",
"breadcrumbHome": "Root" "breadcrumbHome": "Root",
"back": "Back",
"moreActions": "More actions",
"batchProbe": "Batch probe",
"probeRunning": "Probing",
"probeOnline": "Online",
"probeOffline": "Offline",
"probeNoConnections": "No connections to probe",
"colModifiedAt": "Modified",
"colPerms": "Permissions",
"colOwner": "Owner",
"colGroup": "Group",
"colType": "Type"
}, },
"mcp": { "mcp": {
"monitorTitle": "MCP Status Monitor", "monitorTitle": "MCP Status Monitor",
+33 -2
View File
@@ -19,7 +19,8 @@
"copy": "复制", "copy": "复制",
"copied": "已复制", "copied": "已复制",
"copyFailed": "复制失败", "copyFailed": "复制失败",
"view": "查看" "view": "查看",
"actions": "操作"
}, },
"header": { "header": {
"title": "CyberStrikeAI", "title": "CyberStrikeAI",
@@ -146,6 +147,7 @@
"callNumber": "调用 #{{n}}", "callNumber": "调用 #{{n}}",
"iterationRound": "第 {{n}} 轮迭代", "iterationRound": "第 {{n}} 轮迭代",
"aiThinking": "AI思考", "aiThinking": "AI思考",
"planning": "规划中",
"toolCallsDetected": "检测到 {{count}} 个工具调用", "toolCallsDetected": "检测到 {{count}} 个工具调用",
"callTool": "调用工具: {{name}} ({{index}}/{{total}})", "callTool": "调用工具: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "工具 {{name}} 执行完成", "toolExecComplete": "工具 {{name}} 执行完成",
@@ -371,6 +373,23 @@
"tabTerminal": "虚拟终端", "tabTerminal": "虚拟终端",
"tabFileManager": "文件管理", "tabFileManager": "文件管理",
"tabAiAssistant": "AI 助手", "tabAiAssistant": "AI 助手",
"tabDbManager": "数据库管理",
"dbType": "数据库类型",
"dbHost": "主机",
"dbPort": "端口",
"dbUsername": "用户名",
"dbPassword": "密码",
"dbName": "数据库名",
"dbSqlitePath": "SQLite 文件路径",
"dbSqlPlaceholder": "输入 SQL,例如:SELECT version();",
"dbRunSql": "执行 SQL",
"dbTest": "测试连接",
"dbOutput": "执行输出",
"dbNoConn": "请先选择 WebShell 连接",
"dbSqlRequired": "请输入 SQL",
"dbRunning": "数据库命令执行中,请稍候",
"dbCliHint": "如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd",
"dbExecFailed": "数据库执行失败",
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。", "aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"aiNewConversation": "新对话", "aiNewConversation": "新对话",
"aiPreviousConversation": "之前的对话", "aiPreviousConversation": "之前的对话",
@@ -408,7 +427,19 @@
"selectAll": "全选", "selectAll": "全选",
"searchPlaceholder": "搜索连接...", "searchPlaceholder": "搜索连接...",
"noMatchConnections": "暂无匹配连接", "noMatchConnections": "暂无匹配连接",
"breadcrumbHome": "根" "breadcrumbHome": "根",
"back": "返回",
"moreActions": "更多操作",
"batchProbe": "一键批量探活",
"probeRunning": "探活中",
"probeOnline": "在线",
"probeOffline": "离线",
"probeNoConnections": "暂无可探活连接",
"colModifiedAt": "修改时间",
"colPerms": "权限",
"colOwner": "所有者",
"colGroup": "用户组",
"colType": "类型"
}, },
"mcp": { "mcp": {
"monitorTitle": "MCP 状态监控", "monitorTitle": "MCP 状态监控",
+34 -19
View File
@@ -1101,16 +1101,16 @@ function handleStreamEvent(event, progressElement, progressId,
loadActiveTasks(); loadActiveTasks();
} }
// 主回复开始流式输出时隐藏整条进度卡片(迭代阶段默认展开;最终回复时不再占屏) // 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
hideProgressMessageForFinalReply(progressId); // 创建时间线条目用于显示迭代过程中的输出
const agentPrefix = timelineAgentBracketPrefix(responseData);
// 已存在则复用;否则创建空助手消息占位,用于增量追加 const title = agentPrefix + '📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中');
const existing = responseStreamStateByProgressId.get(progressId); const itemId = addTimelineItem(timeline, 'thinking', {
if (existing && existing.assistantId) break; title: title,
message: ' ',
const assistantId = addMessage('assistant', '', mcpIds, progressId); data: responseData
setAssistantId(assistantId); });
responseStreamStateByProgressId.set(progressId, { assistantId, buffer: '' }); responseStreamStateByProgressId.set(progressId, { itemId: itemId, buffer: '' });
break; break;
} }
@@ -1126,19 +1126,31 @@ function handleStreamEvent(event, progressElement, progressId,
} }
} }
hideProgressMessageForFinalReply(progressId); // 多代理模式下,迭代过程中的输出只显示在时间线中
// 更新时间线条目内容
let state = responseStreamStateByProgressId.get(progressId); let state = responseStreamStateByProgressId.get(progressId);
if (!state || !state.assistantId) { if (!state) {
const mcpIds = responseData.mcpExecutionIds || []; state = { itemId: null, buffer: '' };
const assistantId = addMessage('assistant', '', mcpIds, progressId);
setAssistantId(assistantId);
state = { assistantId, buffer: '' };
responseStreamStateByProgressId.set(progressId, state); responseStreamStateByProgressId.set(progressId, state);
} }
state.buffer += (event.message || ''); const deltaContent = event.message || '';
updateAssistantBubbleContent(state.assistantId, state.buffer, false); state.buffer += deltaContent;
// 更新时间线条目内容
if (state.itemId) {
const item = document.getElementById(state.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(state.buffer);
} else {
contentEl.textContent = state.buffer;
}
}
}
}
break; break;
} }
@@ -1179,6 +1191,9 @@ function handleStreamEvent(event, progressElement, progressId,
updateAssistantBubbleContent(assistantIdFinal, event.message, true); updateAssistantBubbleContent(assistantIdFinal, event.message, true);
} }
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
hideProgressMessageForFinalReply(progressId);
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整) // 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds); integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
responseStreamStateByProgressId.delete(progressId); responseStreamStateByProgressId.delete(progressId);
+608 -26
View File
@@ -23,8 +23,11 @@ let webshellClearInProgress = false;
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话 // AI 助手:按连接 ID 保存对话 ID,便于多轮对话
let webshellAiConvMap = {}; let webshellAiConvMap = {};
let webshellAiSending = false; let webshellAiSending = false;
let webshellDbConfigByConn = {};
// 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字
let webshellStreamingTypingId = 0; let webshellStreamingTypingId = 0;
let webshellProbeStatusById = {};
let webshellBatchProbeRunning = false;
/** 与主对话页一致:multi_agent.enabled 且本地模式为 multi 时使用 /api/multi-agent/stream */ /** 与主对话页一致:multi_agent.enabled 且本地模式为 multi 时使用 /api/multi-agent/stream */
function resolveWebshellAiStreamPath() { function resolveWebshellAiStreamPath() {
@@ -87,6 +90,23 @@ function wsT(key) {
'webshell.tabTerminal': '虚拟终端', 'webshell.tabTerminal': '虚拟终端',
'webshell.tabFileManager': '文件管理', 'webshell.tabFileManager': '文件管理',
'webshell.tabAiAssistant': 'AI 助手', 'webshell.tabAiAssistant': 'AI 助手',
'webshell.tabDbManager': '数据库管理',
'webshell.dbType': '数据库类型',
'webshell.dbHost': '主机',
'webshell.dbPort': '端口',
'webshell.dbUsername': '用户名',
'webshell.dbPassword': '密码',
'webshell.dbName': '数据库名',
'webshell.dbSqlitePath': 'SQLite 文件路径',
'webshell.dbSqlPlaceholder': '输入 SQL,例如:SELECT version();',
'webshell.dbRunSql': '执行 SQL',
'webshell.dbTest': '测试连接',
'webshell.dbOutput': '执行输出',
'webshell.dbNoConn': '请先选择 WebShell 连接',
'webshell.dbSqlRequired': '请输入 SQL',
'webshell.dbRunning': '数据库命令执行中,请稍候',
'webshell.dbCliHint': '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd',
'webshell.dbExecFailed': '数据库执行失败',
'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。', 'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。',
'webshell.aiPlaceholder': '例如:列出当前目录下的文件', 'webshell.aiPlaceholder': '例如:列出当前目录下的文件',
'webshell.aiSend': '发送', 'webshell.aiSend': '发送',
@@ -116,13 +136,26 @@ function wsT(key) {
'webshell.filterPlaceholder': '过滤文件名', 'webshell.filterPlaceholder': '过滤文件名',
'webshell.batchDelete': '批量删除', 'webshell.batchDelete': '批量删除',
'webshell.batchDownload': '批量下载', 'webshell.batchDownload': '批量下载',
'webshell.moreActions': '更多操作',
'webshell.refresh': '刷新', 'webshell.refresh': '刷新',
'webshell.selectAll': '全选', 'webshell.selectAll': '全选',
'webshell.breadcrumbHome': '根', 'webshell.breadcrumbHome': '根',
'webshell.searchPlaceholder': '搜索连接...', 'webshell.searchPlaceholder': '搜索连接...',
'webshell.noMatchConnections': '暂无匹配连接', 'webshell.noMatchConnections': '暂无匹配连接',
'webshell.batchProbe': '一键批量探活',
'webshell.probeRunning': '探活中',
'webshell.probeOnline': '在线',
'webshell.probeOffline': '离线',
'webshell.probeNoConnections': '暂无可探活连接',
'webshell.back': '返回',
'webshell.colModifiedAt': '修改时间',
'webshell.colPerms': '权限',
'webshell.colOwner': '所有者',
'webshell.colGroup': '用户组',
'webshell.colType': '类型',
'common.delete': '删除', 'common.delete': '删除',
'common.refresh': '刷新' 'common.refresh': '刷新',
'common.actions': '操作'
}; };
return fallback[key] || key; return fallback[key] || key;
} }
@@ -149,9 +182,30 @@ function bindWebshellClearOnce() {
}, true); }, true);
} }
// WebShell 行内/工具栏“操作”下拉:点击菜单外自动收起
function bindWebshellActionMenusAutoCloseOnce() {
if (window._webshellActionMenusAutoCloseBound) return;
window._webshellActionMenusAutoCloseBound = true;
document.addEventListener('click', function (e) {
// 只要点在 details 内部,就让浏览器自行切换(open/close)
var clickedInMenu = e.target && e.target.closest && (
e.target.closest('details.webshell-conn-actions') ||
e.target.closest('details.webshell-row-actions') ||
e.target.closest('details.webshell-toolbar-actions')
);
if (clickedInMenu) return;
var openDetails = document.querySelectorAll(
'details.webshell-conn-actions[open],details.webshell-row-actions[open],details.webshell-toolbar-actions[open]'
);
openDetails.forEach(function (d) { d.open = false; });
}, true);
}
// 初始化 WebShell 管理页面(从 SQLite 拉取连接列表) // 初始化 WebShell 管理页面(从 SQLite 拉取连接列表)
function initWebshellPage() { function initWebshellPage() {
bindWebshellClearOnce(); bindWebshellClearOnce();
bindWebshellActionMenusAutoCloseOnce();
destroyWebshellTerminal(); destroyWebshellTerminal();
webshellCurrentConn = null; webshellCurrentConn = null;
currentWebshellId = null; currentWebshellId = null;
@@ -177,6 +231,15 @@ function initWebshellPage() {
webshellConnections = list; webshellConnections = list;
renderWebshellList(); renderWebshellList();
}); });
var batchProbeBtn = document.getElementById('webshell-batch-probe-btn');
if (batchProbeBtn && batchProbeBtn.dataset.bound !== '1') {
batchProbeBtn.dataset.bound = '1';
batchProbeBtn.addEventListener('click', function () {
runBatchProbeWebshellConnections();
});
}
updateWebshellBatchProbeButton();
} }
function getWebshellSidebarWidth() { function getWebshellSidebarWidth() {
@@ -287,13 +350,26 @@ function renderWebshellList() {
const urlTitle = (conn.url || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); const urlTitle = (conn.url || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
const active = currentWebshellId === conn.id ? ' active' : ''; const active = currentWebshellId === conn.id ? ' active' : '';
const safeId = escapeHtml(conn.id); const safeId = escapeHtml(conn.id);
const actionsLabel = wsT('common.actions') || '操作';
const probe = webshellProbeStatusById[conn.id] || null;
var probeHtml = '';
if (probe && probe.state === 'probing') {
probeHtml = '<span class="webshell-probe-badge probing">' + (wsT('webshell.probeRunning') || '探活中') + '</span>';
} else if (probe && probe.state === 'ok') {
probeHtml = '<span class="webshell-probe-badge ok">' + (wsT('webshell.probeOnline') || '在线') + '</span>';
} else if (probe && probe.state === 'fail') {
probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>';
}
return ( return (
'<div class="webshell-item' + active + '" data-id="' + safeId + '">' + '<div class="webshell-item' + active + '" data-id="' + safeId + '">' +
'<div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + '<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + '</div>' +
'<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' + '<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' +
'<div class="webshell-item-actions">' + '<div class="webshell-item-actions">' +
'<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
'<div class="webshell-row-actions-menu">' +
'<button type="button" class="btn-ghost btn-sm webshell-edit-conn-btn" data-id="' + safeId + '" title="' + wsT('webshell.editConnection') + '">' + wsT('webshell.editConnection') + '</button>' + '<button type="button" class="btn-ghost btn-sm webshell-edit-conn-btn" data-id="' + safeId + '" title="' + wsT('webshell.editConnection') + '">' + wsT('webshell.editConnection') + '</button>' +
'<button type="button" class="btn-ghost btn-sm webshell-delete-btn" data-id="' + safeId + '" title="' + wsT('common.delete') + '">' + wsT('common.delete') + '</button>' + '<button type="button" class="btn-ghost btn-sm webshell-delete-btn" data-id="' + safeId + '" title="' + wsT('common.delete') + '">' + wsT('common.delete') + '</button>' +
'</div></details>' +
'</div>' + '</div>' +
'</div>' '</div>'
); );
@@ -301,7 +377,7 @@ function renderWebshellList() {
listEl.querySelectorAll('.webshell-item').forEach(el => { listEl.querySelectorAll('.webshell-item').forEach(el => {
el.addEventListener('click', function (e) { el.addEventListener('click', function (e) {
if (e.target.closest('.webshell-delete-btn') || e.target.closest('.webshell-edit-conn-btn')) return; if (e.target.closest('.webshell-delete-btn') || e.target.closest('.webshell-edit-conn-btn') || e.target.closest('.webshell-conn-actions-btn')) return;
selectWebshell(el.getAttribute('data-id')); selectWebshell(el.getAttribute('data-id'));
}); });
}); });
@@ -319,6 +395,102 @@ function renderWebshellList() {
}); });
} }
function probeWebshellConnection(conn) {
if (!conn || typeof apiFetch === 'undefined') {
return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' });
}
return apiFetch('/api/webshell/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '',
command: 'echo 1'
})
})
.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');
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 };
})
.catch(function (e) {
return { ok: false, message: (e && e.message) ? e.message : String(e) };
});
}
function updateWebshellBatchProbeButton(done, total, okCount) {
var btn = document.getElementById('webshell-batch-probe-btn');
if (!btn) return;
if (webshellBatchProbeRunning) {
var d = typeof done === 'number' ? done : 0;
var t = typeof total === 'number' ? total : webshellConnections.length;
btn.disabled = true;
btn.textContent = (wsT('webshell.probeRunning') || '探活中') + ' ' + d + '/' + t;
return;
}
btn.disabled = false;
if (typeof done === 'number' && typeof total === 'number' && total > 0 && typeof okCount === 'number') {
btn.textContent = (wsT('webshell.batchProbe') || '一键批量探活') + ' (' + okCount + '/' + total + ')';
} else {
btn.textContent = wsT('webshell.batchProbe') || '一键批量探活';
}
}
function runBatchProbeWebshellConnections() {
if (webshellBatchProbeRunning) return;
if (!Array.isArray(webshellConnections) || webshellConnections.length === 0) {
alert(wsT('webshell.probeNoConnections') || '暂无可探活连接');
return;
}
webshellBatchProbeRunning = true;
var total = webshellConnections.length;
var done = 0;
var okCount = 0;
webshellConnections.forEach(function (conn) {
if (!conn || !conn.id) return;
webshellProbeStatusById[conn.id] = { state: 'probing', message: '' };
});
renderWebshellList();
updateWebshellBatchProbeButton(done, total, okCount);
var idx = 0;
var concurrency = Math.min(4, total);
function runOne() {
if (idx >= total) return Promise.resolve();
var conn = webshellConnections[idx++];
if (!conn || !conn.id) {
done++;
updateWebshellBatchProbeButton(done, total, okCount);
return runOne();
}
return probeWebshellConnection(conn).then(function (res) {
if (res.ok) okCount++;
webshellProbeStatusById[conn.id] = {
state: res.ok ? 'ok' : 'fail',
message: res.message || ''
};
done++;
renderWebshellList();
updateWebshellBatchProbeButton(done, total, okCount);
}).then(runOne);
}
var workers = [];
for (var i = 0; i < concurrency; i++) workers.push(runOne());
Promise.all(workers).finally(function () {
webshellBatchProbeRunning = false;
updateWebshellBatchProbeButton(done, total, okCount);
});
}
function escapeHtml(s) { function escapeHtml(s) {
if (!s) return ''; if (!s) return '';
const div = document.createElement('div'); const div = document.createElement('div');
@@ -326,6 +498,187 @@ function escapeHtml(s) {
return div.innerHTML; return div.innerHTML;
} }
function escapeSingleQuotedShellArg(value) {
var s = value == null ? '' : String(value);
return "'" + s.replace(/'/g, "'\\''") + "'";
}
function safeConnIdForStorage(conn) {
if (!conn || !conn.id) return '';
return String(conn.id).replace(/[^\w.-]/g, '_');
}
function getWebshellDbConfig(conn) {
var key = 'webshell_db_cfg_' + safeConnIdForStorage(conn);
if (!key) return {
type: 'mysql', host: '127.0.0.1', port: '3306', username: 'root', password: '', database: '', sqlitePath: '/tmp/test.db', sql: 'SELECT 1;'
};
if (webshellDbConfigByConn[key]) return webshellDbConfigByConn[key];
var def = {
type: 'mysql',
host: '127.0.0.1',
port: '3306',
username: 'root',
password: '',
database: '',
sqlitePath: '/tmp/test.db',
sql: 'SELECT 1;'
};
try {
var raw = localStorage.getItem(key);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
def = Object.assign(def, parsed);
}
}
} catch (e) {}
webshellDbConfigByConn[key] = def;
return def;
}
function saveWebshellDbConfig(conn, cfg) {
var key = 'webshell_db_cfg_' + safeConnIdForStorage(conn);
if (!key || !cfg) return;
webshellDbConfigByConn[key] = cfg;
try { localStorage.setItem(key, JSON.stringify(cfg)); } catch (e) {}
}
function webshellDbGetFieldValue(id) {
var el = document.getElementById(id);
return el && typeof el.value === 'string' ? el.value.trim() : '';
}
function webshellDbCollectConfig(conn) {
var cfg = {
type: webshellDbGetFieldValue('webshell-db-type') || 'mysql',
host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1',
port: webshellDbGetFieldValue('webshell-db-port') || '',
username: webshellDbGetFieldValue('webshell-db-user') || '',
password: (document.getElementById('webshell-db-pass') || {}).value || '',
database: webshellDbGetFieldValue('webshell-db-name') || '',
sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db',
sql: (document.getElementById('webshell-db-sql') || {}).value || ''
};
saveWebshellDbConfig(conn, cfg);
return cfg;
}
function webshellDbUpdateFieldVisibility() {
var type = webshellDbGetFieldValue('webshell-db-type') || 'mysql';
var isSqlite = type === 'sqlite';
var blocks = document.querySelectorAll('.webshell-db-common-field');
blocks.forEach(function (el) { el.style.display = isSqlite ? 'none' : ''; });
var sqliteBlock = document.getElementById('webshell-db-sqlite-row');
if (sqliteBlock) sqliteBlock.style.display = isSqlite ? '' : 'none';
var portEl = document.getElementById('webshell-db-port');
if (portEl && !String(portEl.value || '').trim()) {
if (type === 'mysql') portEl.value = '3306';
else if (type === 'pgsql') portEl.value = '5432';
else if (type === 'mssql') portEl.value = '1433';
}
}
function webshellDbSetOutput(text, isError) {
var outputEl = document.getElementById('webshell-db-output');
if (!outputEl) return;
outputEl.textContent = text || '';
outputEl.classList.toggle('error', !!isError);
}
function buildWebshellDbCommand(cfg, isTestOnly) {
var type = cfg.type || 'mysql';
var sql = String(isTestOnly ? 'SELECT 1;' : (cfg.sql || '')).trim();
if (!sql) return { error: wsT('webshell.dbSqlRequired') || '请输入 SQL' };
var sqlB64 = btoa(unescape(encodeURIComponent(sql)));
var sqlB64Arg = escapeSingleQuotedShellArg(sqlB64);
var tmpFile = '/tmp/.csai_sql_$$.sql';
var decodeToFile = 'printf %s ' + sqlB64Arg + " | base64 -d > " + tmpFile;
var cleanup = '; rc=$?; rm -f ' + tmpFile + '; echo "__CSAI_DB_RC__:$rc"; exit $rc';
var command = '';
if (type === 'mysql') {
var host = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1');
var port = escapeSingleQuotedShellArg(cfg.port || '3306');
var user = escapeSingleQuotedShellArg(cfg.username || 'root');
var pass = escapeSingleQuotedShellArg(cfg.password || '');
var db = cfg.database ? (' -D ' + escapeSingleQuotedShellArg(cfg.database)) : '';
command = decodeToFile + '; MYSQL_PWD=' + pass + ' mysql -h ' + host + ' -P ' + port + ' -u ' + user + db + ' --batch --raw < ' + tmpFile + cleanup;
} else if (type === 'pgsql') {
var pHost = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1');
var pPort = escapeSingleQuotedShellArg(cfg.port || '5432');
var pUser = escapeSingleQuotedShellArg(cfg.username || 'postgres');
var pPass = escapeSingleQuotedShellArg(cfg.password || '');
var pDb = escapeSingleQuotedShellArg(cfg.database || 'postgres');
command = decodeToFile + '; PGPASSWORD=' + pPass + ' psql -h ' + pHost + ' -p ' + pPort + ' -U ' + pUser + ' -d ' + pDb + ' -f ' + tmpFile + cleanup;
} else if (type === 'sqlite') {
var sqlitePath = escapeSingleQuotedShellArg(cfg.sqlitePath || '/tmp/test.db');
command = decodeToFile + '; sqlite3 -header -column ' + sqlitePath + ' < ' + tmpFile + cleanup;
} else if (type === 'mssql') {
var sHost = cfg.host || '127.0.0.1';
var sPort = cfg.port || '1433';
var sUser = escapeSingleQuotedShellArg(cfg.username || 'sa');
var sPass = escapeSingleQuotedShellArg(cfg.password || '');
var sDb = escapeSingleQuotedShellArg(cfg.database || 'master');
var server = escapeSingleQuotedShellArg(sHost + ',' + sPort);
command = decodeToFile + '; sqlcmd -S ' + server + ' -U ' + sUser + ' -P ' + sPass + ' -d ' + sDb + ' -i ' + tmpFile + cleanup;
} else {
return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type };
}
return { command: command };
}
function parseWebshellDbExecOutput(rawOutput) {
var raw = String(rawOutput || '');
var rc = null;
var cleaned = raw.replace(/__CSAI_DB_RC__:(\d+)\s*$/m, function (_, code) {
rc = parseInt(code, 10);
return '';
}).trim();
return { rc: rc, output: cleaned };
}
function simplifyWebshellAiError(rawMessage) {
var msg = String(rawMessage || '').trim();
var lower = msg.toLowerCase();
if ((lower.indexOf('401') !== -1 || lower.indexOf('unauthorized') !== -1) &&
(lower.indexOf('api key') !== -1 || lower.indexOf('apikey') !== -1)) {
return '鉴权失败:API Key 未配置或无效(401';
}
if (lower.indexOf('timeout') !== -1 || lower.indexOf('timed out') !== -1) {
return '请求超时,请稍后重试';
}
if (lower.indexOf('network') !== -1 || lower.indexOf('failed to fetch') !== -1) {
return '网络异常,请检查服务连通性';
}
return msg || '请求失败';
}
function renderWebshellAiErrorMessage(targetEl, rawMessage) {
if (!targetEl) return;
var full = String(rawMessage || '').trim();
var shortMsg = simplifyWebshellAiError(full);
targetEl.classList.add('webshell-ai-msg-error');
targetEl.innerHTML = '';
var head = document.createElement('div');
head.className = 'webshell-ai-error-head';
head.textContent = shortMsg;
targetEl.appendChild(head);
if (full && full !== shortMsg) {
var detail = document.createElement('details');
detail.className = 'webshell-ai-error-detail';
var summary = document.createElement('summary');
summary.textContent = '查看详细错误';
var pre = document.createElement('pre');
pre.textContent = full;
detail.appendChild(summary);
detail.appendChild(pre);
targetEl.appendChild(detail);
}
}
function formatWebshellAiConvDate(updatedAt) { function formatWebshellAiConvDate(updatedAt) {
if (!updatedAt) return ''; if (!updatedAt) return '';
var d = typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt; var d = typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt;
@@ -544,6 +897,7 @@ function selectWebshell(id) {
'<button type="button" class="webshell-tab active" data-tab="terminal">' + wsT('webshell.tabTerminal') + '</button>' + '<button type="button" class="webshell-tab active" data-tab="terminal">' + wsT('webshell.tabTerminal') + '</button>' +
'<button type="button" class="webshell-tab" data-tab="file">' + wsT('webshell.tabFileManager') + '</button>' + '<button type="button" class="webshell-tab" data-tab="file">' + wsT('webshell.tabFileManager') + '</button>' +
'<button type="button" class="webshell-tab" data-tab="ai">' + (wsT('webshell.tabAiAssistant') || 'AI 助手') + '</button>' + '<button type="button" class="webshell-tab" data-tab="ai">' + (wsT('webshell.tabAiAssistant') || 'AI 助手') + '</button>' +
'<button type="button" class="webshell-tab" data-tab="db">' + (wsT('webshell.tabDbManager') || '数据库管理') + '</button>' +
'</div>' + '</div>' +
'<div id="webshell-pane-terminal" class="webshell-pane active">' + '<div id="webshell-pane-terminal" class="webshell-pane active">' +
'<div class="webshell-terminal-toolbar">' + '<div class="webshell-terminal-toolbar">' +
@@ -566,16 +920,24 @@ function selectWebshell(id) {
'<div id="webshell-pane-file" class="webshell-pane">' + '<div id="webshell-pane-file" class="webshell-pane">' +
'<div class="webshell-file-toolbar">' + '<div class="webshell-file-toolbar">' +
'<div class="webshell-file-breadcrumb" id="webshell-file-breadcrumb"></div>' + '<div class="webshell-file-breadcrumb" id="webshell-file-breadcrumb"></div>' +
'<label><span>' + wsT('webshell.filePath') + '</span> <input type="text" id="webshell-file-path" class="form-control" value="." /></label>' + '<div class="webshell-file-toolbar-main">' +
'<label class="webshell-file-path-field"><span>' + wsT('webshell.filePath') + '</span> <input type="text" id="webshell-file-path" class="form-control" value="." /></label>' +
'<input type="text" id="webshell-file-filter" class="form-control webshell-file-filter" placeholder="' + (wsT('webshell.filterPlaceholder') || '过滤文件名') + '" />' + '<input type="text" id="webshell-file-filter" class="form-control webshell-file-filter" placeholder="' + (wsT('webshell.filterPlaceholder') || '过滤文件名') + '" />' +
'<button type="button" class="btn-secondary" id="webshell-list-dir">' + wsT('webshell.listDir') + '</button>' + '<button type="button" class="btn-secondary" id="webshell-list-dir">' + wsT('webshell.listDir') + '</button>' +
'<button type="button" class="btn-ghost" id="webshell-parent-dir">' + wsT('webshell.parentDir') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-parent-dir">' + wsT('webshell.parentDir') + '</button>' +
'</div>' +
'<div class="webshell-file-toolbar-actions">' +
'<button type="button" class="btn-ghost" id="webshell-file-refresh" title="' + (wsT('webshell.refresh') || '刷新') + '">' + (wsT('webshell.refresh') || '刷新') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-file-refresh" title="' + (wsT('webshell.refresh') || '刷新') + '">' + (wsT('webshell.refresh') || '刷新') + '</button>' +
'<details class="webshell-toolbar-actions">' +
'<summary class="btn-ghost webshell-toolbar-actions-btn">' + (wsT('webshell.moreActions') || '更多操作') + '</summary>' +
'<div class="webshell-row-actions-menu">' +
'<button type="button" class="btn-ghost" id="webshell-mkdir-btn">' + (wsT('webshell.newDir') || '新建目录') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-mkdir-btn">' + (wsT('webshell.newDir') || '新建目录') + '</button>' +
'<button type="button" class="btn-ghost" id="webshell-newfile-btn">' + (wsT('webshell.newFile') || '新建文件') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-newfile-btn">' + (wsT('webshell.newFile') || '新建文件') + '</button>' +
'<button type="button" class="btn-ghost" id="webshell-upload-btn">' + (wsT('webshell.upload') || '上传') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-upload-btn">' + (wsT('webshell.upload') || '上传') + '</button>' +
'<button type="button" class="btn-ghost" id="webshell-batch-delete-btn">' + (wsT('webshell.batchDelete') || '批量删除') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-batch-delete-btn">' + (wsT('webshell.batchDelete') || '批量删除') + '</button>' +
'<button type="button" class="btn-ghost" id="webshell-batch-download-btn">' + (wsT('webshell.batchDownload') || '批量下载') + '</button>' + '<button type="button" class="btn-ghost" id="webshell-batch-download-btn">' + (wsT('webshell.batchDownload') || '批量下载') + '</button>' +
'</div></details>' +
'</div>' +
'</div>' + '</div>' +
'<div id="webshell-file-list" class="webshell-file-list"></div>' + '<div id="webshell-file-list" class="webshell-file-list"></div>' +
'</div>' + '</div>' +
@@ -591,6 +953,23 @@ function selectWebshell(id) {
'<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' + '<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>' +
'<div id="webshell-pane-db" class="webshell-pane webshell-pane-db">' +
'<div class="webshell-db-toolbar">' +
'<label><span>' + (wsT('webshell.dbType') || '数据库类型') + '</span><select id="webshell-db-type" class="form-control"><option value="mysql">MySQL</option><option value="pgsql">PostgreSQL</option><option value="sqlite">SQLite</option><option value="mssql">SQL Server</option></select></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbHost') || '主机') + '</span><input id="webshell-db-host" class="form-control" type="text" value="127.0.0.1" /></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPort') || '端口') + '</span><input id="webshell-db-port" class="form-control" type="text" /></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbUsername') || '用户名') + '</span><input id="webshell-db-user" class="form-control" type="text" /></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPassword') || '密码') + '</span><input id="webshell-db-pass" class="form-control" type="password" /></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbName') || '数据库名') + '</span><input id="webshell-db-name" class="form-control" type="text" /></label>' +
'<label id="webshell-db-sqlite-row"><span>' + (wsT('webshell.dbSqlitePath') || 'SQLite 文件路径') + '</span><input id="webshell-db-sqlite-path" class="form-control" type="text" value="/tmp/test.db" /></label>' +
'</div>' +
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
'<div class="webshell-db-actions">' +
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
'</div>' +
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd') + '</div></div>' +
'</div>'; '</div>';
// Tab 切换 // Tab 切换
@@ -686,6 +1065,84 @@ function selectWebshell(id) {
}); });
} }
// 数据库管理:通过 WebShell 执行数据库客户端命令
var dbTypeEl = document.getElementById('webshell-db-type');
var dbRunBtn = document.getElementById('webshell-db-run-btn');
var dbTestBtn = document.getElementById('webshell-db-test-btn');
var dbSqlEl = document.getElementById('webshell-db-sql');
var dbCfg = getWebshellDbConfig(conn);
if (dbTypeEl) dbTypeEl.value = dbCfg.type || 'mysql';
var dbHostEl = document.getElementById('webshell-db-host');
var dbPortEl = document.getElementById('webshell-db-port');
var dbUserEl = document.getElementById('webshell-db-user');
var dbPassEl = document.getElementById('webshell-db-pass');
var dbNameEl = document.getElementById('webshell-db-name');
var dbSqliteEl = document.getElementById('webshell-db-sqlite-path');
if (dbHostEl) dbHostEl.value = dbCfg.host || '127.0.0.1';
if (dbPortEl) dbPortEl.value = dbCfg.port || '';
if (dbUserEl) dbUserEl.value = dbCfg.username || '';
if (dbPassEl) dbPassEl.value = dbCfg.password || '';
if (dbNameEl) dbNameEl.value = dbCfg.database || '';
if (dbSqliteEl) dbSqliteEl.value = dbCfg.sqlitePath || '/tmp/test.db';
if (dbSqlEl) dbSqlEl.value = dbCfg.sql || 'SELECT 1;';
webshellDbUpdateFieldVisibility();
function runDbQuery(isTestOnly) {
if (!conn || !conn.id) {
webshellDbSetOutput(wsT('webshell.dbNoConn') || '请先选择 WebShell 连接', true);
return;
}
if (webshellRunning) {
webshellDbSetOutput(wsT('webshell.dbRunning') || '数据库命令执行中,请稍候', true);
return;
}
var cfg = webshellDbCollectConfig(conn);
var built = buildWebshellDbCommand(cfg, !!isTestOnly);
if (!built.command) {
webshellDbSetOutput(built.error || (wsT('webshell.dbExecFailed') || '数据库执行失败'), true);
return;
}
webshellDbSetOutput(wsT('webshell.running') || '执行中…', false);
webshellRunning = true;
if (dbRunBtn) dbRunBtn.disabled = true;
if (dbTestBtn) dbTestBtn.disabled = true;
execWebshellCommand(conn, built.command).then(function (out) {
var parsed = parseWebshellDbExecOutput(out);
var code = parsed.rc;
var content = parsed.output || '';
var success = (code === 0) || (code == null && content && !/error|failed|denied|unknown|not found|access/i.test(content));
if (isTestOnly) {
var maybeOne = /\b1\b/.test(content);
if (success && (maybeOne || content === '' || /^ok$/i.test(content))) {
webshellDbSetOutput('连接测试通过');
} else {
webshellDbSetOutput('连接测试失败' + (content ? (':\n' + content) : ''), true);
}
return;
}
if (!success) {
webshellDbSetOutput((wsT('webshell.dbExecFailed') || '数据库执行失败') + (content ? (':\n' + content) : ''), true);
return;
}
webshellDbSetOutput(content || '执行完成(无输出)');
}).catch(function (err) {
webshellDbSetOutput((wsT('webshell.dbExecFailed') || '数据库执行失败') + ': ' + (err && err.message ? err.message : String(err)), true);
}).finally(function () {
webshellRunning = false;
if (dbRunBtn) dbRunBtn.disabled = false;
if (dbTestBtn) dbTestBtn.disabled = false;
});
}
if (dbTypeEl) dbTypeEl.addEventListener('change', function () { webshellDbUpdateFieldVisibility(); webshellDbCollectConfig(conn); });
['webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', function () { webshellDbCollectConfig(conn); });
});
if (dbSqlEl) dbSqlEl.addEventListener('change', function () { webshellDbCollectConfig(conn); });
if (dbRunBtn) dbRunBtn.addEventListener('click', function () { runDbQuery(false); });
if (dbTestBtn) dbTestBtn.addEventListener('click', function () { runDbQuery(true); });
initWebshellTerminal(conn); initWebshellTerminal(conn);
} }
@@ -851,7 +1308,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}); });
}).then(function (response) { }).then(function (response) {
if (!response.ok) { if (!response.ok) {
assistantDiv.textContent = '请求失败: ' + response.status; renderWebshellAiErrorMessage(assistantDiv, '请求失败: HTTP ' + response.status);
return; return;
} }
return response.body.getReader(); return response.body.getReader();
@@ -906,7 +1363,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
streamingTypingId += 1; streamingTypingId += 1;
var errLabel = (typeof window.t === 'function') ? window.t('chat.error') : '错误'; var errLabel = (typeof window.t === 'function') ? window.t('chat.error') : '错误';
appendTimelineItem('error', '❌ ' + errLabel, eventData.message, eventData.data); appendTimelineItem('error', '❌ ' + errLabel, eventData.message, eventData.data);
assistantDiv.textContent = errLabel + ': ' + eventData.message; renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + eventData.message);
} else if (eventData.type === 'progress' && eventData.message) { } else if (eventData.type === 'progress' && eventData.message) {
var progressMsg = (typeof window.translateProgressMessage === 'function') var progressMsg = (typeof window.translateProgressMessage === 'function')
? window.translateProgressMessage(eventData.message) ? window.translateProgressMessage(eventData.message)
@@ -1014,7 +1471,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
return reader.read().then(processChunk); return reader.read().then(processChunk);
}); });
}).catch(function (err) { }).catch(function (err) {
assistantDiv.textContent = '请求异常: ' + (err && err.message ? err.message : String(err)); renderWebshellAiErrorMessage(assistantDiv, '请求异常: ' + (err && err.message ? err.message : String(err)));
}).then(function () { }).then(function () {
webshellAiSending = false; webshellAiSending = false;
if (sendBtn) sendBtn.disabled = false; if (sendBtn) sendBtn.disabled = false;
@@ -1343,21 +1800,86 @@ function webshellFileListDir(conn, path) {
} }
function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
function normalizeLsMtime(month, day, timeOrYear) {
if (!month || !day || !timeOrYear) return '';
var token = String(timeOrYear).trim();
if (/^\d{4}$/.test(token)) {
return token + ' ' + month + ' ' + day;
}
var now = new Date();
var year = now.getFullYear();
if (/^\d{1,2}:\d{2}$/.test(token)) {
// ls -l 在半年内通常只显示 HH:MM;推断年份(避免未来日期)
var monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
var m = monthMap[month];
var d = parseInt(day, 10);
if (m != null && !isNaN(d)) {
var inferred = new Date(year, m, d);
if (inferred.getTime() > now.getTime()) year = year - 1;
}
return year + ' ' + month + ' ' + day + ' ' + token;
}
return month + ' ' + day + ' ' + token;
}
function modeToType(mode) {
if (!mode || !mode.length) return '';
var c = mode.charAt(0);
if (c === 'd') return 'dir';
if (c === '-') return 'file';
if (c === 'l') return 'link';
if (c === 'c') return 'char';
if (c === 'b') return 'block';
if (c === 's') return 'socket';
if (c === 'p') return 'pipe';
return c;
}
var lines = rawOutput.split(/\n/).filter(function (l) { return l.trim(); }); var lines = rawOutput.split(/\n/).filter(function (l) { return l.trim(); });
var items = []; var items = [];
for (var i = 0; i < lines.length; i++) { for (var i = 0; i < lines.length; i++) {
var line = lines[i]; var line = lines[i];
var m = line.match(/\s*(\S+)\s*$/); var name = '';
var name = m ? m[1].trim() : line.trim(); var isDir = false;
if (name === '.' || name === '..') continue;
var isDir = line.startsWith('d') || line.toLowerCase().indexOf('<dir>') !== -1;
var size = ''; var size = '';
var mode = ''; var mode = '';
var mtime = '';
var owner = '';
var group = '';
var type = '';
// 兼容典型:ls -la 输出(mode links owner group size month day time|year name
// 示例:-rw-r--r-- 1 user group 1234 Mar 23 12:34 file.txt
var mLs = line.match(/^(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\S+)\s+(.+)$/);
if (mLs) {
mode = mLs[1];
owner = mLs[3];
group = mLs[4];
size = mLs[5];
mtime = normalizeLsMtime(mLs[6], mLs[7], mLs[8]);
name = (mLs[9] || '').trim();
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')) { if (line.startsWith('-') || line.startsWith('d')) {
var parts = line.split(/\s+/); var parts = line.split(/\s+/);
if (parts.length >= 5) { mode = parts[0]; size = parts[4]; } if (parts.length >= 5) { mode = parts[0]; size = parts[4]; }
if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; }
// 尝试解析 mtimemonth day (time|year)
if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) {
mtime = normalizeLsMtime(parts[5], parts[6], parts[7]);
} }
items.push({ name: name, isDir: isDir, line: line, size: size, mode: mode }); type = modeToType(mode);
}
}
if (name === '.' || name === '..') continue;
items.push({ name: name, isDir: isDir, line: line, size: size, mode: mode, mtime: mtime, owner: owner, group: group, type: type });
} }
if (nameFilter && nameFilter.trim()) { if (nameFilter && nameFilter.trim()) {
var f = nameFilter.trim().toLowerCase(); var f = nameFilter.trim().toLowerCase();
@@ -1374,26 +1896,44 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
}).join(''); }).join('');
} }
var html = ''; var html = '';
if (items.length === 0 && rawOutput.trim() && !nameFilter) { if (items.length === 0) {
// 目录为空/过滤后为空时,给出明确空状态,避免 tbody 留白导致“整块抽象大白屏”
if (rawOutput.trim() && !nameFilter) {
html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>'; html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>';
} else { } else {
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th></th></tr></thead><tbody>'; html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-type">' + (wsT('webshell.colType') || '类型') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
'<tr><td colspan="9" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
'</tbody></table>';
}
} else {
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-type">' + (wsT('webshell.colType') || '类型') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
if (currentPath !== '.' && currentPath !== '') { if (currentPath !== '.' && currentPath !== '') {
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td></tr>'; html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
} }
items.forEach(function (item) { items.forEach(function (item) {
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
html += '<tr><td class="webshell-col-check">'; html += '<tr><td class="webshell-col-check">';
if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />'; if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />';
html += '</td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td><td class="webshell-col-size">' + escapeHtml(item.size) + '</td><td>'; html += '</td><td><a href="#" class="webshell-file-link" 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>';
html += '<td class="webshell-col-group">' + escapeHtml(item.group || '') + '</td>';
html += '<td class="webshell-col-perms">' + escapeHtml(item.mode || '') + '</td>';
html += '<td class="webshell-col-type">' + escapeHtml(item.type || '') + '</td>';
html += '<td class="webshell-col-actions">';
if (item.isDir) { if (item.isDir) {
html += '<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button>'; html += '<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button>';
} else { } else {
html += '<button type="button" class="btn-ghost btn-sm webshell-file-read" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.readFile') + '</button> '; var actionsLabel = wsT('common.actions') || '操作';
html += '<button type="button" class="btn-ghost btn-sm webshell-file-download" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.downloadFile') + '</button> '; html += '<details class="webshell-row-actions"><summary class="btn-ghost btn-sm webshell-row-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
html += '<button type="button" class="btn-ghost btn-sm webshell-file-edit" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.editFile') + '</button> '; '<div class="webshell-row-actions-menu">' +
html += '<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button> '; '<button type="button" class="btn-ghost btn-sm webshell-file-read" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.readFile') + '</button>' +
html += '<button type="button" class="btn-ghost btn-sm webshell-file-del" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.deleteFile') + '</button>'; '<button type="button" class="btn-ghost btn-sm webshell-file-download" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.downloadFile') + '</button>' +
'<button type="button" class="btn-ghost btn-sm webshell-file-edit" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.editFile') + '</button>' +
'<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button>' +
'<button type="button" class="btn-ghost btn-sm webshell-file-del" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.deleteFile') + '</button>' +
'</div></details>';
} }
html += '</td></tr>'; html += '</td></tr>';
}); });
@@ -1407,15 +1947,19 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
const path = a.getAttribute('data-path'); const path = a.getAttribute('data-path');
const isDir = a.getAttribute('data-isdir') === '1'; const isDir = a.getAttribute('data-isdir') === '1';
const pathInput = document.getElementById('webshell-file-path'); const pathInput = document.getElementById('webshell-file-path');
if (isDir) {
if (pathInput) pathInput.value = path; if (pathInput) pathInput.value = path;
if (isDir) webshellFileListDir(webshellCurrentConn, path); webshellFileListDir(webshellCurrentConn, path);
else webshellFileRead(webshellCurrentConn, path, listEl); } else {
// 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图
webshellFileRead(webshellCurrentConn, path, listEl, currentPath);
}
}); });
}); });
listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) { listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl); webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl, currentPath);
}); });
}); });
listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) { listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) {
@@ -1600,7 +2144,7 @@ function webshellFileDownload(conn, path) {
.catch(function (err) { alert(wsT('webshell.execError') + ': ' + (err && err.message ? err.message : '')); }); .catch(function (err) { alert(wsT('webshell.execError') + ': ' + (err && err.message ? err.message : '')); });
} }
function webshellFileRead(conn, path, listEl) { function webshellFileRead(conn, path, listEl, browsePath) {
if (typeof apiFetch === 'undefined') return; if (typeof apiFetch === 'undefined') return;
listEl.innerHTML = '<div class="webshell-loading">' + wsT('webshell.readFile') + '...</div>'; listEl.innerHTML = '<div class="webshell-loading">' + wsT('webshell.readFile') + '...</div>';
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
@@ -1610,7 +2154,19 @@ function webshellFileRead(conn, path, listEl) {
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
const out = (data && data.output) ? data.output : (data.error || ''); const out = (data && data.output) ? data.output : (data.error || '');
listEl.innerHTML = '<div class="webshell-file-content"><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" onclick="webshellFileListDir(webshellCurrentConn, document.getElementById(\'webshell-file-path\').value.trim() || \'.\')">' + wsT('webshell.listDir') + '</button></div>'; var backPath = (browsePath && String(browsePath).trim()) ? String(browsePath).trim() : ((document.getElementById('webshell-file-path') && document.getElementById('webshell-file-path').value.trim()) || '.');
if (backPath === path) {
// 兜底:若路径被污染成文件路径,回退到父目录
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>';
var backBtn = document.getElementById('webshell-file-back-btn');
if (backBtn) {
backBtn.addEventListener('click', function () {
var p = backBtn.getAttribute('data-back-path') || '.';
webshellFileListDir(webshellCurrentConn, p);
});
}
}) })
.catch(function (err) { .catch(function (err) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : '') + '</div>'; listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : '') + '</div>';
@@ -1763,9 +2319,11 @@ function refreshWebshellUIOnLanguageChange() {
var tabTerminal = workspace.querySelector('.webshell-tab[data-tab="terminal"]'); var tabTerminal = workspace.querySelector('.webshell-tab[data-tab="terminal"]');
var tabFile = workspace.querySelector('.webshell-tab[data-tab="file"]'); var tabFile = workspace.querySelector('.webshell-tab[data-tab="file"]');
var tabAi = workspace.querySelector('.webshell-tab[data-tab="ai"]'); var tabAi = workspace.querySelector('.webshell-tab[data-tab="ai"]');
var tabDb = workspace.querySelector('.webshell-tab[data-tab="db"]');
if (tabTerminal) tabTerminal.textContent = wsT('webshell.tabTerminal'); if (tabTerminal) tabTerminal.textContent = wsT('webshell.tabTerminal');
if (tabFile) tabFile.textContent = wsT('webshell.tabFileManager'); if (tabFile) tabFile.textContent = wsT('webshell.tabFileManager');
if (tabAi) tabAi.textContent = wsT('webshell.tabAiAssistant') || 'AI 助手'; if (tabAi) tabAi.textContent = wsT('webshell.tabAiAssistant') || 'AI 助手';
if (tabDb) tabDb.textContent = wsT('webshell.tabDbManager') || '数据库管理';
var quickLabel = workspace.querySelector('.webshell-quick-label'); var quickLabel = workspace.querySelector('.webshell-quick-label');
if (quickLabel) quickLabel.textContent = (wsT('webshell.quickCommands') || '快捷命令') + ':'; if (quickLabel) quickLabel.textContent = (wsT('webshell.quickCommands') || '快捷命令') + ':';
@@ -1798,6 +2356,30 @@ function refreshWebshellUIOnLanguageChange() {
if (aiInput) aiInput.placeholder = wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件'; if (aiInput) aiInput.placeholder = wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件';
var aiSendBtn = document.getElementById('webshell-ai-send'); var aiSendBtn = document.getElementById('webshell-ai-send');
if (aiSendBtn) aiSendBtn.textContent = wsT('webshell.aiSend') || '发送'; if (aiSendBtn) aiSendBtn.textContent = wsT('webshell.aiSend') || '发送';
var dbTypeLabel = document.querySelector('#webshell-db-type') ? document.querySelector('#webshell-db-type').closest('label') : null;
if (dbTypeLabel && dbTypeLabel.querySelector('span')) dbTypeLabel.querySelector('span').textContent = wsT('webshell.dbType') || '数据库类型';
var dbHostLabel = document.querySelector('#webshell-db-host') ? document.querySelector('#webshell-db-host').closest('label') : null;
if (dbHostLabel && dbHostLabel.querySelector('span')) dbHostLabel.querySelector('span').textContent = wsT('webshell.dbHost') || '主机';
var dbPortLabel = document.querySelector('#webshell-db-port') ? document.querySelector('#webshell-db-port').closest('label') : null;
if (dbPortLabel && dbPortLabel.querySelector('span')) dbPortLabel.querySelector('span').textContent = wsT('webshell.dbPort') || '端口';
var dbUserLabel = document.querySelector('#webshell-db-user') ? document.querySelector('#webshell-db-user').closest('label') : null;
if (dbUserLabel && dbUserLabel.querySelector('span')) dbUserLabel.querySelector('span').textContent = wsT('webshell.dbUsername') || '用户名';
var dbPassLabel = document.querySelector('#webshell-db-pass') ? document.querySelector('#webshell-db-pass').closest('label') : null;
if (dbPassLabel && dbPassLabel.querySelector('span')) dbPassLabel.querySelector('span').textContent = wsT('webshell.dbPassword') || '密码';
var dbNameLabel = document.querySelector('#webshell-db-name') ? document.querySelector('#webshell-db-name').closest('label') : null;
if (dbNameLabel && dbNameLabel.querySelector('span')) dbNameLabel.querySelector('span').textContent = wsT('webshell.dbName') || '数据库名';
var dbSqliteLabel = document.querySelector('#webshell-db-sqlite-path') ? document.querySelector('#webshell-db-sqlite-path').closest('label') : null;
if (dbSqliteLabel && dbSqliteLabel.querySelector('span')) dbSqliteLabel.querySelector('span').textContent = wsT('webshell.dbSqlitePath') || 'SQLite 文件路径';
var dbRunBtn = document.getElementById('webshell-db-run-btn');
if (dbRunBtn) dbRunBtn.textContent = wsT('webshell.dbRunSql') || '执行 SQL';
var dbTestBtn = document.getElementById('webshell-db-test-btn');
if (dbTestBtn) dbTestBtn.textContent = wsT('webshell.dbTest') || '测试连接';
var dbSql = document.getElementById('webshell-db-sql');
if (dbSql) dbSql.placeholder = wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();';
var dbTitle = document.querySelector('.webshell-db-output-title');
if (dbTitle) dbTitle.textContent = wsT('webshell.dbOutput') || '执行输出';
var dbHint = document.querySelector('.webshell-db-hint');
if (dbHint) dbHint.textContent = wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd';
// 如果当前 AI 对话区只有系统就绪提示(没有用户消息),用当前语言重置这条提示 // 如果当前 AI 对话区只有系统就绪提示(没有用户消息),用当前语言重置这条提示
var aiMessages = document.getElementById('webshell-ai-messages'); var aiMessages = document.getElementById('webshell-ai-messages');
+3
View File
@@ -1052,6 +1052,9 @@
data-i18n-attr="placeholder" data-i18n-attr="placeholder"
placeholder="搜索连接..." /> placeholder="搜索连接..." />
</div> </div>
<div class="webshell-sidebar-tools">
<button type="button" class="btn-ghost btn-sm" id="webshell-batch-probe-btn" data-i18n="webshell.batchProbe">一键批量探活</button>
</div>
<div id="webshell-list" class="webshell-list"> <div id="webshell-list" class="webshell-list">
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div> <div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
</div> </div>