diff --git a/plugins/README.md b/plugins/README.md index a33e6f2a..c3896a05 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 | diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/README.md b/plugins/browser-extension/cyberstrikeai-browser-extension/README.md new file mode 100644 index 00000000..33b93a0e --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/README.md @@ -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 +``` diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md b/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md new file mode 100644 index 00000000..be6a652b --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md @@ -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 | +| 捕获开关 | — | ● 捕获中 / ○ 已暂停 | diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js b/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js new file mode 100644 index 00000000..3d39cb2b --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js @@ -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(); + } +}); diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html new file mode 100644 index 00000000..ad7821fb --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js new file mode 100644 index 00000000..9a5f909c --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js @@ -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', + () => {} +); diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip b/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip new file mode 100644 index 00000000..5c708c99 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png new file mode 100644 index 00000000..ab13d087 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png new file mode 100644 index 00000000..db614b67 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png new file mode 100644 index 00000000..945b6ac3 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js new file mode 100644 index 00000000..00766bde --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js @@ -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 }), + }); +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js new file mode 100644 index 00000000..156e0984 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js @@ -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(), + }; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js new file mode 100644 index 00000000..649e06be --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js @@ -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 }; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js new file mode 100644 index 00000000..70b1ffed --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js @@ -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渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口'; diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js new file mode 100644 index 00000000..25894b50 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js @@ -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 + ); +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js new file mode 100644 index 00000000..ae81176f --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js @@ -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; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js new file mode 100644 index 00000000..a07ec9af --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js @@ -0,0 +1,135 @@ +/** Minimal Markdown → HTML (aligned with Burp plugin renderer). */ + +function mdEscapeHtml(s) { + return String(s || '') + .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 += `${buf}`; + 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) + '' + s.slice(start + 2, end) + ''; + 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 = ``; + 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 += `
${mdEscapeHtml(codeBuf)}
`; + inCode = false; + } + continue; + } + if (inCode) { + codeBuf += line + '\n'; + continue; + } + const trimmed = line.trim(); + if (!trimmed) { + if (inList) { + out += ''; + inList = false; + } + out += "
"; + continue; + } + const h = mdHeadingLevel(trimmed); + if (h > 0) { + if (inList) { + out += ''; + inList = false; + } + out += `${mdInlineFormat(trimmed.slice(h).trim())}`; + continue; + } + if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { + if (!inList) { + out += ''; + inList = false; + } + out += `

${mdInlineFormat(trimmed)}

`; + } + if (inCode) out += `
${mdEscapeHtml(codeBuf)}
`; + if (inList) out += ''; + out += ''; + return out; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js new file mode 100644 index 00000000..2ba1cdcc --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js @@ -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 服务器地址'); + } +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json b/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json new file mode 100644 index 00000000..fbe2bfa3 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json @@ -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" + } +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh b/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh new file mode 100644 index 00000000..6282a4b3 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh @@ -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" diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css new file mode 100644 index 00000000..5b2d332a --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css @@ -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); +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html new file mode 100644 index 00000000..936d92c2 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html @@ -0,0 +1,143 @@ + + + + + + CyberStrikeAI + + + + +
+
+
+ + CyberStrikeAI +
+ + + Not validated +
+
+
+ + + + + +
+
+
+
+ + + + + + + + +
+
+ + + +
+
+
+ +
+ + +
+
+ + + +
+
+
+
+
Progress
+

+          
+
+
Final Response
+

+            
+          
+
+
+ + +
+
+ + +
+
+ +

发送到 CyberStrikeAI

+
+
+ + + +
+ +
+ + +
+
+
+ + + + + + + + + + + diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.js b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.js new file mode 100644 index 00000000..4ff7cb52 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.js @@ -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, '>'); +} + +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 = + `` + + `
` + + `
${escapeHtml(r.title)}
` + + `
${escapeHtml(r.meta)} · ${escapeHtml(r.status)}
` + + `
`; + 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 = + `` + + `
` + + `
${escapeHtml(r.title)}
` + + `
${escapeHtml(r.meta)} · ${escapeHtml(r.status)}
` + + `
`; + 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 = + `${escapeHtml(e.method)}` + + `${st || '—'}` + + `${escapeHtml(e.title || e.url)}`; + 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 = ''; + $('dlg-role').innerHTML = ''; + 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); +}); diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.css b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.css new file mode 100644 index 00000000..f35f4de1 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.css @@ -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; + } +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.html b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.html new file mode 100644 index 00000000..77167ce0 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.html @@ -0,0 +1,31 @@ + + + + + + + + + +
加载中…
+ +

主工作区在 DevTools 面板:按 F12CyberStrikeAI

+ + + + + + + + diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.js b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.js new file mode 100644 index 00000000..28b76cee --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/popup/popup.js @@ -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();