Add files via upload

This commit is contained in:
公明
2026-07-08 13:51:05 +08:00
committed by GitHub
parent b4b21f57fe
commit a5d26db5c0
26 changed files with 3398 additions and 6 deletions
+22 -6
View File
@@ -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 / EdgeChromiumDevTools 扩展:在开发者工具中捕获 **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 |
| 捕获开关 | — | ● 捕获中 / ○ 已暂停 |
@@ -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',
() => {}
);
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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();