mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-10 14:28:43 +02:00
Add files via upload
This commit is contained in:
+22
-6
@@ -1,12 +1,28 @@
|
||||
## Plugins
|
||||
|
||||
This directory contains optional plugins/extensions that integrate CyberStrikeAI with other tools.
|
||||
Optional integrations that connect CyberStrikeAI with other tools.
|
||||
|
||||
- `burp-suite/`: Burp Suite extensions
|
||||
|
||||
### Burp Suite Extension
|
||||
### Burp Suite
|
||||
|
||||
- **Path**: `plugins/burp-suite/cyberstrikeai-burp-extension/`
|
||||
- **Build output**: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
|
||||
- **Docs**: see the plugin folder `README.md` / `README.zh-CN.md`
|
||||
- **Build**: `bash build-mvn.sh` → `dist/cyberstrikeai-burp-extension.jar`
|
||||
- **Docs**: `README.md` / `README.zh-CN.md`
|
||||
|
||||
### Browser (Chrome / Edge)
|
||||
|
||||
- **Path**: `plugins/browser-extension/cyberstrikeai-browser-extension/`
|
||||
- **Version**: **0.3.5**
|
||||
- **Install**: `chrome://extensions/` → Load unpacked → F12 → **CyberStrikeAI** tab
|
||||
- **Package**: `bash package.sh` → `dist/cyberstrikeai-browser-extension.zip`
|
||||
- **Docs**: `README.zh-CN.md` (full) / `README.md` (summary)
|
||||
|
||||
#### Highlights (v0.3.x)
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Capture pause | **● Capturing** / **○ Paused** — stop recording without closing DevTools |
|
||||
| HTTP/1.1 display | Raw HAR in memory; UI + AI prompt normalized (no `:method` pseudo-headers) |
|
||||
| Collapsible conn bar | Host/Port/Validate collapses after success |
|
||||
| Popup | Read-only endpoint + connection status |
|
||||
| Performance | XHR-only filter, no body read for static assets, rAF-throttled stream UI |
|
||||
| Data caps | 200 captures/tab, 50 test runs, 512KB progress — all in-memory |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
## CyberStrikeAI Browser Extension
|
||||
|
||||
**Version 0.3.5** — Full docs: **README.zh-CN.md**
|
||||
|
||||
Chromium DevTools extension: capture Network traffic and send it to CyberStrikeAI for AI-assisted security testing. Aligned with the Burp Suite plugin.
|
||||
|
||||
### Quick install
|
||||
|
||||
1. `chrome://extensions/` → Developer mode → **Load unpacked**
|
||||
2. Select `plugins/browser-extension/cyberstrikeai-browser-extension/`
|
||||
3. Open target page → **F12** → **CyberStrikeAI** tab → **Validate**
|
||||
4. Select a captured request → **Send** → view **Output**
|
||||
|
||||
### Popup vs DevTools panel
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| **DevTools panel** | Connection, Validate, capture, Send, Output (primary UI) |
|
||||
| **Extension popup** | Read-only connection status + version + guide |
|
||||
|
||||
### Key features
|
||||
|
||||
- **Capture toggle**: **● Capturing** / **○ Paused** — pause stops `getContent` and list updates; Send still works on existing entries
|
||||
- **Collapsible connection bar** — collapses to `https://host:port` after Validate
|
||||
- **HTTP/1.1 normalization** — raw HAR stored; display and AI prompt strip HTTP/2 pseudo-headers (`:method`, etc.)
|
||||
- **Test History** (50 runs) + **Captured Requests** (200/tab, XHR/Fetch filter)
|
||||
- **SSE streaming** — Progress capped at 512KB; Final uncapped for active run
|
||||
- **Deferred Markdown** — plain text while streaming; render after done; skip above 100KB
|
||||
- **Stop** — abort local stream + server cancel via `conversationId`
|
||||
- **Latest XHR**, **Copy**, project/role/agent send dialog
|
||||
- Session token + optional host permissions
|
||||
|
||||
### Data limits (no unbounded growth)
|
||||
|
||||
| Data | Limit | Storage |
|
||||
|------|-------|---------|
|
||||
| Captures | 200 / tab | In-memory |
|
||||
| Tabs tracked | 20 | In-memory |
|
||||
| Test runs | 50 | Panel memory |
|
||||
| Config / token | Small | `chrome.storage` |
|
||||
|
||||
Closing DevTools clears panel data. Closing the browser invalidates the session token.
|
||||
|
||||
### Performance
|
||||
|
||||
- **DevTools closed** → zero impact on page load
|
||||
- **Capture paused** → near-zero overhead
|
||||
- **Capturing + XHR only** → light overhead on matching requests only
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
After reloading the extension, close DevTools completely and reopen (F12) if you see `chrome.runtime.connect` errors — the old panel context is invalidated.
|
||||
|
||||
### Package
|
||||
|
||||
```bash
|
||||
bash package.sh
|
||||
# → dist/cyberstrikeai-browser-extension.zip
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
background/service-worker.js
|
||||
devtools.js
|
||||
panel/ # main UI
|
||||
popup/ # read-only status
|
||||
lib/ # api, storage, capture, http-normalize, markdown, …
|
||||
icons/
|
||||
package.sh
|
||||
```
|
||||
@@ -0,0 +1,231 @@
|
||||
## CyberStrikeAI 浏览器扩展
|
||||
|
||||
**当前版本:0.3.5**
|
||||
|
||||
Chrome / Edge(Chromium)DevTools 扩展:在开发者工具中捕获 **Network** 流量,发送到 CyberStrikeAI 进行 AI 辅助安全测试。能力与 Burp Suite 插件对齐,并按生产场景做了性能与体验优化。
|
||||
|
||||
---
|
||||
|
||||
### 快速开始
|
||||
|
||||
1. `chrome://extensions/` → 开发者模式 → **加载已解压的扩展程序**
|
||||
2. 选择目录:`plugins/browser-extension/cyberstrikeai-browser-extension/`
|
||||
3. 打开目标页面 → **F12** → 顶部 Tab **CyberStrikeAI**
|
||||
4. 填写 Host / Port / Password → **Validate**(首次会请求访问服务器地址权限)
|
||||
5. 左侧选中捕获请求 → **Send** → 在 **Output** 查看 AI 结果
|
||||
|
||||
点击浏览器工具栏图标可查看 **只读连接状态**;完整配置与操作均在 DevTools 面板内完成。
|
||||
|
||||
---
|
||||
|
||||
### 界面说明
|
||||
|
||||
```
|
||||
┌─ 连接栏(Validate 成功后可收起)────────────────────────────┐
|
||||
│ Logo │ https://host:port │ 连接设置 │ ● OK │
|
||||
├─ 操作栏 ────────────────────────────────────────────────────┤
|
||||
│ Send │ Latest XHR │ Stop │ Copy │ Clear │ ●捕获中/○已暂停 │
|
||||
│ XHR/Fetch only │ Debug │ Markdown │
|
||||
├──────────────┬──────────────────────────────────────────────┤
|
||||
│ Test History │ Output │ Request │ Response │
|
||||
│ Captured Req │ Progress + Final Response │
|
||||
└──────────────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 区域 | 说明 |
|
||||
|------|------|
|
||||
| **连接栏** | Host、Port、HTTPS、Password、Validate;成功后收起为 `https://host:port` 摘要 |
|
||||
| **Test History** | 最多 50 次 Send 记录,可回看 Progress / Final |
|
||||
| **Captured Requests** | 当前 Tab 捕获列表,最多 200 条,支持搜索 |
|
||||
| **Output** | 默认 Tab:流式 Progress + Final Response |
|
||||
| **Request / Response** | 查看选中流量的 HTTP/1.1 格式原文 |
|
||||
|
||||
---
|
||||
|
||||
### 功能一览
|
||||
|
||||
#### 捕获
|
||||
|
||||
- **Background 中枢**:`devtools.js` 监听 Network → `service-worker` 队列 → Panel 订阅
|
||||
- 默认 **XHR/Fetch only**(可关闭以捕获更多类型)
|
||||
- 静态资源 URL / MIME **预过滤**,命中前不读响应体
|
||||
- **● 捕获中 / ○ 已暂停**:暂停后零开销,已有列表仍可 Send
|
||||
- 单条截断:请求体 **64KB**、响应 **4KB**
|
||||
|
||||
#### HTTP 展示与 AI Prompt
|
||||
|
||||
- **存储**:内存中保留原始 HAR(含 HTTP/2 伪首部 `:method` 等)
|
||||
- **展示 / Prompt**:归一化为 **HTTP/1.1**(与 Burp 插件一致)
|
||||
|
||||
```http
|
||||
GET /api/foo HTTP/1.1
|
||||
Host: example.com
|
||||
Cookie: ...
|
||||
```
|
||||
|
||||
#### 发送到 CyberStrikeAI
|
||||
|
||||
- 弹窗选择:**项目 / 角色 / 对话模式**(动态 API)+ 测试指令
|
||||
- 支持 **Eino Single**、**Deep**、**Plan-Execute**、**Supervisor**
|
||||
- **Latest XHR**:一键选中最近 API 请求并打开发送弹窗
|
||||
- **Stop**:中止本地 SSE + 调用服务端 `/api/agent-loop/cancel`
|
||||
|
||||
#### 流式输出
|
||||
|
||||
- Progress 日志上限 **512KB**(超出截断)
|
||||
- **Final Response 不截断**(当前进行中的测试)
|
||||
- 历史 run 切换后 Final 软截断 **100KB**
|
||||
- **Markdown**:流式阶段纯文本;结束后 `requestIdleCallback` 渲染;超 **100KB** 降级纯文本
|
||||
- **Copy**:复制当前 Request / Response / Final
|
||||
|
||||
#### 安全与权限
|
||||
|
||||
- Token 存 **chrome.storage.session**(关浏览器失效)
|
||||
- **optional_host_permissions**:Validate 时按需授权 CyberStrikeAI 地址
|
||||
- Markdown iframe 使用 `sandbox`
|
||||
|
||||
---
|
||||
|
||||
### 按钮与选项
|
||||
|
||||
| 控件 | 作用 |
|
||||
|------|------|
|
||||
| **Validate** | 登录并校验 Token;进行中再次点击为 Cancel |
|
||||
| **连接设置 / 收起** | 展开或折叠 Host/Port/Password 表单 |
|
||||
| **Send** | 对选中捕获发起到 CyberStrikeAI |
|
||||
| **Latest XHR** | 选中最近 XHR/Fetch 并 Send |
|
||||
| **Stop** | 停止当前 AI 流(本地 + 服务端) |
|
||||
| **Clear Output** | 清空当前 run 的 Progress / Final |
|
||||
| **● 捕获中 / ○ 已暂停** | 启用或暂停 Network 捕获 |
|
||||
| **XHR/Fetch only** | 只捕获 API 类请求 |
|
||||
| **Debug events** | 在 Progress 显示更多 SSE 事件 |
|
||||
| **Markdown** | Final 完成后渲染富文本 |
|
||||
| **Clear All** | 清空 Test History |
|
||||
| **Clear** | 清空当前 Tab 捕获列表 |
|
||||
|
||||
---
|
||||
|
||||
### 数据与内存(不会无限增长)
|
||||
|
||||
| 数据 | 上限 | 位置 | 清理时机 |
|
||||
|------|------|------|----------|
|
||||
| 捕获请求 | 200 条 / Tab | Background + Panel 内存 | 超出丢弃最旧;可手动 Clear |
|
||||
| Tab 捕获槽 | 20 个 Tab | Background 内存 | 超出丢弃非当前 Tab |
|
||||
| 测试历史 | 50 条 | Panel 内存 | 超出丢弃最旧;Clear All |
|
||||
| Progress | 512KB / run | Panel 内存 | 超出截断 |
|
||||
| Final(进行中) | 无硬上限 | Panel 内存 | — |
|
||||
| Final(历史) | 100KB 软截断 | Panel 内存 | 切换到其他 run 时 |
|
||||
| 配置 + Token | 极小 | chrome.storage | 手动改配置 |
|
||||
|
||||
- 关闭 **DevTools** → Panel 内存清空
|
||||
- 关闭 **浏览器** → Session Token 失效
|
||||
- Service Worker 被回收 → Background 捕获队列清空
|
||||
|
||||
---
|
||||
|
||||
### 性能说明
|
||||
|
||||
| 场景 | 影响 |
|
||||
|------|------|
|
||||
| 未开 DevTools | **无影响**(不监听 Network) |
|
||||
| DevTools 开 + 捕获暂停 | **几乎无影响** |
|
||||
| DevTools 开 + 捕获中 + XHR only | 仅匹配请求有轻微开销 |
|
||||
| 高流量 SPA | 建议保持 **XHR/Fetch only**,不需要时点 **已暂停** |
|
||||
|
||||
已做优化:过滤器内存缓存、静态资源不读 body、列表增量插入、搜索防抖、rAF 节流流式 UI。
|
||||
|
||||
---
|
||||
|
||||
### 常见问题
|
||||
|
||||
**扩展更新后报错 `chrome.runtime.connect` undefined?**
|
||||
扩展重载后旧 DevTools 面板上下文失效。请:**关闭 DevTools → 重新加载扩展 → 再开 F12**。
|
||||
|
||||
**Request 里为什么曾经有 `:authority`、`:method`?**
|
||||
HTTP/2 伪首部。展示与 AI Prompt 已归一化为 HTTP/1.1;原始 HAR 仍保存在内存 entry 中。
|
||||
|
||||
**Console 里 localhost CORS 报错是插件造成的吗?**
|
||||
不是。那是页面自身请求本机服务被浏览器拦截,与扩展无关。
|
||||
|
||||
**Test History 很多会挡住 Captured Requests 吗?**
|
||||
不会。历史区最高占侧边栏 **42%**,超出部分区域内滚动;捕获区占剩余空间。
|
||||
|
||||
**会拖慢网页吗?**
|
||||
日常浏览(不开 DevTools)无影响。调试时可用 **已暂停** 完全停止捕获。
|
||||
|
||||
---
|
||||
|
||||
### Popup 与 DevTools 分工
|
||||
|
||||
| 位置 | 用途 |
|
||||
|------|------|
|
||||
| **DevTools 面板** | 连接、Validate、捕获、Send、Output(主工作区) |
|
||||
| **扩展 Popup** | 只读连接状态 + 版本号 + 打开 DevTools 引导 |
|
||||
|
||||
不在 Popup 中重复完整配置表单,避免与主流程脱节。
|
||||
|
||||
---
|
||||
|
||||
### 打包发布
|
||||
|
||||
```bash
|
||||
bash plugins/browser-extension/cyberstrikeai-browser-extension/package.sh
|
||||
# → dist/cyberstrikeai-browser-extension.zip
|
||||
```
|
||||
|
||||
图标从项目根 `images/logo.png` 生成:
|
||||
|
||||
```bash
|
||||
LOGO="images/logo.png"
|
||||
ICONS="plugins/browser-extension/cyberstrikeai-browser-extension/icons"
|
||||
for size in 16 48 128; do
|
||||
sips -z $size $size "$LOGO" --out "$ICONS/icon${size}.png"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 限制
|
||||
|
||||
- Chrome **不提供** Network 面板右键菜单 API → 使用 **Latest XHR** + 自建列表
|
||||
- Firefox 需 `about:debugging` 临时加载;`storage.session` 不可用时 Token 回退 `local`
|
||||
- 无法一键从 Popup 跳转到 DevTools 指定面板(Chrome API 限制)
|
||||
|
||||
---
|
||||
|
||||
### 目录结构
|
||||
|
||||
```text
|
||||
manifest.json # MV3 清单
|
||||
background/service-worker.js # 捕获队列、Panel Port、全局开关
|
||||
devtools.js # Network 监听(最早过滤)
|
||||
devtools.html
|
||||
panel/
|
||||
panel.html / panel.js / panel.css # 主 UI
|
||||
popup/
|
||||
popup.html / popup.js / popup.css # 只读状态
|
||||
lib/
|
||||
api.js # 登录、SSE、项目/角色 API
|
||||
storage.js # 配置与 session token
|
||||
capture.js # HAR 摘要、静态过滤
|
||||
http-normalize.js # HTTP/2 → HTTP/1.1 展示/Prompt
|
||||
formatter.js # toPrompt 组装
|
||||
markdown.js # Final Markdown 渲染
|
||||
catalog-cache.js # 项目/角色 5 分钟缓存
|
||||
constants.js # 上限常量
|
||||
icons/ # 16 / 48 / 128
|
||||
package.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 与 Burp 插件对比
|
||||
|
||||
| 能力 | Burp 插件 | 浏览器扩展 |
|
||||
|------|-----------|------------|
|
||||
| 流量来源 | Proxy 历史 | DevTools Network |
|
||||
| 连接配置 | Tab 内 | Tab 内(可折叠) |
|
||||
| HTTP 格式 | HTTP/1.1 | 展示/Prompt 归一化为 HTTP/1.1 |
|
||||
| 项目/角色/模式 | Send 弹窗 | Send 弹窗 |
|
||||
| SSE 输出 | Progress + Final | Progress + Final |
|
||||
| 捕获开关 | — | ● 捕获中 / ○ 已暂停 |
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/* global chrome, CSAI_LIMITS, shouldCaptureEntry */
|
||||
|
||||
importScripts('../lib/constants.js', '../lib/capture.js');
|
||||
|
||||
/** Background hub: per-tab capture queue + panel subscriptions. */
|
||||
|
||||
const capturesByTab = new Map();
|
||||
const portsByTab = new Map();
|
||||
const filterApiByTab = new Map();
|
||||
let captureEnabled = true;
|
||||
|
||||
function getCaptures(tabId) {
|
||||
if (!capturesByTab.has(tabId)) capturesByTab.set(tabId, []);
|
||||
return capturesByTab.get(tabId);
|
||||
}
|
||||
|
||||
function trimCaptures(list) {
|
||||
if (list.length > CSAI_LIMITS.MAX_CAPTURED) {
|
||||
list.length = CSAI_LIMITS.MAX_CAPTURED;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastTab(tabId, message) {
|
||||
const ports = portsByTab.get(tabId);
|
||||
if (!ports) return;
|
||||
for (const port of ports) {
|
||||
try {
|
||||
port.postMessage(message);
|
||||
} catch (_) {
|
||||
ports.delete(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldTabs(activeTabId) {
|
||||
if (capturesByTab.size <= CSAI_LIMITS.MAX_TAB_CAPTURES) return;
|
||||
for (const tabId of capturesByTab.keys()) {
|
||||
if (tabId !== activeTabId) {
|
||||
capturesByTab.delete(tabId);
|
||||
portsByTab.delete(tabId);
|
||||
filterApiByTab.delete(tabId);
|
||||
}
|
||||
if (capturesByTab.size <= CSAI_LIMITS.MAX_TAB_CAPTURES) break;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'cyberstrike-panel') return;
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
for (const [tabId, set] of portsByTab.entries()) {
|
||||
set.delete(port);
|
||||
if (set.size === 0) portsByTab.delete(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
port.onMessage.addListener((msg) => {
|
||||
if (!msg || msg.type !== 'subscribe') return;
|
||||
const tabId = msg.tabId;
|
||||
if (tabId == null) return;
|
||||
|
||||
if (!portsByTab.has(tabId)) portsByTab.set(tabId, new Set());
|
||||
portsByTab.get(tabId).add(port);
|
||||
|
||||
if (typeof msg.filterApiOnly === 'boolean') {
|
||||
filterApiByTab.set(tabId, msg.filterApiOnly);
|
||||
}
|
||||
|
||||
port.postMessage({ type: 'list', entries: getCaptures(tabId) });
|
||||
cleanupOldTabs(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (!msg || !msg.type) return false;
|
||||
|
||||
if (msg.type === 'capture-entry') {
|
||||
const tabId = msg.tabId;
|
||||
if (tabId == null) return false;
|
||||
if (!captureEnabled) {
|
||||
sendResponse({ ok: true, skipped: true });
|
||||
return false;
|
||||
}
|
||||
const filterApi = filterApiByTab.has(tabId) ? filterApiByTab.get(tabId) : true;
|
||||
const entry = msg.entry;
|
||||
if (!shouldCaptureEntry(entry, filterApi)) {
|
||||
sendResponse({ ok: true, skipped: true });
|
||||
return false;
|
||||
}
|
||||
const list = getCaptures(tabId);
|
||||
list.unshift(entry);
|
||||
trimCaptures(list);
|
||||
broadcastTab(tabId, { type: 'entry', entry });
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.type === 'clear-captures') {
|
||||
const tabId = msg.tabId;
|
||||
if (tabId != null) {
|
||||
capturesByTab.set(tabId, []);
|
||||
broadcastTab(tabId, { type: 'list', entries: [] });
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.type === 'set-filter-api') {
|
||||
const tabId = msg.tabId;
|
||||
if (tabId != null && typeof msg.filterApiOnly === 'boolean') {
|
||||
filterApiByTab.set(tabId, msg.filterApiOnly);
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.type === 'set-capture-enabled') {
|
||||
if (typeof msg.enabled === 'boolean') {
|
||||
captureEnabled = msg.enabled;
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.type === 'get-latest-api') {
|
||||
const tabId = msg.tabId;
|
||||
const list = getCaptures(tabId);
|
||||
const latest = list.find((e) => {
|
||||
const rt = (e.resourceType || '').toLowerCase();
|
||||
return rt === 'xhr' || rt === 'fetch';
|
||||
});
|
||||
sendResponse({ entry: latest || null });
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
if (chrome.contextMenus && chrome.contextMenus.removeAll) {
|
||||
chrome.contextMenus.removeAll();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<script src="lib/constants.js"></script>
|
||||
<script src="lib/capture.js"></script>
|
||||
<script src="devtools.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
/* global chrome, summarizeHarEntry, inferResourceType, mightCaptureRequest */
|
||||
|
||||
const FILTER_STORAGE_KEY = 'csai_filter_api_only';
|
||||
const CAPTURE_ENABLED_KEY = 'csai_capture_enabled';
|
||||
|
||||
/** In-memory flags — avoids storage read on every network request. */
|
||||
let filterApiOnly = true;
|
||||
let captureEnabled = true;
|
||||
|
||||
function syncFromStorage(data) {
|
||||
filterApiOnly = (data && data[FILTER_STORAGE_KEY]) !== false;
|
||||
captureEnabled = (data && data[CAPTURE_ENABLED_KEY]) !== false;
|
||||
}
|
||||
|
||||
chrome.storage.local.get([FILTER_STORAGE_KEY, CAPTURE_ENABLED_KEY], (data) => {
|
||||
syncFromStorage(data);
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
if (changes[FILTER_STORAGE_KEY]) {
|
||||
filterApiOnly = changes[FILTER_STORAGE_KEY].newValue !== false;
|
||||
}
|
||||
if (changes[CAPTURE_ENABLED_KEY]) {
|
||||
captureEnabled = changes[CAPTURE_ENABLED_KEY].newValue !== false;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg || !msg.type) return;
|
||||
if (msg.type === 'set-filter-api' && typeof msg.filterApiOnly === 'boolean') {
|
||||
filterApiOnly = msg.filterApiOnly;
|
||||
}
|
||||
if (msg.type === 'set-capture-enabled' && typeof msg.enabled === 'boolean') {
|
||||
captureEnabled = msg.enabled;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.devtools.network.onRequestFinished.addListener((request) => {
|
||||
if (!captureEnabled) return;
|
||||
|
||||
const tabId = chrome.devtools.inspectedWindow.tabId;
|
||||
const req = request.request || {};
|
||||
const res = request.response || {};
|
||||
const url = req.url || '';
|
||||
const resourceType =
|
||||
(request._resourceType || request.resourceType || inferResourceType(url, res.headers) || 'other')
|
||||
.toLowerCase();
|
||||
|
||||
if (!mightCaptureRequest(url, resourceType, filterApiOnly)) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.getContent((body) => {
|
||||
const entry = summarizeHarEntry(request, body, resourceType);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'capture-entry',
|
||||
tabId,
|
||||
entry,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
chrome.devtools.panels.create(
|
||||
'CyberStrikeAI',
|
||||
'icons/icon48.png',
|
||||
'panel/panel.html',
|
||||
() => {}
|
||||
);
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 633 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,153 @@
|
||||
const AGENT_MODES = [
|
||||
{ id: 'eino_single', label: 'Eino Single (ADK)', path: '/api/eino-agent/stream' },
|
||||
{ id: 'deep', label: 'Deep (DeepAgent)', path: '/api/multi-agent/stream', orchestration: 'deep' },
|
||||
{ id: 'plan_execute', label: 'Plan-Execute', path: '/api/multi-agent/stream', orchestration: 'plan_execute' },
|
||||
{ id: 'supervisor', label: 'Supervisor', path: '/api/multi-agent/stream', orchestration: 'supervisor' },
|
||||
];
|
||||
|
||||
function agentModeById(id) {
|
||||
return AGENT_MODES.find((m) => m.id === id) || AGENT_MODES[0];
|
||||
}
|
||||
|
||||
async function apiFetch(baseUrl, path, options = {}) {
|
||||
const res = await fetch(baseUrl + path, options);
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
json = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (json && json.error) || text || `HTTP ${res.status}`;
|
||||
throw new Error(err);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loginAndValidate(baseUrl, password, signal) {
|
||||
const login = await apiFetch(baseUrl, '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
signal,
|
||||
});
|
||||
const token = login && login.token;
|
||||
if (!token) throw new Error('Login response missing token');
|
||||
await apiFetch(baseUrl, '/api/auth/validate', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fetchProjects(baseUrl, token, signal) {
|
||||
const data = await apiFetch(baseUrl, '/api/projects?limit=500', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
const list = (data && data.projects) || [];
|
||||
const out = [{ id: '', label: '(无)' }];
|
||||
for (const p of list) {
|
||||
if (!p.id) continue;
|
||||
let label = p.name || p.id;
|
||||
if (p.status === 'archived') label += ' [已归档]';
|
||||
out.push({ id: p.id, label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchRoles(baseUrl, token, signal) {
|
||||
const data = await apiFetch(baseUrl, '/api/roles', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
const list = (data && data.roles) || [];
|
||||
const out = [{ id: '', label: '默认' }];
|
||||
for (const r of list) {
|
||||
if (r.enabled === false) continue;
|
||||
if (!r.name) continue;
|
||||
out.push({ id: r.name, label: r.name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractConversationId(ev) {
|
||||
if (!ev || typeof ev !== 'object') return '';
|
||||
if (ev.conversationId) return String(ev.conversationId).trim();
|
||||
if (ev.data && ev.data.conversationId) return String(ev.data.conversationId).trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
async function streamTest(baseUrl, token, options, handlers) {
|
||||
const mode = agentModeById(options.agentMode);
|
||||
const body = {
|
||||
message: options.message,
|
||||
conversationId: '',
|
||||
role: options.role || '',
|
||||
};
|
||||
if (options.projectId) body.projectId = options.projectId;
|
||||
if (mode.orchestration) body.orchestration = mode.orchestration;
|
||||
|
||||
const controller = new AbortController();
|
||||
if (handlers.setAbortController) handlers.setAbortController(controller);
|
||||
|
||||
const res = await fetch(baseUrl + mode.path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
throw new Error(t || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const json = line.slice(5).trim();
|
||||
if (!json) continue;
|
||||
let ev;
|
||||
try {
|
||||
ev = JSON.parse(json);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
const type = ev.type || '';
|
||||
const message = ev.message || '';
|
||||
if (handlers.onEvent) handlers.onEvent(type, message, ev);
|
||||
if (type === 'done') {
|
||||
if (handlers.onDone) handlers.onDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (handlers.onDone) handlers.onDone();
|
||||
}
|
||||
|
||||
async function cancelByConversationId(baseUrl, token, conversationId) {
|
||||
await apiFetch(baseUrl, '/api/agent-loop/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ conversationId }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Capture filtering and HAR entry normalization. */
|
||||
|
||||
const STATIC_EXT =
|
||||
/\.(js|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|map|wasm)(\?|$)/i;
|
||||
|
||||
const STATIC_MIME_PREFIXES = [
|
||||
'image/',
|
||||
'font/',
|
||||
'audio/',
|
||||
'video/',
|
||||
'text/css',
|
||||
];
|
||||
|
||||
function inferResourceType(url, responseHeaders) {
|
||||
if (STATIC_EXT.test(url || '')) return 'static';
|
||||
const ct = headerValue(responseHeaders, 'content-type').toLowerCase();
|
||||
for (const p of STATIC_MIME_PREFIXES) {
|
||||
if (ct.startsWith(p)) return 'static';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function headerValue(headers, name) {
|
||||
if (!headers || !headers.length) return '';
|
||||
const lower = name.toLowerCase();
|
||||
for (const h of headers) {
|
||||
if ((h.name || '').toLowerCase() === lower) return h.value || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function headerLines(headers) {
|
||||
if (!headers || !headers.length) return '';
|
||||
return headers.map((h) => `${h.name}: ${h.value}`).join('\n');
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str || str.length <= max) return str || '';
|
||||
return str.slice(0, max) + '\n… [truncated]';
|
||||
}
|
||||
|
||||
function shouldCaptureEntry(entry, filterApiOnly) {
|
||||
if (!entry || !entry.url) return false;
|
||||
return mightCaptureRequest(entry.url, entry.resourceType, filterApiOnly);
|
||||
}
|
||||
|
||||
/** Fast pre-filter before reading response body in devtools. */
|
||||
function mightCaptureRequest(url, resourceType, filterApiOnly) {
|
||||
const rt = (resourceType || inferResourceType(url, null) || 'other').toLowerCase();
|
||||
if (filterApiOnly) {
|
||||
return rt === 'xhr' || rt === 'fetch' || rt === 'websocket';
|
||||
}
|
||||
if (rt === 'static') return false;
|
||||
if (STATIC_EXT.test(url || '')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function summarizeHarEntry(harEntry, responseBody, resourceType) {
|
||||
const req = harEntry.request || {};
|
||||
const res = harEntry.response || {};
|
||||
const url = req.url || '';
|
||||
let path = '/';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
path = u.pathname + (u.search || '');
|
||||
} catch (_) {
|
||||
path = url;
|
||||
}
|
||||
const shortPath = path.length > 80 ? path.slice(0, 77) + '...' : path;
|
||||
const title = `${req.method || 'GET'} ${shortPath}`;
|
||||
|
||||
return {
|
||||
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title,
|
||||
method: req.method || 'GET',
|
||||
url,
|
||||
resourceType: resourceType || 'other',
|
||||
requestHeaders: headerLines(req.headers),
|
||||
requestBody: truncate((req.postData && req.postData.text) || '', CSAI_LIMITS.MAX_REQUEST_BODY),
|
||||
responseStatus: res.status,
|
||||
responseHeaders: headerLines(res.headers),
|
||||
responseBody: truncate(responseBody || '', CSAI_LIMITS.MAX_RESPONSE_BODY),
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePageContext(page) {
|
||||
return {
|
||||
id: `page_${Date.now()}`,
|
||||
title: `PAGE ${page.title || page.url || ''}`.slice(0, 80),
|
||||
method: 'PAGE',
|
||||
url: page.url || '',
|
||||
resourceType: 'document',
|
||||
requestHeaders: '',
|
||||
requestBody: '',
|
||||
responseStatus: 0,
|
||||
responseHeaders: '',
|
||||
responseBody: '',
|
||||
pageTitle: page.title || '',
|
||||
isPageContext: true,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** In-memory cache for projects / roles lists (per baseUrl + token). */
|
||||
|
||||
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const catalogCache = {
|
||||
baseUrl: '',
|
||||
token: '',
|
||||
projects: null,
|
||||
roles: null,
|
||||
fetchedAt: 0,
|
||||
};
|
||||
|
||||
function catalogCacheValid(baseUrl, token) {
|
||||
if (!catalogCache.fetchedAt) return false;
|
||||
if (catalogCache.baseUrl !== baseUrl || catalogCache.token !== token) return false;
|
||||
return Date.now() - catalogCache.fetchedAt < CATALOG_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function invalidateCatalogCache() {
|
||||
catalogCache.projects = null;
|
||||
catalogCache.roles = null;
|
||||
catalogCache.fetchedAt = 0;
|
||||
}
|
||||
|
||||
async function fetchCatalogCached(baseUrl, token, signal) {
|
||||
if (catalogCacheValid(baseUrl, token) && catalogCache.projects && catalogCache.roles) {
|
||||
return { projects: catalogCache.projects, roles: catalogCache.roles };
|
||||
}
|
||||
const [projects, roles] = await Promise.all([
|
||||
fetchProjects(baseUrl, token, signal),
|
||||
fetchRoles(baseUrl, token, signal),
|
||||
]);
|
||||
catalogCache.baseUrl = baseUrl;
|
||||
catalogCache.token = token;
|
||||
catalogCache.projects = projects;
|
||||
catalogCache.roles = roles;
|
||||
catalogCache.fetchedAt = Date.now();
|
||||
return { projects, roles };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Shared limits and defaults for the browser extension. */
|
||||
const CSAI_LIMITS = {
|
||||
MAX_CAPTURED: 200,
|
||||
MAX_RUNS: 50,
|
||||
MAX_REQUEST_BODY: 65536,
|
||||
MAX_RESPONSE_BODY: 4096,
|
||||
/** Progress log only; active Final Response is not truncated. */
|
||||
MAX_PROGRESS_CHARS: 524288,
|
||||
MAX_TAB_CAPTURES: 20,
|
||||
/** Markdown render skipped above this size (plain text only). */
|
||||
MAX_MARKDOWN_CHARS: 100000,
|
||||
/** Non-selected completed runs: soft-trim final to limit memory. */
|
||||
MAX_FINAL_ARCHIVE_CHARS: 100000,
|
||||
};
|
||||
|
||||
const CSAI_DEFAULT_INSTRUCTION =
|
||||
'针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口';
|
||||
@@ -0,0 +1,47 @@
|
||||
const DEFAULT_INSTRUCTION = CSAI_DEFAULT_INSTRUCTION;
|
||||
|
||||
function defaultInstruction() {
|
||||
return DEFAULT_INSTRUCTION;
|
||||
}
|
||||
|
||||
function toPrompt(entry, instruction) {
|
||||
if (entry && entry.isPageContext) {
|
||||
const prefix = (instruction && instruction.trim()) ? instruction.trim() : DEFAULT_INSTRUCTION;
|
||||
return (
|
||||
prefix +
|
||||
'\n\n[Target]\n' +
|
||||
'PAGE ' +
|
||||
(entry.url || '') +
|
||||
'\n\n[Page]\n' +
|
||||
'Title: ' +
|
||||
(entry.pageTitle || '') +
|
||||
'\nURL: ' +
|
||||
(entry.url || '')
|
||||
);
|
||||
}
|
||||
const prefix = (instruction && instruction.trim()) ? instruction.trim() : DEFAULT_INSTRUCTION;
|
||||
const method = entry.method || 'GET';
|
||||
const url = entry.url || '(unknown)';
|
||||
const reqHeaders = normalizeRequestBlock(entry);
|
||||
const reqBody = entry.requestBody || '';
|
||||
let respSnippet = '';
|
||||
if (entry.responseHeaders || entry.responseBody) {
|
||||
respSnippet =
|
||||
'\n\n[Optional: Response (truncated)]\n' +
|
||||
normalizeResponseBlock(entry) +
|
||||
'\n\n' +
|
||||
(entry.responseBody || '');
|
||||
}
|
||||
return (
|
||||
prefix +
|
||||
'\n\n[Target]\n' +
|
||||
method +
|
||||
' ' +
|
||||
url +
|
||||
'\n\n[Request]\n' +
|
||||
reqHeaders +
|
||||
'\n\n' +
|
||||
reqBody +
|
||||
respSnippet
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** HTTP/2 pseudo-header → HTTP/1.1 normalization for display and AI prompts.
|
||||
* Raw HAR headers in storage are never modified. */
|
||||
|
||||
function parseHeaderLines(headerText) {
|
||||
const headers = [];
|
||||
for (const line of String(headerText || '').split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const idx = line.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
headers.push({
|
||||
name: line.slice(0, idx).trim(),
|
||||
value: line.slice(idx + 1).trim(),
|
||||
});
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function urlParts(url) {
|
||||
try {
|
||||
const u = new URL(url || '');
|
||||
return {
|
||||
host: u.host,
|
||||
path: (u.pathname || '/') + (u.search || ''),
|
||||
};
|
||||
} catch (_) {
|
||||
return { host: '', path: '/' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Request line + headers (HTTP/1.1), no body. */
|
||||
function normalizeRequestBlock(entry) {
|
||||
if (!entry || entry.isPageContext) return entry?.requestHeaders || '';
|
||||
|
||||
const headers = parseHeaderLines(entry.requestHeaders);
|
||||
const fromUrl = urlParts(entry.url);
|
||||
let method = entry.method || 'GET';
|
||||
let path = fromUrl.path || '/';
|
||||
let host = fromUrl.host;
|
||||
|
||||
const regular = [];
|
||||
let hasHost = false;
|
||||
|
||||
for (const h of headers) {
|
||||
const name = h.name;
|
||||
const lower = name.toLowerCase();
|
||||
if (name.startsWith(':')) {
|
||||
if (lower === ':method') method = h.value || method;
|
||||
else if (lower === ':path') path = h.value || path;
|
||||
else if (lower === ':authority') host = h.value || host;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'host') hasHost = true;
|
||||
regular.push(`${name}: ${h.value}`);
|
||||
}
|
||||
|
||||
if (host && !hasHost) {
|
||||
regular.unshift(`Host: ${host}`);
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) path = '/' + path;
|
||||
return `${method} ${path} HTTP/1.1\n${regular.join('\n')}`;
|
||||
}
|
||||
|
||||
/** Status line + headers (HTTP/1.1), no body. */
|
||||
function normalizeResponseBlock(entry) {
|
||||
if (!entry) return '';
|
||||
|
||||
const headers = parseHeaderLines(entry.responseHeaders);
|
||||
let status = entry.responseStatus || 0;
|
||||
const regular = [];
|
||||
|
||||
for (const h of headers) {
|
||||
const name = h.name;
|
||||
const lower = name.toLowerCase();
|
||||
if (name.startsWith(':')) {
|
||||
if (lower === ':status') {
|
||||
const n = parseInt(h.value, 10);
|
||||
if (!Number.isNaN(n)) status = n;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
regular.push(`${name}: ${h.value}`);
|
||||
}
|
||||
|
||||
const statusLine = `HTTP/1.1 ${status || '?'}`;
|
||||
return regular.length ? `${statusLine}\n${regular.join('\n')}` : statusLine;
|
||||
}
|
||||
|
||||
function formatRequestDisplay(entry) {
|
||||
if (!entry) return '';
|
||||
if (entry.isPageContext) {
|
||||
return `PAGE ${entry.url || ''}\n\nTitle: ${entry.pageTitle || ''}`;
|
||||
}
|
||||
const block = normalizeRequestBlock(entry);
|
||||
const body = entry.requestBody || '';
|
||||
return body ? `${block}\n\n${body}` : block;
|
||||
}
|
||||
|
||||
function formatResponseDisplay(entry) {
|
||||
if (!entry) return '';
|
||||
const block = normalizeResponseBlock(entry);
|
||||
const body = entry.responseBody || '';
|
||||
return body ? `${block}\n\n${body}` : block;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Minimal Markdown → HTML (aligned with Burp plugin renderer). */
|
||||
|
||||
function mdEscapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function mdHeadingLevel(s) {
|
||||
let i = 0;
|
||||
while (i < s.length && s[i] === '#') i++;
|
||||
if (i >= 1 && i <= 6 && i < s.length && /\s/.test(s[i])) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mdReplaceInlineCode(s) {
|
||||
let out = '';
|
||||
let inCode = false;
|
||||
let buf = '';
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '`') {
|
||||
if (!inCode) {
|
||||
inCode = true;
|
||||
buf = '';
|
||||
} else {
|
||||
out += `<code>${buf}</code>`;
|
||||
inCode = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) buf += c;
|
||||
else out += c;
|
||||
}
|
||||
if (inCode) out += '`' + buf;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mdReplaceBold(s) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const start = s.indexOf('**', i);
|
||||
if (start < 0) {
|
||||
out += s.slice(i);
|
||||
break;
|
||||
}
|
||||
const end = s.indexOf('**', start + 2);
|
||||
if (end < 0) {
|
||||
out += s.slice(i);
|
||||
break;
|
||||
}
|
||||
out += s.slice(i, start) + '<b>' + s.slice(start + 2, end) + '</b>';
|
||||
i = end + 2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mdInlineFormat(text) {
|
||||
let escaped = mdEscapeHtml(text);
|
||||
escaped = mdReplaceInlineCode(escaped);
|
||||
escaped = mdReplaceBold(escaped);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
const lines = String(markdown || '').split(/\r?\n/);
|
||||
const css =
|
||||
'body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1.45;margin:10px;color:#111;}' +
|
||||
'code,pre{font-family:ui-monospace,Menlo,Consolas,monospace;}' +
|
||||
'code{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:4px;padding:0 4px;}' +
|
||||
'pre{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:6px;padding:10px;overflow:auto;}' +
|
||||
'pre code{background:transparent;border:none;padding:0;}' +
|
||||
'p{margin:0.55em 0;}ul{margin:0.4em 0 0.6em 1.2em;padding:0;}';
|
||||
|
||||
let out = `<html><head><meta charset="utf-8"><style>${css}</style></head><body>`;
|
||||
let inCode = false;
|
||||
let inList = false;
|
||||
let codeBuf = '';
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw == null ? '' : raw;
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (!inCode) {
|
||||
inCode = true;
|
||||
codeBuf = '';
|
||||
} else {
|
||||
out += `<pre><code>${mdEscapeHtml(codeBuf)}</code></pre>`;
|
||||
inCode = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
codeBuf += line + '\n';
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
if (inList) {
|
||||
out += '</ul>';
|
||||
inList = false;
|
||||
}
|
||||
out += "<div style='height:6px'></div>";
|
||||
continue;
|
||||
}
|
||||
const h = mdHeadingLevel(trimmed);
|
||||
if (h > 0) {
|
||||
if (inList) {
|
||||
out += '</ul>';
|
||||
inList = false;
|
||||
}
|
||||
out += `<h${h}>${mdInlineFormat(trimmed.slice(h).trim())}</h${h}>`;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
if (!inList) {
|
||||
out += '<ul>';
|
||||
inList = true;
|
||||
}
|
||||
out += `<li>${mdInlineFormat(trimmed.slice(2).trim())}</li>`;
|
||||
continue;
|
||||
}
|
||||
if (inList) {
|
||||
out += '</ul>';
|
||||
inList = false;
|
||||
}
|
||||
out += `<p>${mdInlineFormat(trimmed)}</p>`;
|
||||
}
|
||||
if (inCode) out += `<pre><code>${mdEscapeHtml(codeBuf)}</code></pre>`;
|
||||
if (inList) out += '</ul>';
|
||||
out += '</body></html>';
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* global chrome */
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
host: 'csai_host',
|
||||
port: 'csai_port',
|
||||
https: 'csai_https',
|
||||
lastProjectId: 'csai_last_project',
|
||||
lastRole: 'csai_last_role',
|
||||
lastAgentMode: 'csai_last_agent_mode',
|
||||
lastInstruction: 'csai_last_instruction',
|
||||
filterApiOnly: 'csai_filter_api_only',
|
||||
captureEnabled: 'csai_capture_enabled',
|
||||
renderMarkdown: 'csai_render_markdown',
|
||||
showDebugEvents: 'csai_show_debug',
|
||||
};
|
||||
|
||||
const SESSION_TOKEN_KEY = 'csai_token';
|
||||
|
||||
const DEFAULTS = {
|
||||
host: '127.0.0.1',
|
||||
port: '8080',
|
||||
https: true,
|
||||
lastInstruction: CSAI_DEFAULT_INSTRUCTION,
|
||||
filterApiOnly: true,
|
||||
captureEnabled: true,
|
||||
renderMarkdown: true,
|
||||
showDebugEvents: false,
|
||||
};
|
||||
|
||||
function localGet(keys) {
|
||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||
}
|
||||
|
||||
function localSet(obj) {
|
||||
return new Promise((resolve) => chrome.storage.local.set(obj, resolve));
|
||||
}
|
||||
|
||||
function sessionGet(keys) {
|
||||
return new Promise((resolve) => {
|
||||
if (!chrome.storage.session) {
|
||||
chrome.storage.local.get(keys, resolve);
|
||||
return;
|
||||
}
|
||||
chrome.storage.session.get(keys, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionSet(obj) {
|
||||
return new Promise((resolve) => {
|
||||
if (!chrome.storage.session) {
|
||||
chrome.storage.local.set(obj, resolve);
|
||||
return;
|
||||
}
|
||||
chrome.storage.session.set(obj, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const data = await localGet(Object.values(STORAGE_KEYS));
|
||||
const sess = await sessionGet([SESSION_TOKEN_KEY]);
|
||||
return {
|
||||
host: data[STORAGE_KEYS.host] || DEFAULTS.host,
|
||||
port: data[STORAGE_KEYS.port] || DEFAULTS.port,
|
||||
https: data[STORAGE_KEYS.https] !== false,
|
||||
token: sess[SESSION_TOKEN_KEY] || '',
|
||||
lastProjectId: data[STORAGE_KEYS.lastProjectId] || '',
|
||||
lastRole: data[STORAGE_KEYS.lastRole] || '',
|
||||
lastAgentMode: data[STORAGE_KEYS.lastAgentMode] || 'eino_single',
|
||||
lastInstruction: data[STORAGE_KEYS.lastInstruction] || DEFAULTS.lastInstruction,
|
||||
filterApiOnly: data[STORAGE_KEYS.filterApiOnly] !== false,
|
||||
captureEnabled: data[STORAGE_KEYS.captureEnabled] !== false,
|
||||
renderMarkdown: data[STORAGE_KEYS.renderMarkdown] !== false,
|
||||
showDebugEvents: data[STORAGE_KEYS.showDebugEvents] === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveConfig(partial) {
|
||||
const localMap = {};
|
||||
if (partial.host != null) localMap[STORAGE_KEYS.host] = partial.host;
|
||||
if (partial.port != null) localMap[STORAGE_KEYS.port] = partial.port;
|
||||
if (partial.https != null) localMap[STORAGE_KEYS.https] = partial.https;
|
||||
if (partial.lastProjectId != null) localMap[STORAGE_KEYS.lastProjectId] = partial.lastProjectId;
|
||||
if (partial.lastRole != null) localMap[STORAGE_KEYS.lastRole] = partial.lastRole;
|
||||
if (partial.lastAgentMode != null) localMap[STORAGE_KEYS.lastAgentMode] = partial.lastAgentMode;
|
||||
if (partial.lastInstruction != null) localMap[STORAGE_KEYS.lastInstruction] = partial.lastInstruction;
|
||||
if (partial.filterApiOnly != null) localMap[STORAGE_KEYS.filterApiOnly] = partial.filterApiOnly;
|
||||
if (partial.captureEnabled != null) localMap[STORAGE_KEYS.captureEnabled] = partial.captureEnabled;
|
||||
if (partial.renderMarkdown != null) localMap[STORAGE_KEYS.renderMarkdown] = partial.renderMarkdown;
|
||||
if (partial.showDebugEvents != null) localMap[STORAGE_KEYS.showDebugEvents] = partial.showDebugEvents;
|
||||
if (Object.keys(localMap).length) await localSet(localMap);
|
||||
if (partial.token != null) await sessionSet({ [SESSION_TOKEN_KEY]: partial.token });
|
||||
}
|
||||
|
||||
function baseUrlFrom(cfg) {
|
||||
const scheme = cfg.https ? 'https' : 'http';
|
||||
return `${scheme}://${cfg.host}:${cfg.port}`;
|
||||
}
|
||||
|
||||
/** Request optional host permission for the configured CyberStrikeAI origin. */
|
||||
async function ensureHostPermission(baseUrl) {
|
||||
if (!chrome.permissions || !chrome.permissions.request) return;
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(baseUrl).origin + '/*';
|
||||
} catch (_) {
|
||||
throw new Error('Invalid Host/Port');
|
||||
}
|
||||
const has = await chrome.permissions.contains({ origins: [origin] });
|
||||
if (has) return;
|
||||
const granted = await chrome.permissions.request({ origins: [origin] });
|
||||
if (!granted) {
|
||||
throw new Error('需要授权访问 CyberStrikeAI 服务器地址');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CyberStrikeAI",
|
||||
"version": "0.3.5",
|
||||
"description": "Capture browser HTTP traffic and send to CyberStrikeAI for AI-assisted security testing.",
|
||||
"devtools_page": "devtools.html",
|
||||
"permissions": ["storage"],
|
||||
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background/service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_title": "CyberStrikeAI"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT="$ROOT/dist/cyberstrikeai-browser-extension.zip"
|
||||
mkdir -p "$ROOT/dist"
|
||||
rm -f "$OUT"
|
||||
(cd "$ROOT" && zip -r "$OUT" . \
|
||||
-x './dist/*' -x './package.sh' -x '*/.DS_Store')
|
||||
echo "[+] $OUT"
|
||||
@@ -0,0 +1,867 @@
|
||||
/* CyberStrikeAI DevTools panel — light default, dark via prefers-color-scheme */
|
||||
|
||||
:root {
|
||||
--csai-accent: #ea580c;
|
||||
--csai-accent-hover: #c2410c;
|
||||
--csai-accent-soft: rgba(234, 88, 12, 0.12);
|
||||
--csai-accent-ring: rgba(234, 88, 12, 0.28);
|
||||
|
||||
--csai-bg: #f4f5f7;
|
||||
--csai-surface: #ffffff;
|
||||
--csai-surface-2: #f8f9fb;
|
||||
--csai-surface-3: #eef0f4;
|
||||
--csai-border: #e2e5eb;
|
||||
--csai-border-strong: #cdd2dc;
|
||||
|
||||
--csai-text: #1a1d24;
|
||||
--csai-text-muted: #5c6470;
|
||||
--csai-text-faint: #8b939e;
|
||||
|
||||
--csai-success: #16a34a;
|
||||
--csai-success-bg: rgba(22, 163, 74, 0.1);
|
||||
--csai-danger: #dc2626;
|
||||
--csai-danger-bg: rgba(220, 38, 38, 0.1);
|
||||
--csai-info: #2563eb;
|
||||
--csai-warn: #d97706;
|
||||
|
||||
--csai-radius: 8px;
|
||||
--csai-radius-sm: 6px;
|
||||
--csai-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
--csai-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08);
|
||||
|
||||
--csai-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--csai-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
--csai-control-h: 30px;
|
||||
--csai-bar-h: 40px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--csai-bg: #1a1d23;
|
||||
--csai-surface: #22262e;
|
||||
--csai-surface-2: #2a2f38;
|
||||
--csai-surface-3: #323844;
|
||||
--csai-border: #3a404c;
|
||||
--csai-border-strong: #4a5160;
|
||||
|
||||
--csai-text: #e8eaed;
|
||||
--csai-text-muted: #9aa3af;
|
||||
--csai-text-faint: #6b7280;
|
||||
|
||||
--csai-accent-soft: rgba(251, 146, 60, 0.15);
|
||||
--csai-accent-ring: rgba(251, 146, 60, 0.35);
|
||||
--csai-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--csai-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.ctx-banner {
|
||||
padding: 8px 12px;
|
||||
background: #fef3c7;
|
||||
border-bottom: 1px solid #f59e0b;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-banner.hidden { display: none; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ctx-banner {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 12px/1.45 var(--csai-font);
|
||||
color: var(--csai-text);
|
||||
background: var(--csai-bg);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
background: var(--csai-surface);
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-shadow: var(--csai-shadow);
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--csai-control-h);
|
||||
}
|
||||
|
||||
.toolbar-row--conn {
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar--conn-collapsed .conn-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar:not(.toolbar--conn-collapsed) .conn-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conn-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
height: var(--csai-control-h);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--csai-mono);
|
||||
color: var(--csai-text-muted);
|
||||
background: var(--csai-surface-2);
|
||||
border: 1px solid var(--csai-border);
|
||||
border-radius: var(--csai-radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conn-summary--ok {
|
||||
color: var(--csai-success);
|
||||
background: var(--csai-success-bg);
|
||||
border-color: rgba(22, 163, 74, 0.25);
|
||||
}
|
||||
|
||||
.conn-details {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.conn-details .conn-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#btn-conn-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-row--top { gap: 12px; }
|
||||
|
||||
.toolbar-row--bottom {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
height: var(--csai-control-h);
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--csai-border);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
box-shadow: var(--csai-shadow);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--csai-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.conn-group,
|
||||
.action-group,
|
||||
.option-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conn-group { flex: 1; min-width: 0; }
|
||||
|
||||
.conn-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--csai-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Toolbar: inline label + control on one baseline */
|
||||
.toolbar .field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar .field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--csai-text-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.field {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.field.block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--csai-text-faint);
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field input[type="password"],
|
||||
.field select,
|
||||
.field textarea,
|
||||
#search {
|
||||
height: var(--csai-control-h);
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--csai-border);
|
||||
border-radius: var(--csai-radius-sm);
|
||||
background: var(--csai-surface-2);
|
||||
color: var(--csai-text);
|
||||
font: inherit;
|
||||
line-height: normal;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
height: auto;
|
||||
min-height: 120px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus,
|
||||
#search:focus {
|
||||
outline: none;
|
||||
border-color: var(--csai-accent);
|
||||
box-shadow: 0 0 0 3px var(--csai-accent-ring);
|
||||
}
|
||||
|
||||
.field--sm input { width: 64px; }
|
||||
|
||||
#host { width: 120px; }
|
||||
#password { width: 100px; }
|
||||
|
||||
.search-wrap {
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
#search {
|
||||
width: 100%;
|
||||
background: var(--csai-surface-2);
|
||||
}
|
||||
|
||||
#search::placeholder { color: var(--csai-text-faint); }
|
||||
|
||||
/* ── Toggle pills ── */
|
||||
.toggle-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: var(--csai-control-h);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
border: 1px solid var(--csai-border);
|
||||
background: var(--csai-surface-2);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--csai-text-muted);
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-pill:hover {
|
||||
border-color: var(--csai-border-strong);
|
||||
color: var(--csai-text);
|
||||
}
|
||||
|
||||
.toggle-pill:has(input:checked) {
|
||||
background: var(--csai-accent-soft);
|
||||
border-color: var(--csai-accent);
|
||||
color: var(--csai-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-pill input { margin: 0; accent-color: var(--csai-accent); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--csai-control-h);
|
||||
padding: 0 14px;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) { transform: scale(0.98); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--csai-accent);
|
||||
color: #fff;
|
||||
border-color: var(--csai-accent);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--csai-accent-hover);
|
||||
border-color: var(--csai-accent-hover);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--csai-surface-2);
|
||||
color: var(--csai-text);
|
||||
border-color: var(--csai-border);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--csai-surface-3);
|
||||
border-color: var(--csai-border-strong);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--csai-danger-bg);
|
||||
color: var(--csai-danger);
|
||||
border-color: rgba(220, 38, 38, 0.25);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--csai-text-muted);
|
||||
border-color: var(--csai-border);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--csai-surface-2);
|
||||
color: var(--csai-text);
|
||||
}
|
||||
|
||||
.btn--capture-on {
|
||||
background: var(--csai-success-bg);
|
||||
color: var(--csai-success);
|
||||
border-color: rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
|
||||
.btn--capture-on:hover:not(:disabled) {
|
||||
background: rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
|
||||
.btn--capture-off {
|
||||
background: var(--csai-surface-2);
|
||||
color: var(--csai-text-muted);
|
||||
border-color: var(--csai-border);
|
||||
}
|
||||
|
||||
.btn--capture-off:hover:not(:disabled) {
|
||||
background: var(--csai-surface-3);
|
||||
color: var(--csai-text);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--csai-accent);
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
background: var(--csai-accent-soft);
|
||||
}
|
||||
|
||||
/* ── Status badge ── */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--csai-control-h);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--csai-text-muted);
|
||||
background: var(--csai-surface-2);
|
||||
border: 1px solid var(--csai-border);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--csai-text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: var(--csai-success);
|
||||
background: var(--csai-success-bg);
|
||||
border-color: rgba(22, 163, 74, 0.25);
|
||||
}
|
||||
|
||||
.status-ok::before { background: var(--csai-success); }
|
||||
|
||||
.status-error {
|
||||
color: var(--csai-danger);
|
||||
background: var(--csai-danger-bg);
|
||||
border-color: rgba(220, 38, 38, 0.25);
|
||||
}
|
||||
|
||||
.status-error::before { background: var(--csai-danger); }
|
||||
|
||||
.status-pending {
|
||||
color: var(--csai-info);
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.status-pending::before {
|
||||
background: var(--csai-info);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--csai-warn);
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border-color: rgba(217, 119, 6, 0.25);
|
||||
}
|
||||
|
||||
.status-warn::before { background: var(--csai-warn); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
background: var(--csai-surface);
|
||||
border: 1px solid var(--csai-border);
|
||||
border-radius: var(--csai-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--csai-shadow);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
max-height: 42%;
|
||||
}
|
||||
|
||||
.sidebar-section.grow {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--csai-bar-h);
|
||||
min-height: var(--csai-bar-h);
|
||||
padding: 0 12px;
|
||||
background: var(--csai-surface-2);
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-head strong {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--csai-text-muted);
|
||||
}
|
||||
|
||||
.capture-paused-hint {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--csai-warn);
|
||||
}
|
||||
|
||||
.sidebar-head .link-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sidebar.capture-paused .request-list {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ── Lists ── */
|
||||
.run-list,
|
||||
.request-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.run-list li,
|
||||
.request-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--csai-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.run-list li:hover,
|
||||
.request-list li:hover {
|
||||
background: var(--csai-surface-2);
|
||||
}
|
||||
|
||||
.run-list li.selected,
|
||||
.request-list li.selected {
|
||||
background: var(--csai-accent-soft);
|
||||
border-color: rgba(234, 88, 12, 0.35);
|
||||
}
|
||||
|
||||
.run-body { min-width: 0; flex: 1; }
|
||||
|
||||
.run-title {
|
||||
font-weight: 600;
|
||||
color: var(--csai-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.run-list .meta {
|
||||
color: var(--csai-text-faint);
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px var(--csai-surface);
|
||||
}
|
||||
|
||||
.dot-running {
|
||||
background: var(--csai-info);
|
||||
box-shadow: 0 0 0 2px var(--csai-surface), 0 0 6px rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
|
||||
.dot-done { background: var(--csai-success); }
|
||||
.dot-error { background: var(--csai-danger); }
|
||||
.dot-cancelled { background: var(--csai-warn); }
|
||||
|
||||
.request-list .method {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--csai-accent-soft);
|
||||
color: var(--csai-accent);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.request-list .status-ok {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
color: var(--csai-success);
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.request-list .status-err {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
color: var(--csai-danger);
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.request-list li {
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-list .req-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Main panel ── */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--csai-surface);
|
||||
border: 1px solid var(--csai-border);
|
||||
border-radius: var(--csai-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--csai-shadow);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: var(--csai-bar-h);
|
||||
min-height: var(--csai-bar-h);
|
||||
padding: 0 10px;
|
||||
background: var(--csai-surface-2);
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
border-radius: var(--csai-radius-sm) var(--csai-radius-sm) 0 0;
|
||||
background: transparent;
|
||||
height: calc(var(--csai-bar-h) - 1px);
|
||||
padding: 0 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--csai-text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--csai-text);
|
||||
background: var(--csai-surface);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--csai-accent);
|
||||
background: var(--csai-surface);
|
||||
border-bottom-color: var(--csai-accent);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--csai-surface);
|
||||
}
|
||||
|
||||
.tab-panel.hidden { display: none; }
|
||||
|
||||
.split {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane {
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 72px;
|
||||
max-height: 38%;
|
||||
}
|
||||
|
||||
.pane.grow {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
border-bottom: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--csai-text-faint);
|
||||
background: var(--csai-surface-2);
|
||||
border-bottom: 1px solid var(--csai-border);
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: 11px/1.55 var(--csai-mono);
|
||||
color: var(--csai-text);
|
||||
background: var(--csai-surface);
|
||||
}
|
||||
|
||||
.log.full { height: 100%; }
|
||||
|
||||
.md-frame {
|
||||
border: none;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--csai-surface);
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── Dialog ── */
|
||||
#send-dialog {
|
||||
border: 1px solid var(--csai-border);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
width: min(720px, 92vw);
|
||||
background: var(--csai-surface);
|
||||
color: var(--csai-text);
|
||||
box-shadow: var(--csai-shadow-md);
|
||||
}
|
||||
|
||||
#send-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
#send-dialog form { padding: 20px; margin: 0; }
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-logo {
|
||||
border-radius: var(--csai-radius-sm);
|
||||
box-shadow: var(--csai-shadow);
|
||||
}
|
||||
|
||||
#send-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.send-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#dlg-instruction {
|
||||
resize: vertical;
|
||||
font-family: var(--csai-font);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--csai-border);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CyberStrikeAI</title>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="ctx-banner" class="ctx-banner hidden" role="alert">
|
||||
扩展已更新或上下文失效,请关闭 DevTools 后重新打开(F12 → CyberStrikeAI 面板)。
|
||||
</div>
|
||||
<header class="toolbar" id="toolbar">
|
||||
<div class="toolbar-row toolbar-row--conn">
|
||||
<div class="brand">
|
||||
<img src="../icons/icon48.png" alt="" width="24" height="24" class="brand-logo">
|
||||
<span class="brand-name">CyberStrikeAI</span>
|
||||
</div>
|
||||
<span id="conn-summary" class="conn-summary"></span>
|
||||
<button id="btn-conn-toggle" type="button" class="btn btn--ghost" aria-expanded="false">连接设置</button>
|
||||
<span id="status" class="status">Not validated</span>
|
||||
</div>
|
||||
<div id="conn-details" class="conn-details">
|
||||
<div class="conn-group">
|
||||
<label class="field">
|
||||
<span class="field-label">Host</span>
|
||||
<input id="host" type="text" size="12" spellcheck="false">
|
||||
</label>
|
||||
<label class="field field--sm">
|
||||
<span class="field-label">Port</span>
|
||||
<input id="port" type="text" size="5" spellcheck="false">
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input id="https" type="checkbox">
|
||||
<span>HTTPS</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input id="password" type="password" size="10" autocomplete="off">
|
||||
</label>
|
||||
<button id="btn-validate" type="button" class="btn btn--ghost">Validate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-row toolbar-row--bottom">
|
||||
<div class="action-group">
|
||||
<button id="btn-send" type="button" class="btn btn--primary" disabled>Send</button>
|
||||
<button id="btn-latest-xhr" type="button" class="btn btn--secondary" disabled>Latest XHR</button>
|
||||
<button id="btn-stop" type="button" class="btn btn--danger">Stop</button>
|
||||
<span class="action-divider"></span>
|
||||
<button id="btn-copy" type="button" class="btn btn--ghost">Copy</button>
|
||||
<button id="btn-clear" type="button" class="btn btn--ghost">Clear Output</button>
|
||||
<span class="action-divider"></span>
|
||||
<button id="btn-capture-toggle" type="button" class="btn btn--capture-on" aria-pressed="true">● 捕获中</button>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="toggle-pill"><input id="filter-api" type="checkbox" checked><span>XHR/Fetch only</span></label>
|
||||
<label class="toggle-pill"><input id="debug-events" type="checkbox"><span>Debug events</span></label>
|
||||
<label class="toggle-pill"><input id="render-md" type="checkbox" checked><span>Markdown</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-head">
|
||||
<strong>Test History</strong>
|
||||
<button id="btn-clear-runs" type="button" class="link-btn">Clear All</button>
|
||||
</div>
|
||||
<ul id="run-list" class="run-list"></ul>
|
||||
</div>
|
||||
<div class="sidebar-section grow">
|
||||
<div class="sidebar-head">
|
||||
<strong>Captured Requests</strong>
|
||||
<span id="capture-paused-hint" class="capture-paused-hint hidden">已暂停</span>
|
||||
<button id="btn-clear-captures" type="button" class="link-btn">Clear</button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<input id="search" type="search" placeholder="Search URL…">
|
||||
</div>
|
||||
<ul id="request-list" class="request-list"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="main">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="output">Output</button>
|
||||
<button class="tab" data-tab="request">Request</button>
|
||||
<button class="tab" data-tab="response">Response</button>
|
||||
</div>
|
||||
<div id="tab-output" class="tab-panel active">
|
||||
<div class="split">
|
||||
<div class="pane">
|
||||
<div class="pane-title">Progress</div>
|
||||
<pre id="progress" class="log"></pre>
|
||||
</div>
|
||||
<div class="pane grow">
|
||||
<div class="pane-title">Final Response</div>
|
||||
<pre id="final-raw" class="log"></pre>
|
||||
<iframe id="final-md" class="log md-frame hidden" sandbox=""></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-request" class="tab-panel hidden">
|
||||
<pre id="view-request" class="log full"></pre>
|
||||
</div>
|
||||
<div id="tab-response" class="tab-panel hidden">
|
||||
<pre id="view-response" class="log full"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="send-dialog">
|
||||
<form method="dialog" id="send-form">
|
||||
<div class="dialog-header">
|
||||
<img src="../icons/icon48.png" alt="" width="32" height="32" class="dialog-logo">
|
||||
<h3>发送到 CyberStrikeAI</h3>
|
||||
</div>
|
||||
<div class="send-row">
|
||||
<label class="field block">项目 <select id="dlg-project"></select></label>
|
||||
<label class="field block">角色 <select id="dlg-role"></select></label>
|
||||
<label class="field block">对话 <select id="dlg-agent"></select></label>
|
||||
</div>
|
||||
<label class="field block">测试指令(可针对当前流量修改)
|
||||
<textarea id="dlg-instruction" rows="6"></textarea>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="dlg-cancel" class="btn btn--ghost">取消</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">确定</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="../lib/constants.js"></script>
|
||||
<script src="../lib/storage.js"></script>
|
||||
<script src="../lib/http-normalize.js"></script>
|
||||
<script src="../lib/formatter.js"></script>
|
||||
<script src="../lib/markdown.js"></script>
|
||||
<script src="../lib/api.js"></script>
|
||||
<script src="../lib/catalog-cache.js"></script>
|
||||
<script src="panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,947 @@
|
||||
/* global chrome, loadConfig, saveConfig, baseUrlFrom, ensureHostPermission,
|
||||
loginAndValidate, fetchCatalogCached, invalidateCatalogCache, streamTest,
|
||||
cancelByConversationId, extractConversationId, AGENT_MODES, toPrompt,
|
||||
defaultInstruction, markdownToHtml, CSAI_LIMITS */
|
||||
|
||||
let config = {};
|
||||
let entries = [];
|
||||
let runs = [];
|
||||
let selectedEntryId = null;
|
||||
let selectedRunId = null;
|
||||
let inspectedTabId = null;
|
||||
let token = '';
|
||||
let bgPort = null;
|
||||
let activeAbort = null;
|
||||
let activeRunId = null;
|
||||
let validateAbort = null;
|
||||
let validating = false;
|
||||
let captureEnabled = true;
|
||||
let searchDebounceTimer = null;
|
||||
let connectRetryTimer = null;
|
||||
let outputFlushScheduled = false;
|
||||
let pendingProgressRunId = null;
|
||||
let pendingFinalRunId = null;
|
||||
let finalRenderedLen = 0;
|
||||
let markdownRenderToken = 0;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function extensionAlive() {
|
||||
try {
|
||||
return !!(typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showContextBanner(show) {
|
||||
const el = $('ctx-banner');
|
||||
if (el) el.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function scheduleConnect(delayMs) {
|
||||
if (connectRetryTimer) clearTimeout(connectRetryTimer);
|
||||
connectRetryTimer = setTimeout(connectBackground, delayMs);
|
||||
}
|
||||
|
||||
function runtimeSendMessage(msg) {
|
||||
if (!extensionAlive()) {
|
||||
showContextBanner(true);
|
||||
return Promise.reject(new Error('Extension context lost'));
|
||||
}
|
||||
return chrome.runtime.sendMessage(msg);
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $('status');
|
||||
el.textContent = text;
|
||||
el.className = 'status' + (kind ? ' status-' + kind : '');
|
||||
updateConnSummary();
|
||||
}
|
||||
|
||||
function connectionEndpointLabel() {
|
||||
const scheme = $('https').checked ? 'https' : 'http';
|
||||
const host = ($('host').value || '').trim() || '127.0.0.1';
|
||||
const port = ($('port').value || '').trim() || '8080';
|
||||
return `${scheme}://${host}:${port}`;
|
||||
}
|
||||
|
||||
function setConnExpanded(expanded) {
|
||||
const toolbar = $('toolbar');
|
||||
if (!toolbar) return;
|
||||
toolbar.classList.toggle('toolbar--conn-collapsed', !expanded);
|
||||
const btn = $('btn-conn-toggle');
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
btn.textContent = expanded ? '收起' : '连接设置';
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnSummary() {
|
||||
const summary = $('conn-summary');
|
||||
if (!summary) return;
|
||||
summary.textContent = connectionEndpointLabel();
|
||||
summary.classList.toggle('conn-summary--ok', !!token);
|
||||
}
|
||||
|
||||
function onConnToggle() {
|
||||
const toolbar = $('toolbar');
|
||||
const collapsed = toolbar && toolbar.classList.contains('toolbar--conn-collapsed');
|
||||
setConnExpanded(!!collapsed);
|
||||
}
|
||||
|
||||
function updateCaptureToggleUi() {
|
||||
const btn = $('btn-capture-toggle');
|
||||
if (btn) {
|
||||
btn.classList.toggle('btn--capture-on', captureEnabled);
|
||||
btn.classList.toggle('btn--capture-off', !captureEnabled);
|
||||
btn.setAttribute('aria-pressed', captureEnabled ? 'true' : 'false');
|
||||
btn.textContent = captureEnabled ? '● 捕获中' : '○ 已暂停';
|
||||
}
|
||||
const sidebar = $('sidebar');
|
||||
if (sidebar) sidebar.classList.toggle('capture-paused', !captureEnabled);
|
||||
const hint = $('capture-paused-hint');
|
||||
if (hint) hint.classList.toggle('hidden', captureEnabled);
|
||||
}
|
||||
|
||||
async function syncCaptureEnabled() {
|
||||
await saveConfig({ captureEnabled });
|
||||
try {
|
||||
await runtimeSendMessage({ type: 'set-capture-enabled', enabled: captureEnabled });
|
||||
} catch (_) {}
|
||||
updateCaptureToggleUi();
|
||||
}
|
||||
|
||||
async function onCaptureToggle() {
|
||||
captureEnabled = !captureEnabled;
|
||||
await syncCaptureEnabled();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function truncateOutput(s, max) {
|
||||
if (!s || s.length <= max) return s || '';
|
||||
return s.slice(0, max) + '\n\n… [output truncated at ' + max + ' chars]';
|
||||
}
|
||||
|
||||
function selectedEntry() {
|
||||
return entries.find((e) => e.id === selectedEntryId) || null;
|
||||
}
|
||||
|
||||
function selectedRun() {
|
||||
return runs.find((r) => r.id === selectedRunId) || null;
|
||||
}
|
||||
|
||||
function statusDotClass(status) {
|
||||
switch (status) {
|
||||
case 'running': return 'dot-running';
|
||||
case 'done': return 'dot-done';
|
||||
case 'error': return 'dot-error';
|
||||
case 'cancelled':
|
||||
case 'cancelling': return 'dot-cancelled';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function createRun(entry, meta) {
|
||||
const run = {
|
||||
id: 'run_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
||||
title: entry ? entry.title : 'Unknown',
|
||||
status: 'running',
|
||||
meta: meta || '',
|
||||
progress: '',
|
||||
final: '',
|
||||
entry: entry ? { ...entry } : null,
|
||||
conversationId: '',
|
||||
};
|
||||
runs.unshift(run);
|
||||
if (runs.length > CSAI_LIMITS.MAX_RUNS) runs.length = CSAI_LIMITS.MAX_RUNS;
|
||||
selectedRunId = run.id;
|
||||
return run;
|
||||
}
|
||||
|
||||
function buildRunLi(r) {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.id = r.id;
|
||||
if (r.id === selectedRunId) li.classList.add('selected');
|
||||
li.innerHTML =
|
||||
`<span class="dot ${statusDotClass(r.status)}"></span>` +
|
||||
`<div class="run-body">` +
|
||||
`<div class="run-title">${escapeHtml(r.title)}</div>` +
|
||||
`<div class="meta">${escapeHtml(r.meta)} · ${escapeHtml(r.status)}</div>` +
|
||||
`</div>`;
|
||||
li.addEventListener('click', () => selectRun(r.id));
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRunList() {
|
||||
const ul = $('run-list');
|
||||
ul.innerHTML = '';
|
||||
for (const r of runs) {
|
||||
ul.appendChild(buildRunLi(r));
|
||||
}
|
||||
}
|
||||
|
||||
function prependRunItem(run) {
|
||||
const ul = $('run-list');
|
||||
ul.insertBefore(buildRunLi(run), ul.firstChild);
|
||||
while (ul.children.length > runs.length) {
|
||||
ul.removeChild(ul.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRunListItem(runId) {
|
||||
const r = runs.find((x) => x.id === runId);
|
||||
const ul = $('run-list');
|
||||
const li = ul.querySelector(`li[data-id="${CSS.escape(runId)}"]`);
|
||||
if (!r || !li) {
|
||||
renderRunList();
|
||||
return;
|
||||
}
|
||||
const selected = r.id === selectedRunId;
|
||||
li.className = selected ? 'selected' : '';
|
||||
li.innerHTML =
|
||||
`<span class="dot ${statusDotClass(r.status)}"></span>` +
|
||||
`<div class="run-body">` +
|
||||
`<div class="run-title">${escapeHtml(r.title)}</div>` +
|
||||
`<div class="meta">${escapeHtml(r.meta)} · ${escapeHtml(r.status)}</div>` +
|
||||
`</div>`;
|
||||
li.onclick = () => selectRun(r.id);
|
||||
}
|
||||
|
||||
function currentSearchQuery() {
|
||||
return ($('search').value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function entryMatchesSearch(e, q) {
|
||||
return !q || (e.url || '').toLowerCase().includes(q) || (e.title || '').toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
function buildRequestLi(e) {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.id = e.id;
|
||||
if (e.id === selectedEntryId) li.classList.add('selected');
|
||||
const st = e.responseStatus || 0;
|
||||
const stClass = st >= 200 && st < 400 ? 'status-ok' : 'status-err';
|
||||
li.innerHTML =
|
||||
`<span class="method">${escapeHtml(e.method)}</span>` +
|
||||
`<span class="${stClass}">${st || '—'}</span>` +
|
||||
`<span class="req-path">${escapeHtml(e.title || e.url)}</span>`;
|
||||
li.addEventListener('click', () => selectEntry(e.id));
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRequestList() {
|
||||
const q = currentSearchQuery();
|
||||
const ul = $('request-list');
|
||||
ul.innerHTML = '';
|
||||
const filtered = entries.filter((e) => entryMatchesSearch(e, q));
|
||||
for (const e of filtered) {
|
||||
ul.appendChild(buildRequestLi(e));
|
||||
}
|
||||
updateSendButtons();
|
||||
}
|
||||
|
||||
function prependRequestEntry(entry) {
|
||||
const q = currentSearchQuery();
|
||||
if (!entryMatchesSearch(entry, q)) return;
|
||||
const ul = $('request-list');
|
||||
ul.insertBefore(buildRequestLi(entry), ul.firstChild);
|
||||
while (ul.children.length > CSAI_LIMITS.MAX_CAPTURED) {
|
||||
ul.removeChild(ul.lastChild);
|
||||
}
|
||||
updateSendButtons();
|
||||
}
|
||||
|
||||
function updateRequestSelection() {
|
||||
$('request-list').querySelectorAll('li').forEach((li) => {
|
||||
li.classList.toggle('selected', li.dataset.id === selectedEntryId);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSendButtons() {
|
||||
const ok = !!token;
|
||||
$('btn-send').disabled = !selectedEntryId || !ok;
|
||||
$('btn-latest-xhr').disabled = !ok;
|
||||
}
|
||||
|
||||
function selectEntry(id) {
|
||||
selectedEntryId = id;
|
||||
const e = selectedEntry();
|
||||
updateRequestSelection();
|
||||
if (!e) return;
|
||||
$('view-request').textContent = formatRawRequest(e);
|
||||
$('view-response').textContent = formatRawResponse(e);
|
||||
}
|
||||
|
||||
function updateRunSelection(prevId, nextId) {
|
||||
const ul = $('run-list');
|
||||
if (prevId && prevId !== nextId) {
|
||||
const prevLi = ul.querySelector(`li[data-id="${CSS.escape(prevId)}"]`);
|
||||
if (prevLi) prevLi.classList.remove('selected');
|
||||
}
|
||||
if (nextId) {
|
||||
const nextLi = ul.querySelector(`li[data-id="${CSS.escape(nextId)}"]`);
|
||||
if (nextLi) nextLi.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function trimInactiveRunFinals(activeId) {
|
||||
const max = CSAI_LIMITS.MAX_FINAL_ARCHIVE_CHARS;
|
||||
for (const r of runs) {
|
||||
if (r.id === activeId || r.status === 'running') continue;
|
||||
if (r.final && r.final.length > max) {
|
||||
r.final = r.final.slice(0, max) + '\n\n… [inactive run truncated at ' + max + ' chars]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectRun(id) {
|
||||
const prevId = selectedRunId;
|
||||
selectedRunId = id;
|
||||
if (prevId !== id) {
|
||||
updateRunSelection(prevId, id);
|
||||
trimInactiveRunFinals(id);
|
||||
}
|
||||
const r = selectedRun();
|
||||
if (!r) return;
|
||||
flushOutputViewNow();
|
||||
refreshFinalView(r.final, { streaming: r.status === 'running' && activeRunId === r.id });
|
||||
if (r.entry) {
|
||||
$('view-request').textContent = formatRawRequest(r.entry);
|
||||
$('view-response').textContent = formatRawResponse(r.entry);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRawRequest(e) {
|
||||
return formatRequestDisplay(e);
|
||||
}
|
||||
|
||||
function formatRawResponse(e) {
|
||||
return formatResponseDisplay(e);
|
||||
}
|
||||
|
||||
function resetFinalDom(finalText) {
|
||||
const raw = $('final-raw');
|
||||
raw.textContent = finalText || '';
|
||||
finalRenderedLen = (finalText || '').length;
|
||||
}
|
||||
|
||||
function appendFinalDom(delta) {
|
||||
if (!delta) return;
|
||||
const raw = $('final-raw');
|
||||
raw.append(document.createTextNode(delta));
|
||||
finalRenderedLen += delta.length;
|
||||
raw.scrollTop = raw.scrollHeight;
|
||||
}
|
||||
|
||||
function refreshFinalView(finalText, options) {
|
||||
const opts = options || {};
|
||||
const streaming = !!opts.streaming;
|
||||
const text = finalText || '';
|
||||
const raw = $('final-raw');
|
||||
const md = $('final-md');
|
||||
const wantMd =
|
||||
$('render-md').checked &&
|
||||
text.trim() &&
|
||||
text.length <= CSAI_LIMITS.MAX_MARKDOWN_CHARS;
|
||||
|
||||
if (streaming) {
|
||||
md.classList.add('hidden');
|
||||
raw.classList.remove('hidden');
|
||||
if (text.length < finalRenderedLen) {
|
||||
resetFinalDom(text);
|
||||
} else if (text.length > finalRenderedLen) {
|
||||
appendFinalDom(text.slice(finalRenderedLen));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resetFinalDom(text);
|
||||
markdownRenderToken += 1;
|
||||
const token = markdownRenderToken;
|
||||
|
||||
if (wantMd) {
|
||||
const renderMd = () => {
|
||||
if (token !== markdownRenderToken) return;
|
||||
md.classList.remove('hidden');
|
||||
raw.classList.add('hidden');
|
||||
md.srcdoc = markdownToHtml(text);
|
||||
};
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(renderMd, { timeout: 500 });
|
||||
} else {
|
||||
setTimeout(renderMd, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
md.classList.add('hidden');
|
||||
raw.classList.remove('hidden');
|
||||
if (text.length > CSAI_LIMITS.MAX_MARKDOWN_CHARS && $('render-md').checked) {
|
||||
raw.textContent =
|
||||
text + '\n\n… [Markdown disabled: content exceeds ' + CSAI_LIMITS.MAX_MARKDOWN_CHARS + ' chars]';
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeFinalView(finalText) {
|
||||
flushOutputViewNow();
|
||||
refreshFinalView(finalText, { streaming: false });
|
||||
}
|
||||
|
||||
function scheduleOutputFlush() {
|
||||
if (outputFlushScheduled) return;
|
||||
outputFlushScheduled = true;
|
||||
requestAnimationFrame(flushOutputView);
|
||||
}
|
||||
|
||||
function flushOutputView() {
|
||||
outputFlushScheduled = false;
|
||||
if (pendingProgressRunId != null) {
|
||||
const rid = pendingProgressRunId;
|
||||
pendingProgressRunId = null;
|
||||
if (rid === selectedRunId) {
|
||||
const r = runs.find((x) => x.id === rid);
|
||||
if (r) {
|
||||
const el = $('progress');
|
||||
el.textContent = r.progress;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingFinalRunId != null) {
|
||||
const rid = pendingFinalRunId;
|
||||
pendingFinalRunId = null;
|
||||
if (rid === selectedRunId) {
|
||||
const r = runs.find((x) => x.id === rid);
|
||||
if (r) {
|
||||
refreshFinalView(r.final, { streaming: isRunStreaming(rid) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flushOutputViewNow() {
|
||||
outputFlushScheduled = false;
|
||||
if (pendingProgressRunId != null || pendingFinalRunId != null) {
|
||||
const p = pendingProgressRunId;
|
||||
const f = pendingFinalRunId;
|
||||
pendingProgressRunId = null;
|
||||
pendingFinalRunId = null;
|
||||
if (p === selectedRunId) {
|
||||
const r = runs.find((x) => x.id === p);
|
||||
if (r) {
|
||||
$('progress').textContent = r.progress;
|
||||
$('progress').scrollTop = $('progress').scrollHeight;
|
||||
}
|
||||
}
|
||||
if (f === selectedRunId) {
|
||||
const r = runs.find((x) => x.id === f);
|
||||
if (r) {
|
||||
refreshFinalView(r.final, { streaming: isRunStreaming(f) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRunStreaming(runId) {
|
||||
if (runId !== activeRunId) return false;
|
||||
const r = runs.find((x) => x.id === runId);
|
||||
return !!(r && r.status === 'running');
|
||||
}
|
||||
|
||||
function appendToRun(runId, field, text) {
|
||||
const r = runs.find((x) => x.id === runId);
|
||||
if (!r || !text) return;
|
||||
if (field === 'progress') {
|
||||
r.progress = truncateOutput(r.progress + text, CSAI_LIMITS.MAX_PROGRESS_CHARS);
|
||||
} else {
|
||||
r[field] = (r[field] || '') + text;
|
||||
}
|
||||
if (runId === selectedRunId) {
|
||||
if (field === 'progress') {
|
||||
pendingProgressRunId = runId;
|
||||
scheduleOutputFlush();
|
||||
} else if (field === 'final') {
|
||||
pendingFinalRunId = runId;
|
||||
scheduleOutputFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setRunStatus(runId, status) {
|
||||
const r = runs.find((x) => x.id === runId);
|
||||
if (!r) return;
|
||||
if (r.status === status) return;
|
||||
r.status = status;
|
||||
updateRunListItem(runId);
|
||||
if (runId === selectedRunId && status !== 'running') {
|
||||
finalizeFinalView(r ? r.final : '');
|
||||
}
|
||||
}
|
||||
|
||||
function setRunConversationId(runId, cid) {
|
||||
const r = runs.find((x) => x.id === runId);
|
||||
if (r && cid) r.conversationId = cid;
|
||||
}
|
||||
|
||||
async function initConfig() {
|
||||
config = await loadConfig();
|
||||
token = config.token || '';
|
||||
$('host').value = config.host;
|
||||
$('port').value = config.port;
|
||||
$('https').checked = config.https;
|
||||
$('filter-api').checked = config.filterApiOnly;
|
||||
captureEnabled = config.captureEnabled !== false;
|
||||
$('render-md').checked = config.renderMarkdown;
|
||||
$('debug-events').checked = config.showDebugEvents;
|
||||
setStatus(token ? 'OK (token saved, session)' : 'Not validated', token ? 'ok' : '');
|
||||
setConnExpanded(!token);
|
||||
updateConnSummary();
|
||||
updateCaptureToggleUi();
|
||||
syncCaptureEnabled().catch(() => {});
|
||||
updateSendButtons();
|
||||
}
|
||||
|
||||
async function persistConnection() {
|
||||
await saveConfig({
|
||||
host: $('host').value.trim(),
|
||||
port: $('port').value.trim(),
|
||||
https: $('https').checked,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
renderMarkdown: $('render-md').checked,
|
||||
showDebugEvents: $('debug-events').checked,
|
||||
});
|
||||
}
|
||||
|
||||
async function onValidate() {
|
||||
if (validating) {
|
||||
validateAbort?.abort();
|
||||
validating = false;
|
||||
$('btn-validate').textContent = 'Validate';
|
||||
setStatus('Cancelled', 'warn');
|
||||
return;
|
||||
}
|
||||
validating = true;
|
||||
validateAbort = new AbortController();
|
||||
$('btn-validate').textContent = 'Cancel';
|
||||
setStatus('Validating...', 'pending');
|
||||
await persistConnection();
|
||||
config = await loadConfig();
|
||||
const baseUrl = baseUrlFrom(config);
|
||||
const password = $('password').value;
|
||||
try {
|
||||
await ensureHostPermission(baseUrl);
|
||||
token = await loginAndValidate(baseUrl, password, validateAbort.signal);
|
||||
await saveConfig({ token });
|
||||
invalidateCatalogCache();
|
||||
setStatus('OK (token saved, session)', 'ok');
|
||||
setConnExpanded(false);
|
||||
updateSendButtons();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
setStatus('Cancelled', 'warn');
|
||||
} else {
|
||||
token = '';
|
||||
await saveConfig({ token: '' });
|
||||
setStatus('Failed: ' + err.message, 'error');
|
||||
setConnExpanded(true);
|
||||
}
|
||||
updateSendButtons();
|
||||
} finally {
|
||||
validating = false;
|
||||
validateAbort = null;
|
||||
$('btn-validate').textContent = 'Validate';
|
||||
}
|
||||
}
|
||||
|
||||
function fillAgentSelect(sel, selected) {
|
||||
sel.innerHTML = '';
|
||||
for (const m of AGENT_MODES) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.label;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = selected || 'eino_single';
|
||||
}
|
||||
|
||||
function fillSelect(sel, items, value) {
|
||||
sel.innerHTML = '';
|
||||
for (const item of items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.id;
|
||||
opt.textContent = item.label;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
const has = items.some((i) => i.id === value);
|
||||
sel.value = has ? value : (items[0] && items[0].id) || '';
|
||||
}
|
||||
|
||||
async function openSendDialog(entryOverride) {
|
||||
const e = entryOverride || selectedEntry();
|
||||
if (!e || !token) return;
|
||||
const dlg = $('send-dialog');
|
||||
config = await loadConfig();
|
||||
const baseUrl = baseUrlFrom(config);
|
||||
|
||||
$('dlg-instruction').value = config.lastInstruction || defaultInstruction();
|
||||
fillAgentSelect($('dlg-agent'), config.lastAgentMode);
|
||||
$('dlg-project').innerHTML = '<option>加载中...</option>';
|
||||
$('dlg-role').innerHTML = '<option>加载中...</option>';
|
||||
dlg.showModal();
|
||||
|
||||
try {
|
||||
const { projects, roles } = await fetchCatalogCached(baseUrl, token);
|
||||
fillSelect($('dlg-project'), projects, config.lastProjectId);
|
||||
fillSelect($('dlg-role'), roles, config.lastRole);
|
||||
} catch (err) {
|
||||
fillSelect($('dlg-project'), [{ id: '', label: '(加载失败)' }], '');
|
||||
fillSelect($('dlg-role'), [{ id: '', label: '默认' }], '');
|
||||
}
|
||||
|
||||
dlg.dataset.entryId = e.id;
|
||||
}
|
||||
|
||||
async function onSendConfirmed() {
|
||||
const entryId = $('send-dialog').dataset.entryId;
|
||||
const e = entries.find((x) => x.id === entryId) || selectedEntry();
|
||||
if (!e || !token) return;
|
||||
|
||||
const instruction = $('dlg-instruction').value.trim() || defaultInstruction();
|
||||
const projectId = $('dlg-project').value || '';
|
||||
const role = $('dlg-role').value || '';
|
||||
const agentMode = $('dlg-agent').value || 'eino_single';
|
||||
|
||||
await saveConfig({
|
||||
lastInstruction: instruction,
|
||||
lastProjectId: projectId,
|
||||
lastRole: role,
|
||||
lastAgentMode: agentMode,
|
||||
});
|
||||
|
||||
const modeLabel = (AGENT_MODES.find((m) => m.id === agentMode) || {}).label || agentMode;
|
||||
const roleLabel = role || '默认';
|
||||
const run = createRun(e, modeLabel + ' · ' + roleLabel);
|
||||
activeRunId = run.id;
|
||||
prependRunItem(run);
|
||||
finalRenderedLen = 0;
|
||||
markdownRenderToken += 1;
|
||||
selectRun(run.id);
|
||||
|
||||
const baseUrl = baseUrlFrom(await loadConfig());
|
||||
const prompt = toPrompt(e, instruction);
|
||||
appendToRun(run.id, 'progress', `[*] ${e.title}\n[server] ${baseUrl}\n[mode] ${modeLabel}\n`);
|
||||
if (projectId) appendToRun(run.id, 'progress', `[project] ${projectId}\n`);
|
||||
appendToRun(run.id, 'progress', '\n');
|
||||
|
||||
try {
|
||||
await streamTest(
|
||||
baseUrl,
|
||||
token,
|
||||
{ message: prompt, role, projectId, agentMode },
|
||||
{
|
||||
setAbortController(ctrl) {
|
||||
activeAbort = ctrl;
|
||||
},
|
||||
onEvent(type, message, ev) {
|
||||
handleStreamEvent(run.id, type, message, ev);
|
||||
},
|
||||
onDone() {
|
||||
appendToRun(run.id, 'progress', '\n[done]\n');
|
||||
setRunStatus(run.id, 'done');
|
||||
activeAbort = null;
|
||||
activeRunId = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
appendToRun(run.id, 'progress', '\n[info] Local stream stopped.\n');
|
||||
setRunStatus(run.id, 'cancelled');
|
||||
} else {
|
||||
appendToRun(run.id, 'progress', '\n[error] ' + err.message + '\n');
|
||||
setRunStatus(run.id, 'error');
|
||||
}
|
||||
activeAbort = null;
|
||||
activeRunId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamEvent(runId, type, message, ev) {
|
||||
const debug = $('debug-events').checked;
|
||||
switch (type) {
|
||||
case 'response_start':
|
||||
appendToRun(runId, 'progress', '\n\n[主回复]\n');
|
||||
break;
|
||||
case 'response_delta':
|
||||
if (message) appendToRun(runId, 'final', message);
|
||||
break;
|
||||
case 'response':
|
||||
if (message) appendToRun(runId, 'final', message);
|
||||
break;
|
||||
case 'eino_agent_reply_stream_start':
|
||||
appendToRun(runId, 'progress', '\n\n[子代理回复]\n');
|
||||
break;
|
||||
case 'eino_agent_reply_stream_delta':
|
||||
if (message) appendToRun(runId, 'progress', message);
|
||||
break;
|
||||
case 'progress':
|
||||
appendToRun(runId, 'progress', '\n[progress] ' + message + '\n');
|
||||
setRunStatus(runId, 'running');
|
||||
break;
|
||||
case 'error':
|
||||
appendToRun(runId, 'progress', '\n[error] ' + message + '\n');
|
||||
setRunStatus(runId, 'error');
|
||||
break;
|
||||
case 'cancelled':
|
||||
appendToRun(runId, 'progress', '\n[cancelled] ' + message + '\n');
|
||||
setRunStatus(runId, 'cancelled');
|
||||
break;
|
||||
case 'conversation':
|
||||
case 'message_saved': {
|
||||
const cid = extractConversationId(ev);
|
||||
if (cid) setRunConversationId(runId, cid);
|
||||
if (type === 'conversation' && debug && message) {
|
||||
appendToRun(runId, 'progress', '\n[conversation] ' + message + '\n');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
case 'tool_result':
|
||||
case 'tool_result_delta':
|
||||
if (debug && message) appendToRun(runId, 'progress', '\n[' + type + '] ' + message + '\n');
|
||||
break;
|
||||
default:
|
||||
if (debug && message && !type.endsWith('_stream_delta') && !type.endsWith('_stream_start')) {
|
||||
appendToRun(runId, 'progress', '\n[' + type + '] ' + message + '\n');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function onStop() {
|
||||
const runId = activeRunId || selectedRunId;
|
||||
const run = runs.find((x) => x.id === runId);
|
||||
if (!run) return;
|
||||
|
||||
if (activeAbort) {
|
||||
activeAbort.abort();
|
||||
activeAbort = null;
|
||||
}
|
||||
|
||||
setRunStatus(run.id, 'cancelling');
|
||||
appendToRun(run.id, 'progress', '\n[info] Stop requested (local stream + server cancel).\n');
|
||||
|
||||
const cid = run.conversationId;
|
||||
if (!cid || !token) {
|
||||
if (!cid) {
|
||||
appendToRun(run.id, 'progress', '[info] conversationId not ready yet; only local stream was stopped.\n');
|
||||
}
|
||||
setRunStatus(run.id, 'cancelled');
|
||||
activeRunId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = baseUrlFrom(await loadConfig());
|
||||
try {
|
||||
await cancelByConversationId(baseUrl, token, cid);
|
||||
appendToRun(run.id, 'progress', '[info] Server cancel acknowledged.\n');
|
||||
setRunStatus(run.id, 'cancelled');
|
||||
} catch (err) {
|
||||
appendToRun(run.id, 'progress', '[error] Server cancel failed: ' + err.message + '\n');
|
||||
setRunStatus(run.id, 'cancelled');
|
||||
}
|
||||
activeRunId = null;
|
||||
}
|
||||
|
||||
function clearCurrentRunOutput() {
|
||||
pendingProgressRunId = null;
|
||||
pendingFinalRunId = null;
|
||||
outputFlushScheduled = false;
|
||||
finalRenderedLen = 0;
|
||||
markdownRenderToken += 1;
|
||||
const r = selectedRun();
|
||||
if (r) {
|
||||
r.progress = '';
|
||||
r.final = '';
|
||||
$('progress').textContent = '';
|
||||
refreshFinalView('');
|
||||
} else {
|
||||
$('progress').textContent = '';
|
||||
refreshFinalView('');
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllRuns() {
|
||||
runs = [];
|
||||
selectedRunId = null;
|
||||
pendingProgressRunId = null;
|
||||
pendingFinalRunId = null;
|
||||
outputFlushScheduled = false;
|
||||
finalRenderedLen = 0;
|
||||
markdownRenderToken += 1;
|
||||
renderRunList();
|
||||
$('progress').textContent = '';
|
||||
refreshFinalView('');
|
||||
}
|
||||
|
||||
function connectBackground() {
|
||||
connectRetryTimer = null;
|
||||
|
||||
if (!extensionAlive()) {
|
||||
showContextBanner(true);
|
||||
bgPort = null;
|
||||
return;
|
||||
}
|
||||
|
||||
showContextBanner(false);
|
||||
|
||||
try {
|
||||
if (!chrome.devtools || !chrome.devtools.inspectedWindow) {
|
||||
scheduleConnect(1000);
|
||||
return;
|
||||
}
|
||||
inspectedTabId = chrome.devtools.inspectedWindow.tabId;
|
||||
bgPort = chrome.runtime.connect({ name: 'cyberstrike-panel' });
|
||||
} catch (_) {
|
||||
if (!extensionAlive()) {
|
||||
showContextBanner(true);
|
||||
bgPort = null;
|
||||
return;
|
||||
}
|
||||
scheduleConnect(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
bgPort.postMessage({
|
||||
type: 'subscribe',
|
||||
tabId: inspectedTabId,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
});
|
||||
bgPort.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'list') {
|
||||
entries = msg.entries || [];
|
||||
renderRequestList();
|
||||
} else if (msg.type === 'entry') {
|
||||
entries.unshift(msg.entry);
|
||||
if (entries.length > CSAI_LIMITS.MAX_CAPTURED) entries.length = CSAI_LIMITS.MAX_CAPTURED;
|
||||
prependRequestEntry(msg.entry);
|
||||
}
|
||||
});
|
||||
bgPort.onDisconnect.addListener(() => {
|
||||
bgPort = null;
|
||||
if (!extensionAlive()) {
|
||||
showContextBanner(true);
|
||||
return;
|
||||
}
|
||||
scheduleConnect(1000);
|
||||
});
|
||||
}
|
||||
|
||||
async function onFilterApiChange() {
|
||||
await persistConnection();
|
||||
if (bgPort && inspectedTabId != null) {
|
||||
bgPort.postMessage({
|
||||
type: 'subscribe',
|
||||
tabId: inspectedTabId,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
});
|
||||
runtimeSendMessage({
|
||||
type: 'set-filter-api',
|
||||
tabId: inspectedTabId,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCaptures() {
|
||||
if (inspectedTabId == null) return;
|
||||
await runtimeSendMessage({ type: 'clear-captures', tabId: inspectedTabId });
|
||||
entries = [];
|
||||
selectedEntryId = null;
|
||||
renderRequestList();
|
||||
}
|
||||
|
||||
async function sendLatestXhr() {
|
||||
if (inspectedTabId == null || !token) return;
|
||||
const resp = await runtimeSendMessage({
|
||||
type: 'get-latest-api',
|
||||
tabId: inspectedTabId,
|
||||
});
|
||||
if (resp && resp.entry) {
|
||||
selectedEntryId = resp.entry.id;
|
||||
selectEntry(resp.entry.id);
|
||||
openSendDialog(resp.entry);
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(renderRequestList, 150);
|
||||
}
|
||||
|
||||
function onCopyOutput() {
|
||||
const activeTab = document.querySelector('.tab.active');
|
||||
const tabName = activeTab ? activeTab.dataset.tab : 'output';
|
||||
let text = '';
|
||||
if (tabName === 'request') text = $('view-request').textContent;
|
||||
else if (tabName === 'response') text = $('view-response').textContent;
|
||||
else {
|
||||
const r = selectedRun();
|
||||
text = (r && r.final) ? r.final : $('final-raw').textContent;
|
||||
}
|
||||
navigator.clipboard.writeText(text || '').catch(() => {});
|
||||
}
|
||||
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('.tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
|
||||
btn.classList.add('active');
|
||||
$('tab-' + btn.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$('btn-validate').addEventListener('click', onValidate);
|
||||
$('btn-conn-toggle').addEventListener('click', onConnToggle);
|
||||
$('host').addEventListener('input', updateConnSummary);
|
||||
$('port').addEventListener('input', updateConnSummary);
|
||||
$('https').addEventListener('change', updateConnSummary);
|
||||
$('btn-send').addEventListener('click', () => openSendDialog());
|
||||
$('btn-latest-xhr').addEventListener('click', sendLatestXhr);
|
||||
$('btn-stop').addEventListener('click', onStop);
|
||||
$('btn-copy').addEventListener('click', onCopyOutput);
|
||||
$('btn-clear').addEventListener('click', clearCurrentRunOutput);
|
||||
$('btn-clear-runs').addEventListener('click', clearAllRuns);
|
||||
$('btn-clear-captures').addEventListener('click', clearCaptures);
|
||||
$('search').addEventListener('input', onSearchInput);
|
||||
$('btn-capture-toggle').addEventListener('click', onCaptureToggle);
|
||||
$('filter-api').addEventListener('change', onFilterApiChange);
|
||||
$('render-md').addEventListener('change', () => {
|
||||
const r = selectedRun();
|
||||
const text = r ? r.final : $('final-raw').textContent;
|
||||
const streaming = r && r.status === 'running' && activeRunId === r.id;
|
||||
refreshFinalView(text, { streaming });
|
||||
persistConnection();
|
||||
});
|
||||
$('debug-events').addEventListener('change', persistConnection);
|
||||
$('send-form').addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
$('send-dialog').close();
|
||||
onSendConfirmed();
|
||||
});
|
||||
$('dlg-cancel').addEventListener('click', () => $('send-dialog').close());
|
||||
|
||||
setupTabs();
|
||||
initConfig().then(() => {
|
||||
if (extensionAlive()) connectBackground();
|
||||
else showContextBanner(true);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
body {
|
||||
width: 300px;
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font: 12px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #1a1d24;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.popup-header img {
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin: 2px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e2e5eb;
|
||||
background: #f8f9fb;
|
||||
color: #5c6470;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.conn-status::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #8b939e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conn-status--ok {
|
||||
color: #16a34a;
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
border-color: rgba(22, 163, 74, 0.25);
|
||||
}
|
||||
|
||||
.conn-status--ok::before { background: #16a34a; }
|
||||
|
||||
.conn-status--idle::before { background: #d97706; }
|
||||
|
||||
.hint {
|
||||
margin: 0 0 10px;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tips li { margin-bottom: 4px; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #22262e; color: #e8eaed; }
|
||||
.version, .tips { color: #9aa3af; }
|
||||
.hint { color: #d1d5db; }
|
||||
.conn-status {
|
||||
background: #2a2f38;
|
||||
border-color: #3a404c;
|
||||
color: #9aa3af;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="popup-header">
|
||||
<img src="../icons/icon48.png" alt="CyberStrikeAI" width="40" height="40">
|
||||
<div>
|
||||
<h1>CyberStrikeAI</h1>
|
||||
<p class="version" id="version"></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="conn-status" class="conn-status conn-status--idle">加载中…</div>
|
||||
|
||||
<p class="hint">主工作区在 DevTools 面板:按 <strong>F12</strong> → <strong>CyberStrikeAI</strong></p>
|
||||
|
||||
<ul class="tips">
|
||||
<li>连接配置与 Validate 请在 DevTools 面板完成</li>
|
||||
<li>默认只捕获 XHR/Fetch</li>
|
||||
<li>Test History 可回看多次发送</li>
|
||||
<li><strong>Latest XHR</strong> 快速发送最近 API 请求</li>
|
||||
</ul>
|
||||
|
||||
<script src="../lib/constants.js"></script>
|
||||
<script src="../lib/storage.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
chrome.action.setBadgeText({ text: '' });
|
||||
|
||||
async function renderPopup() {
|
||||
const verEl = document.getElementById('version');
|
||||
if (verEl && chrome.runtime && chrome.runtime.getManifest) {
|
||||
verEl.textContent = 'v' + chrome.runtime.getManifest().version;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('conn-status');
|
||||
if (!statusEl || typeof loadConfig !== 'function') return;
|
||||
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
const endpoint = baseUrlFrom(cfg);
|
||||
if (cfg.token) {
|
||||
statusEl.className = 'conn-status conn-status--ok';
|
||||
statusEl.textContent = `已连接 ${endpoint}`;
|
||||
} else {
|
||||
statusEl.className = 'conn-status conn-status--idle';
|
||||
statusEl.textContent = `未验证 · 目标 ${endpoint}`;
|
||||
}
|
||||
} catch (_) {
|
||||
statusEl.className = 'conn-status conn-status--idle';
|
||||
statusEl.textContent = '无法读取配置';
|
||||
}
|
||||
}
|
||||
|
||||
renderPopup();
|
||||
Reference in New Issue
Block a user