diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 7d560dd90..88d96e25e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,39 +1,47 @@
-## ProxyCast v0.73.0
+# Release v0.74.0
-发布日期:2026-02-27
+## 🎉 重大功能
-### ✨ 新功能
+### Chrome Bridge - 浏览器自动化集成
-#### 记忆管理系统
-- 新增多层记忆架构:支持组织策略、项目记忆、用户记忆、项目本地记忆四层配置
-- 新增记忆画像(MemoryProfile):可配置学习状态、擅长领域、解释风格、难题偏好
-- 新增记忆设置页面(settings-v2/general/memory),支持记忆来源、自动记忆、画像等配置
-- 新增记忆层级指标统计(memoryLayerMetrics),量化各层记忆贡献
-- 新增 memory profile prompt 服务,将记忆画像自动合并到系统提示词
+实现了完整的 Chrome 浏览器自动化控制系统,AI 可以在对话中直接操作浏览器。
-#### Agent 增强
-- Agent 支持上下文准备轨迹(ContextTrace)事件,前端可展示上下文注入过程
-- 新增 instruction discovery 模块,自动发现项目级指令文件
-- 新增 shell security 和 tool permissions 模块
-- 新增 hooks 模块,支持 Agent 生命周期钩子
-- SessionConfigBuilder 支持 include_context_trace 配置
+#### 核心特性
-#### 技能与处理器
-- 新增 skill matcher 模块,优化技能匹配逻辑
-- 新增 processor steps registry,统一步骤注册管理
+- **零配置自动连接**:打开 Chrome Profile 时自动加载扩展并配置连接
+- **双通道架构**:Observer 通道(页面监控)+ Control 通道(命令控制)
+- **AI 原生集成**:作为 MCP 工具集成到 Aster Agent,支持自然语言控制
+- **多 Profile 支持**:可同时管理多个独立的 Chrome Profile
-#### 渠道管理
-- 新增 ChannelsConfig 配置类型与渠道管理 UI 组件
+#### 支持的操作
-### 🐛 修复
-- 修复 workspace_mismatch 错误:会话切换 workspace 时自动更新 working_dir,不再阻断用户操作
-- 修复前端 lint 错误:清理未使用的导入和不必要的 try/catch 包装
-- 修复 Config 测试中缺少 channels 字段导致编译失败的问题
+- **导航**:打开 URL、刷新、前进、后退
+- **页面读取**:获取页面内容(Markdown 格式)、标题、URL
+- **元素交互**:点击、输入文本、滚动
+- **表单操作**:批量填写表单字段
+- **标签页管理**:获取标签页列表、切换标签页
-### 🔧 优化与重构
-- 优化 unified memory API 和前端调用
-- 移除废弃的 external-tools 设置页面
+#### 使用示例
-### 📦 技术细节
-- 54 个文件变更,+2279 行,-410 行
-- 新增 10 个文件,涵盖记忆管理、Agent 安全、技能匹配等模块
+用户:帮我在 Google 上搜索 "Rust"
+AI 自动执行:打开 Google → 输入搜索词 → 点击搜索 → 读取结果 → 总结
+
+## 🐛 Bug 修复
+
+- WebSocket 路由修复:从 `/Proxycast_Key={key}` 改为 `/:key`
+- Chrome 扩展存储清理:删除旧配置缓存
+- 扩展重复注入防护:使用 IIFE 包装
+- 剪贴板权限:添加 `clipboardRead` 权限
+
+## 🔧 代码质量改进
+
+- 修复 33+ Clippy 警告
+- 所有 259 个测试通过
+- ESLint 无警告
+
+## 📝 文档
+
+新增:
+- `CHROME_BRIDGE_AI_USAGE.md` - AI 使用指南
+- `CHROME_BRIDGE_QUICKSTART.md` - 快速参考
+- `CHROME_BRIDGE_USAGE.md` - API 文档
diff --git a/extensions/proxycast-chrome/README.md b/extensions/proxycast-chrome/README.md
new file mode 100644
index 000000000..85e521068
--- /dev/null
+++ b/extensions/proxycast-chrome/README.md
@@ -0,0 +1,71 @@
+# Proxycast Chrome Bridge 扩展
+
+用于把 Chrome 页面能力接入 Proxycast 的浏览器桥接通道,供各业务 AI Agent 通过统一 `browser_execute_action` / MCP 浏览器工具调用。
+
+## 功能
+
+- Observer 通道自动连接:`/proxycast-chrome-observer/Proxycast_Key=...`
+- 页面信息上报:标题、URL、Markdown
+- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `go_back` 等
+- 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取
+
+## 安装
+
+1. 打开 Chrome `chrome://extensions`
+2. 打开右上角「开发者模式」
+3. 点击「加载已解压的扩展程序」
+4. 选择目录:`extensions/proxycast-chrome`
+
+## 配置
+
+点击扩展图标打开弹窗,配置:
+
+- `Server URL`:Proxycast 服务地址,例如 `ws://127.0.0.1:8999`
+- `Bridge Key`:Proxycast 服务 API Key(与后端 `Proxycast_Key` 一致)
+- `Profile Key`:浏览器会话隔离键(建议与业务场景对应,如 `research_a`)
+
+点击「保存并重连」后,扩展会建立 observer WebSocket 连接。
+
+## 验证
+
+1. 在 Proxycast 设置中查看 `get_chrome_bridge_status`,`observer_count` 应大于 0
+2. 调用 `browser_execute_action`:
+
+```json
+{
+ "profile_key": "default",
+ "action": "navigate",
+ "args": { "url": "https://example.com" }
+}
+```
+
+3. 再调用 `browser_execute_action`:
+
+```json
+{
+ "action": "read_page"
+}
+```
+
+如果返回 `success=true` 且 `data.markdown` 有内容,说明链路可用。
+
+## 自动化联调脚本
+
+仓库提供了桥接链路的端到端联调脚本(模拟 observer/control 双端):
+
+```bash
+npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default
+```
+
+脚本会验证:
+
+- observer/control 握手
+- 双向心跳 ack
+- `wait_for_page_info=true` 命令链路(`command_result` + `page_info_update`)
+- 普通命令链路(`command_result`)
+
+## 兼容说明
+
+- 扩展只负责浏览器侧采集与动作执行。
+- Agent 侧通过 `aster_agent_cmd` 与 `unified_chat_cmd` 注册的浏览器 MCP 兼容工具访问。
+- 若你同时使用独立 Chrome Profile(Tauri `open_chrome_profile_window`),请在对应 Profile 内安装该扩展,并使用不同 `profileKey` 做隔离。
diff --git a/extensions/proxycast-chrome/background.js b/extensions/proxycast-chrome/background.js
new file mode 100644
index 000000000..12e37461f
--- /dev/null
+++ b/extensions/proxycast-chrome/background.js
@@ -0,0 +1,667 @@
+const HEARTBEAT_INTERVAL_MS = 30000;
+const RECONNECT_MIN_DELAY_MS = 1000;
+const RECONNECT_MAX_DELAY_MS = 30000;
+const PAGE_CAPTURE_RETRY_LIMIT = 3;
+
+const DEFAULT_SETTINGS = {
+ serverUrl: "ws://127.0.0.1:8999",
+ bridgeKey: "",
+ profileKey: "default",
+ monitoringEnabled: true,
+};
+
+let ws = null;
+let isConnected = false;
+let reconnectAttempts = 0;
+let reconnectTimer = null;
+let heartbeatTimer = null;
+let activeTabId = null;
+let monitoringEnabled = true;
+let latestPageInfo = null;
+let lastSettings = { ...DEFAULT_SETTINGS };
+
+function logInfo(message, payload) {
+ if (payload === undefined) {
+ console.log(`[ProxycastBridge] ${message}`);
+ } else {
+ console.log(`[ProxycastBridge] ${message}`, payload);
+ }
+}
+
+function logWarn(message, payload) {
+ if (payload === undefined) {
+ console.warn(`[ProxycastBridge] ${message}`);
+ } else {
+ console.warn(`[ProxycastBridge] ${message}`, payload);
+ }
+}
+
+function readSettings() {
+ return new Promise((resolve) => {
+ chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
+ resolve({ ...DEFAULT_SETTINGS, ...stored });
+ });
+ });
+}
+
+function writeSettings(partial) {
+ return new Promise((resolve) => {
+ chrome.storage.local.set(partial, () => resolve());
+ });
+}
+
+function buildObserverUrl(settings) {
+ const serverUrl = String(settings.serverUrl || "").trim();
+ const bridgeKey = String(settings.bridgeKey || "").trim();
+ if (!serverUrl || !bridgeKey) {
+ return null;
+ }
+
+ const normalized = serverUrl.replace(/\/$/, "");
+ const profileKey = encodeURIComponent(settings.profileKey || "default");
+ return `${normalized}/proxycast-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`;
+}
+
+async function connectObserver(forceReconnect = false) {
+ if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) {
+ return;
+ }
+
+ clearReconnectTimer();
+ clearHeartbeatTimer();
+
+ const settings = await readSettings();
+ lastSettings = settings;
+ monitoringEnabled = Boolean(settings.monitoringEnabled);
+
+ const url = buildObserverUrl(settings);
+ if (!url) {
+ logWarn("缺少 serverUrl 或 bridgeKey,无法建立连接");
+ setConnectionState(false);
+ broadcastStatus();
+ return;
+ }
+
+ if (forceReconnect && ws) {
+ try {
+ ws.close();
+ } catch (_) {}
+ }
+
+ logInfo(`连接 observer: ${url}`);
+ ws = new WebSocket(url);
+
+ ws.onopen = () => {
+ reconnectAttempts = 0;
+ setConnectionState(true);
+ startHeartbeat();
+ broadcastStatus();
+ triggerPageCapture("ws_open");
+ };
+
+ ws.onmessage = async (event) => {
+ try {
+ const payload = JSON.parse(event.data);
+ await handleObserverMessage(payload);
+ } catch (error) {
+ logWarn("解析消息失败", error?.message || String(error));
+ }
+ };
+
+ ws.onclose = () => {
+ setConnectionState(false);
+ clearHeartbeatTimer();
+ scheduleReconnect();
+ broadcastStatus();
+ };
+
+ ws.onerror = (error) => {
+ logWarn("WebSocket 错误", error?.message || error);
+ };
+}
+
+function disconnectObserver(manual = true) {
+ clearReconnectTimer();
+ clearHeartbeatTimer();
+ if (ws) {
+ try {
+ ws.close();
+ } catch (_) {}
+ }
+ if (manual) {
+ setConnectionState(false);
+ broadcastStatus();
+ }
+}
+
+function setConnectionState(connected) {
+ isConnected = connected;
+ chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" });
+ chrome.action.setBadgeBackgroundColor({ color: connected ? "#16a34a" : "#dc2626" });
+}
+
+function startHeartbeat() {
+ clearHeartbeatTimer();
+ heartbeatTimer = setInterval(() => {
+ sendObserverMessage({ type: "heartbeat", timestamp: Date.now() });
+ }, HEARTBEAT_INTERVAL_MS);
+}
+
+function clearHeartbeatTimer() {
+ if (heartbeatTimer) {
+ clearInterval(heartbeatTimer);
+ heartbeatTimer = null;
+ }
+}
+
+function scheduleReconnect() {
+ if (reconnectTimer) {
+ return;
+ }
+ reconnectAttempts += 1;
+ const delay = Math.min(
+ RECONNECT_MAX_DELAY_MS,
+ RECONNECT_MIN_DELAY_MS * Math.pow(2, reconnectAttempts - 1),
+ );
+ reconnectTimer = setTimeout(async () => {
+ reconnectTimer = null;
+ await connectObserver();
+ }, delay);
+ logInfo(`连接断开,${delay}ms 后重连(第 ${reconnectAttempts} 次)`);
+}
+
+function clearReconnectTimer() {
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+}
+
+function sendObserverMessage(payload) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ return false;
+ }
+ ws.send(JSON.stringify(payload));
+ return true;
+}
+
+function broadcastStatus(extra) {
+ chrome.runtime
+ .sendMessage({
+ type: "STATUS_UPDATE",
+ data: {
+ isConnected,
+ monitoringEnabled,
+ activeTabId,
+ latestPageInfo,
+ settings: {
+ ...lastSettings,
+ bridgeKey: lastSettings.bridgeKey ? "***" : "",
+ },
+ ...extra,
+ },
+ })
+ .catch(() => {});
+}
+
+async function handleObserverMessage(payload) {
+ const type = payload?.type;
+ if (type === "heartbeat_ack" || type === "connection_ack") {
+ return;
+ }
+ if (type !== "command" || !payload.data) {
+ return;
+ }
+ await executeRemoteCommand(payload.data);
+}
+
+async function executeRemoteCommand(commandData) {
+ const command = String(commandData.command || "").trim();
+ if (!command) {
+ return;
+ }
+
+ const requestId = commandData.requestId;
+ const sourceClientId = commandData.sourceClientId;
+ const waitForPageInfo = commandData.wait_for_page_info === true;
+
+ if (command === "open_url") {
+ await handleOpenUrl(commandData, waitForPageInfo);
+ return;
+ }
+
+ if (command === "switch_tab") {
+ await handleSwitchTab(commandData, waitForPageInfo);
+ return;
+ }
+
+ const tabId = await resolveTargetTabId();
+ if (!tabId) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: "没有可用的活动标签页",
+ });
+ return;
+ }
+
+ try {
+ const response = await sendCommandToTab(tabId, {
+ type: "EXECUTE_COMMAND",
+ data: commandData,
+ });
+
+ if (response?.status === "error") {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: response.error || "命令执行失败",
+ });
+ return;
+ }
+
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "success",
+ message: response?.message || `${command} 执行成功`,
+ });
+
+ if (waitForPageInfo || command === "get_page_info") {
+ await triggerPageCapture("command_result");
+ }
+ } catch (error) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: error?.message || String(error),
+ });
+ }
+}
+
+async function handleOpenUrl(commandData, waitForPageInfo) {
+ const requestId = commandData.requestId;
+ const sourceClientId = commandData.sourceClientId;
+ let targetUrl = String(commandData.url || "").trim();
+ if (!targetUrl) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: "open_url 缺少 url 参数",
+ });
+ return;
+ }
+ if (!/^https?:\/\//i.test(targetUrl)) {
+ targetUrl = `https://${targetUrl}`;
+ }
+
+ try {
+ const tab = await new Promise((resolve, reject) => {
+ chrome.tabs.create({ url: targetUrl, active: true }, (created) => {
+ if (chrome.runtime.lastError) {
+ reject(new Error(chrome.runtime.lastError.message));
+ return;
+ }
+ resolve(created);
+ });
+ });
+
+ activeTabId = tab.id;
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "success",
+ message: `已打开 ${targetUrl}`,
+ });
+
+ if (waitForPageInfo) {
+ await waitTabLoadComplete(tab.id, 30000);
+ await triggerPageCapture("open_url_complete");
+ }
+ } catch (error) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: error?.message || String(error),
+ });
+ }
+}
+
+async function handleSwitchTab(commandData, waitForPageInfo) {
+ const requestId = commandData.requestId;
+ const sourceClientId = commandData.sourceClientId;
+ const raw = String(commandData.target || "").trim();
+ if (!raw) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: "switch_tab 缺少 target 参数",
+ });
+ return;
+ }
+
+ let targetTab = null;
+ const byId = Number(raw);
+ if (!Number.isNaN(byId) && byId > 0) {
+ try {
+ targetTab = await chrome.tabs.get(byId);
+ } catch (_) {
+ targetTab = null;
+ }
+ }
+
+ if (!targetTab) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const idx = Number(raw);
+ if (!Number.isNaN(idx) && idx >= 0 && idx < tabs.length) {
+ targetTab = tabs[idx];
+ }
+ }
+
+ if (!targetTab || !targetTab.id) {
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "error",
+ error: `未找到标签页: ${raw}`,
+ });
+ return;
+ }
+
+ await chrome.tabs.update(targetTab.id, { active: true });
+ activeTabId = targetTab.id;
+
+ sendCommandResult({
+ requestId,
+ sourceClientId,
+ status: "success",
+ message: `已切换到标签页 ${targetTab.id}`,
+ });
+
+ if (waitForPageInfo) {
+ await triggerPageCapture("switch_tab");
+ }
+}
+
+function sendCommandResult(data) {
+ sendObserverMessage({
+ type: "command_result",
+ data,
+ });
+}
+
+async function resolveTargetTabId() {
+ if (activeTabId) {
+ try {
+ const tab = await chrome.tabs.get(activeTabId);
+ if (tab && !tab.discarded) {
+ return tab.id;
+ }
+ } catch (_) {}
+ }
+
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ if (tabs[0]?.id) {
+ activeTabId = tabs[0].id;
+ return tabs[0].id;
+ }
+ return null;
+}
+
+async function sendCommandToTab(tabId, payload) {
+ try {
+ return await chrome.tabs.sendMessage(tabId, payload);
+ } catch (error) {
+ await injectContentScript(tabId);
+ return await chrome.tabs.sendMessage(tabId, payload);
+ }
+}
+
+async function injectContentScript(tabId) {
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ files: ["content_script.js"],
+ });
+}
+
+async function triggerPageCapture(reason, retry = 0) {
+ if (!monitoringEnabled && reason !== "manual") {
+ return;
+ }
+
+ const tabId = await resolveTargetTabId();
+ if (!tabId) {
+ return;
+ }
+
+ try {
+ await sendCommandToTab(tabId, {
+ type: "REQUEST_PAGE_CAPTURE",
+ data: { reason },
+ });
+ } catch (error) {
+ if (retry < PAGE_CAPTURE_RETRY_LIMIT) {
+ setTimeout(() => {
+ triggerPageCapture(reason, retry + 1);
+ }, 250 * (retry + 1));
+ } else {
+ logWarn("页面抓取请求失败", error?.message || String(error));
+ }
+ }
+}
+
+function waitTabLoadComplete(tabId, timeoutMs) {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const timer = setTimeout(() => {
+ if (resolved) {
+ return;
+ }
+ resolved = true;
+ chrome.tabs.onUpdated.removeListener(onUpdated);
+ resolve();
+ }, timeoutMs);
+
+ const onUpdated = (updatedTabId, changeInfo) => {
+ if (updatedTabId !== tabId || changeInfo.status !== "complete") {
+ return;
+ }
+ if (resolved) {
+ return;
+ }
+ resolved = true;
+ clearTimeout(timer);
+ chrome.tabs.onUpdated.removeListener(onUpdated);
+ resolve();
+ };
+
+ chrome.tabs.onUpdated.addListener(onUpdated);
+ });
+}
+
+chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
+ const type = request?.type;
+
+ if (type === "GET_STATUS") {
+ sendResponse({
+ isConnected,
+ monitoringEnabled,
+ activeTabId,
+ latestPageInfo,
+ settings: {
+ ...lastSettings,
+ bridgeKey: lastSettings.bridgeKey ? "***" : "",
+ },
+ });
+ return true;
+ }
+
+ if (type === "UPDATE_SETTINGS") {
+ const patch = request?.data || {};
+ const next = {
+ serverUrl: typeof patch.serverUrl === "string" ? patch.serverUrl : lastSettings.serverUrl,
+ bridgeKey: typeof patch.bridgeKey === "string" ? patch.bridgeKey : lastSettings.bridgeKey,
+ profileKey: typeof patch.profileKey === "string" ? patch.profileKey : lastSettings.profileKey,
+ monitoringEnabled:
+ typeof patch.monitoringEnabled === "boolean"
+ ? patch.monitoringEnabled
+ : monitoringEnabled,
+ };
+
+ writeSettings(next).then(async () => {
+ lastSettings = { ...lastSettings, ...next };
+ monitoringEnabled = Boolean(next.monitoringEnabled);
+ if (request?.data?.reconnect === true) {
+ await connectObserver(true);
+ }
+ broadcastStatus();
+ sendResponse({ success: true });
+ });
+ return true;
+ }
+
+ if (type === "TOGGLE_CONNECTION") {
+ if (isConnected) {
+ disconnectObserver(true);
+ sendResponse({ success: true, isConnected: false });
+ } else {
+ connectObserver().then(() => {
+ sendResponse({ success: true, isConnected: isConnected });
+ });
+ }
+ return true;
+ }
+
+ if (type === "TOGGLE_MONITORING") {
+ monitoringEnabled = !monitoringEnabled;
+ writeSettings({ monitoringEnabled }).then(() => {
+ if (monitoringEnabled) {
+ triggerPageCapture("manual");
+ }
+ broadcastStatus();
+ sendResponse({ success: true, monitoringEnabled });
+ });
+ return true;
+ }
+
+ if (type === "REQUEST_PAGE_CAPTURE") {
+ triggerPageCapture("manual").then(() => {
+ sendResponse({ success: true });
+ });
+ return true;
+ }
+
+ if (type === "PAGE_INFO_UPDATE") {
+ const senderTabId = sender?.tab?.id;
+ if (senderTabId && activeTabId && senderTabId !== activeTabId) {
+ return true;
+ }
+
+ const markdown = request?.data?.markdown;
+ if (typeof markdown !== "string" || !markdown.trim()) {
+ return true;
+ }
+
+ latestPageInfo = {
+ title: request?.data?.title || "",
+ url: request?.data?.url || "",
+ timestamp: Date.now(),
+ markdown,
+ };
+
+ chrome.storage.local.set({ latestPageInfo });
+ sendObserverMessage({
+ type: "pageInfoUpdate",
+ data: { markdown },
+ });
+ broadcastStatus({ latestPageInfo });
+ return true;
+ }
+
+ if (type === "COMMAND_RESULT") {
+ if (request?.data) {
+ sendCommandResult(request.data);
+ }
+ return true;
+ }
+
+ return true;
+});
+
+chrome.tabs.onActivated.addListener(async ({ tabId }) => {
+ activeTabId = tabId;
+ await triggerPageCapture("tab_activated");
+ broadcastStatus();
+});
+
+chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+ if (tab.active) {
+ activeTabId = tabId;
+ }
+ if (tab.active && changeInfo.status === "complete") {
+ await triggerPageCapture("tab_updated");
+ }
+});
+
+async function loadAutoConfig() {
+ try {
+ const configUrl = chrome.runtime.getURL("auto_config.json");
+ logInfo(`尝试加载自动配置: ${configUrl}`);
+ const response = await fetch(configUrl);
+ logInfo(`fetch 响应状态: ${response.status}`);
+ if (!response.ok) {
+ logWarn(`自动配置文件不存在或无法访问: ${response.status}`);
+ return;
+ }
+ const config = await response.json();
+ logInfo("成功读取自动配置", config);
+ if (config.serverUrl && config.bridgeKey) {
+ logInfo("检测到自动配置,正在应用...", config);
+ await writeSettings({
+ serverUrl: config.serverUrl,
+ bridgeKey: config.bridgeKey,
+ profileKey: config.profileKey || "default",
+ monitoringEnabled: config.monitoringEnabled !== false,
+ });
+ logInfo("自动配置已应用");
+ } else {
+ logWarn("自动配置缺少必要字段", config);
+ }
+ } catch (error) {
+ // 文件不存在或解析失败时记录错误
+ logWarn("加载自动配置失败", error?.message || String(error));
+ }
+}
+
+async function init() {
+ await loadAutoConfig();
+
+ const settings = await readSettings();
+ lastSettings = settings;
+ monitoringEnabled = Boolean(settings.monitoringEnabled);
+
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ activeTabId = tabs[0]?.id || null;
+
+ if (settings.serverUrl && settings.bridgeKey) {
+ await connectObserver();
+ } else {
+ setConnectionState(false);
+ broadcastStatus();
+ }
+
+ chrome.storage.local.get(["latestPageInfo"], (stored) => {
+ if (stored.latestPageInfo) {
+ latestPageInfo = stored.latestPageInfo;
+ broadcastStatus();
+ }
+ });
+}
+
+init();
diff --git a/extensions/proxycast-chrome/content_script.js b/extensions/proxycast-chrome/content_script.js
new file mode 100644
index 000000000..70d5a82df
--- /dev/null
+++ b/extensions/proxycast-chrome/content_script.js
@@ -0,0 +1,300 @@
+// 使用 IIFE 避免重复注入时的变量冲突
+(function () {
+ // 检查是否已经注入过
+ if (window.__PROXYCAST_CONTENT_SCRIPT_LOADED__) {
+ return;
+ }
+ window.__PROXYCAST_CONTENT_SCRIPT_LOADED__ = true;
+
+let refCounter = 0;
+const REF_ATTR = "proxycast-id";
+
+function nextRefId() {
+ refCounter += 1;
+ return `proxycast-${refCounter}`;
+}
+
+function resetRefs() {
+ refCounter = 0;
+ document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => {
+ el.removeAttribute(REF_ATTR);
+ });
+}
+
+function normalizeText(value) {
+ return String(value || "").replace(/\s+/g, " ").trim();
+}
+
+function isElementVisible(element) {
+ if (!element) {
+ return false;
+ }
+ const style = window.getComputedStyle(element);
+ if (
+ style.display === "none" ||
+ style.visibility === "hidden" ||
+ style.opacity === "0"
+ ) {
+ return false;
+ }
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+}
+
+function isInteractiveElement(element) {
+ if (!element || !isElementVisible(element)) {
+ return false;
+ }
+
+ const tag = element.tagName.toLowerCase();
+ const role = element.getAttribute("role");
+ if (["a", "button", "input", "textarea", "select", "option"].includes(tag)) {
+ return true;
+ }
+ if (
+ role &&
+ [
+ "button",
+ "link",
+ "checkbox",
+ "radio",
+ "menuitem",
+ "tab",
+ "switch",
+ "option",
+ "searchbox",
+ "textbox",
+ "combobox",
+ ].includes(role)
+ ) {
+ return true;
+ }
+
+ if (element.hasAttribute("onclick")) {
+ return true;
+ }
+
+ const style = window.getComputedStyle(element);
+ return style.cursor === "pointer";
+}
+
+function interactiveLabel(element) {
+ const tag = element.tagName.toLowerCase();
+ const role = element.getAttribute("role");
+ const text = normalizeText(
+ element.innerText ||
+ element.value ||
+ element.placeholder ||
+ element.getAttribute("aria-label") ||
+ element.title ||
+ element.name ||
+ element.id,
+ );
+
+ if (tag === "a") {
+ return `链接: ${text || "无标题链接"}`;
+ }
+ if (tag === "button" || role === "button") {
+ return `按钮: ${text || "无标题按钮"}`;
+ }
+ if (tag === "input" || tag === "textarea") {
+ return `输入框: ${text || "未命名输入框"}`;
+ }
+ if (tag === "select") {
+ return `下拉框: ${text || "未命名下拉框"}`;
+ }
+ return `可交互元素: ${text || tag}`;
+}
+
+function buildMarkdown() {
+ resetRefs();
+
+ const lines = [];
+ lines.push(`# ${document.title || "Untitled"}`);
+ lines.push(`URL: ${window.location.href}`);
+ lines.push("");
+
+ const bodyText = normalizeText(document.body ? document.body.innerText : "");
+ if (bodyText) {
+ lines.push("## 页面文本");
+ lines.push(bodyText.slice(0, 6000));
+ lines.push("");
+ }
+
+ lines.push("## 可交互元素");
+ const allElements = Array.from(document.querySelectorAll("*")).filter(isInteractiveElement);
+
+ for (const element of allElements.slice(0, 300)) {
+ const refId = nextRefId();
+ element.setAttribute(REF_ATTR, refId);
+ lines.push(`- [${interactiveLabel(element)}](${refId})`);
+ }
+
+ return lines.join("\n").trim();
+}
+
+function findElement(target) {
+ if (!target || typeof target !== "string") {
+ return null;
+ }
+ const trimmed = target.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ let element = document.querySelector(`[${REF_ATTR}="${CSS.escape(trimmed)}"]`);
+ if (element) {
+ return element;
+ }
+
+ try {
+ element = document.querySelector(trimmed);
+ if (element) {
+ return element;
+ }
+ } catch (_) {}
+
+ element = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button']")).find(
+ (candidate) => normalizeText(candidate.innerText || candidate.value || candidate.placeholder || "") === trimmed,
+ );
+ if (element) {
+ return element;
+ }
+
+ element = Array.from(document.querySelectorAll("[aria-label]")).find(
+ (candidate) => normalizeText(candidate.getAttribute("aria-label")) === trimmed,
+ );
+ return element || null;
+}
+
+function scrollPage(text) {
+ const input = normalizeText(text);
+ let direction = "down";
+ let amount = 500;
+
+ if (input.includes(":")) {
+ const parts = input.split(":");
+ direction = normalizeText(parts[0]) || "down";
+ const parsed = Number(parts[1]);
+ if (!Number.isNaN(parsed) && parsed > 0) {
+ amount = parsed;
+ }
+ }
+
+ if (direction === "up") {
+ window.scrollBy(0, -amount);
+ } else if (direction === "left") {
+ window.scrollBy(-amount, 0);
+ } else if (direction === "right") {
+ window.scrollBy(amount, 0);
+ } else {
+ window.scrollBy(0, amount);
+ }
+}
+
+async function executeCommand(commandData) {
+ const command = String(commandData.command || "").trim();
+ const target = commandData.target;
+ const text = commandData.text;
+
+ switch (command) {
+ case "click": {
+ const element = findElement(target);
+ if (!element) {
+ return { status: "error", error: `未找到点击目标: ${target}` };
+ }
+ element.click();
+ return { status: "success", message: "click 执行成功" };
+ }
+ case "type": {
+ const element = findElement(target);
+ if (!element) {
+ return { status: "error", error: `未找到输入目标: ${target}` };
+ }
+ const value = text == null ? "" : String(text);
+ if ("value" in element) {
+ element.focus();
+ element.value = value;
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+ } else {
+ element.textContent = value;
+ }
+ return { status: "success", message: "type 执行成功" };
+ }
+ case "scroll":
+ case "scroll_page": {
+ scrollPage(text);
+ return { status: "success", message: "scroll 执行成功" };
+ }
+ case "get_page_info": {
+ await sendPageInfo("get_page_info");
+ return { status: "success", message: "页面信息已回传" };
+ }
+ case "refresh_page": {
+ window.location.reload();
+ return { status: "success", message: "页面刷新中" };
+ }
+ case "go_back": {
+ window.history.back();
+ return { status: "success", message: "执行后退" };
+ }
+ case "go_forward": {
+ window.history.forward();
+ return { status: "success", message: "执行前进" };
+ }
+ default:
+ return { status: "error", error: `不支持的命令: ${command}` };
+ }
+}
+
+async function sendPageInfo(reason) {
+ const markdown = buildMarkdown();
+ const payload = {
+ type: "PAGE_INFO_UPDATE",
+ data: {
+ reason,
+ title: document.title || "",
+ url: window.location.href,
+ markdown,
+ },
+ };
+ await chrome.runtime.sendMessage(payload);
+}
+
+chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
+ const type = request?.type;
+
+ if (type === "REQUEST_PAGE_CAPTURE") {
+ sendPageInfo(request?.data?.reason || "manual")
+ .then(() => sendResponse({ status: "success" }))
+ .catch((error) =>
+ sendResponse({ status: "error", error: error?.message || String(error) }),
+ );
+ return true;
+ }
+
+ if (type === "EXECUTE_COMMAND") {
+ executeCommand(request?.data || {})
+ .then(async (result) => {
+ if (request?.data?.wait_for_page_info === true) {
+ setTimeout(() => {
+ sendPageInfo("wait_for_page_info");
+ }, 400);
+ }
+ sendResponse(result);
+ })
+ .catch((error) =>
+ sendResponse({ status: "error", error: error?.message || String(error) }),
+ );
+ return true;
+ }
+
+ return true;
+});
+
+setTimeout(() => {
+ sendPageInfo("content_script_ready").catch(() => {});
+}, 800);
+
+})(); // 结束 IIFE
diff --git a/extensions/proxycast-chrome/manifest.json b/extensions/proxycast-chrome/manifest.json
new file mode 100644
index 000000000..c5d6beadb
--- /dev/null
+++ b/extensions/proxycast-chrome/manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifest_version": 3,
+ "name": "Proxycast Browser Bridge",
+ "description": "Connect Chrome tabs with Proxycast browser runtime for AI agents.",
+ "version": "0.1.0",
+ "permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"],
+ "host_permissions": ["http://*/*", "https://*/*"],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "action": {
+ "default_title": "Proxycast Browser Bridge",
+ "default_popup": "popup.html"
+ },
+ "content_scripts": [
+ {
+ "matches": ["http://*/*", "https://*/*"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle"
+ }
+ ]
+}
diff --git a/extensions/proxycast-chrome/popup.html b/extensions/proxycast-chrome/popup.html
new file mode 100644
index 000000000..a6cb43051
--- /dev/null
+++ b/extensions/proxycast-chrome/popup.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+ Proxycast Browser Bridge
+
+
+
+
+
+
+ Observer 连接:
+ 未连接
+
+
+
+ 页面监控:
+ 关闭
+
+
+
+
🚀 一键配置
+
从剪贴板粘贴配置(在 ProxyCast 页面点击"复制配置")
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Observer URL: 未配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/proxycast-chrome/popup.js b/extensions/proxycast-chrome/popup.js
new file mode 100644
index 000000000..9305e1ee2
--- /dev/null
+++ b/extensions/proxycast-chrome/popup.js
@@ -0,0 +1,249 @@
+const DEFAULT_SETTINGS = {
+ serverUrl: "ws://127.0.0.1:8999",
+ bridgeKey: "",
+ profileKey: "default",
+ monitoringEnabled: true,
+};
+
+const bridgeStatusEl = document.getElementById("bridgeStatus");
+const monitorStatusEl = document.getElementById("monitorStatus");
+const endpointPreviewEl = document.getElementById("endpointPreview");
+
+const serverUrlEl = document.getElementById("serverUrl");
+const bridgeKeyEl = document.getElementById("bridgeKey");
+const profileKeyEl = document.getElementById("profileKey");
+
+const saveBtnEl = document.getElementById("saveBtn");
+const toggleConnBtnEl = document.getElementById("toggleConnBtn");
+const toggleMonitorBtnEl = document.getElementById("toggleMonitorBtn");
+const captureBtnEl = document.getElementById("captureBtn");
+
+const pageTitleEl = document.getElementById("pageTitle");
+const pageUrlEl = document.getElementById("pageUrl");
+
+function setBadge(el, isOn, onText, offText) {
+ el.textContent = isOn ? onText : offText;
+ el.className = `badge ${isOn ? "badge-on" : "badge-off"}`;
+}
+
+function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
+ const base = String(serverUrl || "").trim().replace(/\/$/, "");
+ const key = String(bridgeKey || "").trim();
+ const profile = encodeURIComponent(String(profileKey || "default").trim() || "default");
+ if (!base || !key) {
+ return "Observer URL: 未配置";
+ }
+ return `Observer URL: ${base}/proxycast-chrome-observer/Proxycast_Key=${encodeURIComponent(key)}?profileKey=${profile}`;
+}
+
+function applyStatus(status) {
+ const connected = Boolean(status?.isConnected);
+ const monitoring = Boolean(status?.monitoringEnabled);
+ setBadge(bridgeStatusEl, connected, "已连接", "未连接");
+ setBadge(monitorStatusEl, monitoring, "开启", "关闭");
+
+ const latestPageInfo = status?.latestPageInfo;
+ if (latestPageInfo?.title || latestPageInfo?.url) {
+ pageTitleEl.textContent = latestPageInfo.title || "无标题";
+ pageUrlEl.textContent = latestPageInfo.url || "";
+ }
+
+ const settings = status?.settings;
+ if (settings) {
+ if (typeof settings.serverUrl === "string" && settings.serverUrl) {
+ serverUrlEl.value = settings.serverUrl;
+ }
+ if (typeof settings.profileKey === "string" && settings.profileKey) {
+ profileKeyEl.value = settings.profileKey;
+ }
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ settings.serverUrl,
+ bridgeKeyEl.value,
+ settings.profileKey,
+ );
+ }
+}
+
+function sendMessage(message) {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(message, (response) => {
+ if (chrome.runtime.lastError) {
+ reject(new Error(chrome.runtime.lastError.message));
+ return;
+ }
+ resolve(response);
+ });
+ });
+}
+
+function readStoredSettings() {
+ return new Promise((resolve) => {
+ chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
+ resolve({ ...DEFAULT_SETTINGS, ...stored });
+ });
+ });
+}
+
+async function loadInitialState() {
+ const settings = await readStoredSettings();
+ serverUrlEl.value = settings.serverUrl;
+ bridgeKeyEl.value = settings.bridgeKey;
+ profileKeyEl.value = settings.profileKey;
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ settings.serverUrl,
+ settings.bridgeKey,
+ settings.profileKey,
+ );
+
+ try {
+ const status = await sendMessage({ type: "GET_STATUS" });
+ applyStatus(status || {});
+ } catch (error) {
+ console.warn("[ProxycastBridgePopup] 获取状态失败", error?.message || String(error));
+ }
+}
+
+async function saveAndReconnect() {
+ const payload = {
+ serverUrl: serverUrlEl.value.trim(),
+ bridgeKey: bridgeKeyEl.value.trim(),
+ profileKey: profileKeyEl.value.trim() || "default",
+ reconnect: true,
+ };
+
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ payload.serverUrl,
+ payload.bridgeKey,
+ payload.profileKey,
+ );
+
+ saveBtnEl.disabled = true;
+ const originalText = saveBtnEl.textContent;
+ saveBtnEl.textContent = "保存中...";
+
+ try {
+ await sendMessage({ type: "UPDATE_SETTINGS", data: payload });
+ saveBtnEl.textContent = "已保存";
+ setTimeout(() => {
+ saveBtnEl.textContent = originalText;
+ saveBtnEl.disabled = false;
+ }, 900);
+ } catch (error) {
+ saveBtnEl.textContent = "保存失败";
+ setTimeout(() => {
+ saveBtnEl.textContent = originalText;
+ saveBtnEl.disabled = false;
+ }, 1200);
+ console.warn("[ProxycastBridgePopup] 保存设置失败", error?.message || String(error));
+ }
+}
+
+async function toggleConnection() {
+ try {
+ await sendMessage({ type: "TOGGLE_CONNECTION" });
+ } catch (error) {
+ console.warn("[ProxycastBridgePopup] 切换连接失败", error?.message || String(error));
+ }
+}
+
+async function toggleMonitoring() {
+ try {
+ await sendMessage({ type: "TOGGLE_MONITORING" });
+ } catch (error) {
+ console.warn("[ProxycastBridgePopup] 切换监控失败", error?.message || String(error));
+ }
+}
+
+async function capturePageNow() {
+ captureBtnEl.disabled = true;
+ const originalText = captureBtnEl.textContent;
+ captureBtnEl.textContent = "抓取中...";
+
+ try {
+ await sendMessage({ type: "REQUEST_PAGE_CAPTURE" });
+ } catch (error) {
+ console.warn("[ProxycastBridgePopup] 请求抓取失败", error?.message || String(error));
+ } finally {
+ setTimeout(() => {
+ captureBtnEl.textContent = originalText;
+ captureBtnEl.disabled = false;
+ }, 800);
+ }
+}
+
+async function pasteConfigFromClipboard() {
+ const pasteBtn = document.getElementById("pasteConfigBtn");
+ pasteBtn.disabled = true;
+ const originalText = pasteBtn.textContent;
+ pasteBtn.textContent = "粘贴中...";
+
+ try {
+ const text = await navigator.clipboard.readText();
+ const config = JSON.parse(text);
+
+ if (config.serverUrl) {
+ serverUrlEl.value = config.serverUrl;
+ }
+ if (config.bridgeKey) {
+ bridgeKeyEl.value = config.bridgeKey;
+ }
+ if (config.profileKey) {
+ profileKeyEl.value = config.profileKey;
+ }
+
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ serverUrlEl.value,
+ bridgeKeyEl.value,
+ profileKeyEl.value,
+ );
+
+ pasteBtn.textContent = "已粘贴";
+ setTimeout(() => {
+ pasteBtn.textContent = originalText;
+ pasteBtn.disabled = false;
+ }, 1000);
+ } catch (error) {
+ pasteBtn.textContent = "粘贴失败";
+ setTimeout(() => {
+ pasteBtn.textContent = originalText;
+ pasteBtn.disabled = false;
+ }, 1500);
+ console.warn("[ProxycastBridgePopup] 粘贴配置失败", error?.message || String(error));
+ }
+}
+
+function clearConfig() {
+ serverUrlEl.value = DEFAULT_SETTINGS.serverUrl;
+ bridgeKeyEl.value = "";
+ profileKeyEl.value = DEFAULT_SETTINGS.profileKey;
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ serverUrlEl.value,
+ bridgeKeyEl.value,
+ profileKeyEl.value,
+ );
+}
+
+saveBtnEl.addEventListener("click", saveAndReconnect);
+toggleConnBtnEl.addEventListener("click", toggleConnection);
+toggleMonitorBtnEl.addEventListener("click", toggleMonitoring);
+captureBtnEl.addEventListener("click", capturePageNow);
+document.getElementById("pasteConfigBtn").addEventListener("click", pasteConfigFromClipboard);
+document.getElementById("clearConfigBtn").addEventListener("click", clearConfig);
+
+for (const input of [serverUrlEl, bridgeKeyEl, profileKeyEl]) {
+ input.addEventListener("input", () => {
+ endpointPreviewEl.textContent = buildObserverEndpoint(
+ serverUrlEl.value,
+ bridgeKeyEl.value,
+ profileKeyEl.value,
+ );
+ });
+}
+
+chrome.runtime.onMessage.addListener((request) => {
+ if (request?.type === "STATUS_UPDATE") {
+ applyStatus(request?.data || {});
+ }
+});
+
+loadInitialState();
diff --git a/package.json b/package.json
index 1d75ffbf0..73f081ab7 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
- "version": "0.73.0",
+ "version": "0.74.0",
"type": "module",
"repository": {
"type": "git",
@@ -29,7 +29,8 @@
"ai-verify:level1": "tsx scripts/ai-code-verify.ts --level 1",
"ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2",
"ai-verify:prompt": "tsx scripts/ai-code-verify.ts --generate-prompt",
- "ai-verify:file": "tsx scripts/ai-code-verify.ts --files"
+ "ai-verify:file": "tsx scripts/ai-code-verify.ts --files",
+ "bridge:e2e": "node scripts/chrome-bridge-e2e.mjs"
},
"dependencies": {
"@babel/standalone": "^7.29.0",
diff --git a/scripts/check-chrome-bridge.mjs b/scripts/check-chrome-bridge.mjs
new file mode 100644
index 000000000..578945302
--- /dev/null
+++ b/scripts/check-chrome-bridge.mjs
@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+
+/**
+ * 检查 Chrome Bridge 状态
+ */
+
+import WebSocket from 'ws';
+
+const SERVER_URL = 'ws://127.0.0.1:8999';
+const BRIDGE_KEY = 'Proxycast-key11';
+
+// 连接 Observer 通道查看状态
+const observerUrl = `${SERVER_URL}/proxycast-chrome-observer/${BRIDGE_KEY}?profileKey=test`;
+console.log(`[检查] 连接 Observer 通道: ${observerUrl}`);
+
+const ws = new WebSocket(observerUrl);
+
+ws.on('open', () => {
+ console.log('[检查] ✅ Observer 通道连接成功');
+ console.log('[检查] 这说明服务器正常运行\n');
+
+ setTimeout(() => {
+ ws.close();
+ }, 2000);
+});
+
+ws.on('message', (data) => {
+ const message = JSON.parse(data.toString());
+ console.log('[检查] 收到消息:', JSON.stringify(message, null, 2));
+});
+
+ws.on('error', (error) => {
+ console.error('[检查] ❌ 错误:', error.message);
+});
+
+ws.on('close', () => {
+ console.log('\n[检查] 连接关闭');
+ process.exit(0);
+});
diff --git a/scripts/chrome-bridge-e2e.mjs b/scripts/chrome-bridge-e2e.mjs
new file mode 100644
index 000000000..381105d6c
--- /dev/null
+++ b/scripts/chrome-bridge-e2e.mjs
@@ -0,0 +1,345 @@
+#!/usr/bin/env node
+
+import { randomUUID } from 'node:crypto';
+import process from 'node:process';
+
+const DEFAULTS = {
+ server: 'ws://127.0.0.1:8787',
+ key: '',
+ profile: 'default',
+ timeoutMs: 15000,
+};
+
+function parseArgs(argv) {
+ const args = { ...DEFAULTS };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--server' && argv[i + 1]) {
+ args.server = argv[i + 1];
+ i += 1;
+ continue;
+ }
+ if (arg === '--key' && argv[i + 1]) {
+ args.key = argv[i + 1];
+ i += 1;
+ continue;
+ }
+ if (arg === '--profile' && argv[i + 1]) {
+ args.profile = argv[i + 1];
+ i += 1;
+ continue;
+ }
+ if (arg === '--timeout-ms' && argv[i + 1]) {
+ args.timeoutMs = Number(argv[i + 1]);
+ i += 1;
+ continue;
+ }
+ if (arg === '--help' || arg === '-h') {
+ printHelp();
+ process.exit(0);
+ }
+ }
+ return args;
+}
+
+function printHelp() {
+ console.log(`
+Proxycast Chrome Bridge E2E 联调脚本
+
+用法:
+ node scripts/chrome-bridge-e2e.mjs --key [选项]
+
+选项:
+ --server 服务地址,默认 ws://127.0.0.1:8787
+ --key Proxycast API Key(必填)
+ --profile profileKey,默认 default
+ --timeout-ms 单步超时毫秒,默认 15000
+ -h, --help 显示帮助
+
+示例:
+ node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default
+`);
+}
+
+function assertGlobalWebSocket() {
+ if (typeof WebSocket !== 'undefined') {
+ return;
+ }
+ throw new Error(
+ '当前 Node 运行时不支持全局 WebSocket,请使用 Node 20+ 或安装支持 WebSocket 的运行环境。',
+ );
+}
+
+function normalizeServer(server) {
+ return String(server || '').trim().replace(/\/$/, '');
+}
+
+function toText(data) {
+ if (typeof data === 'string') return data;
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer).toString('utf8');
+ return String(data);
+}
+
+function createClient(url, label, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(url);
+ const state = {
+ label,
+ ws,
+ messages: [],
+ waiters: [],
+ };
+
+ const timer = setTimeout(() => {
+ reject(new Error(`[${label}] 连接超时: ${url}`));
+ try {
+ ws.close();
+ } catch (_) {
+ // ignore
+ }
+ }, timeoutMs);
+
+ ws.onopen = () => {
+ clearTimeout(timer);
+ resolve(state);
+ };
+
+ ws.onerror = (event) => {
+ clearTimeout(timer);
+ reject(new Error(`[${label}] WebSocket 连接失败: ${event?.message || 'unknown error'}`));
+ };
+
+ ws.onmessage = (event) => {
+ let payload;
+ const text = toText(event.data);
+ try {
+ payload = JSON.parse(text);
+ } catch (_) {
+ payload = { type: 'raw_text', data: text };
+ }
+ state.messages.push(payload);
+
+ const pending = [...state.waiters];
+ for (const waiter of pending) {
+ if (waiter.predicate(payload)) {
+ waiter.resolve(payload);
+ state.waiters = state.waiters.filter((item) => item !== waiter);
+ }
+ }
+ };
+ });
+}
+
+function waitForMessage(client, predicate, timeoutMs, desc) {
+ const found = client.messages.find(predicate);
+ if (found) {
+ return Promise.resolve(found);
+ }
+
+ return new Promise((resolve, reject) => {
+ const waiter = { predicate, resolve };
+ client.waiters.push(waiter);
+ const timer = setTimeout(() => {
+ client.waiters = client.waiters.filter((item) => item !== waiter);
+ reject(
+ new Error(
+ `[${client.label}] 等待消息超时(${timeoutMs}ms): ${desc}\n最近消息: ${JSON.stringify(
+ client.messages.slice(-5),
+ null,
+ 2,
+ )}`,
+ ),
+ );
+ }, timeoutMs);
+
+ waiter.resolve = (payload) => {
+ clearTimeout(timer);
+ resolve(payload);
+ };
+ });
+}
+
+function send(client, payload) {
+ client.ws.send(JSON.stringify(payload));
+}
+
+async function closeClient(client) {
+ if (!client) return;
+ await new Promise((resolve) => {
+ try {
+ client.ws.onclose = () => resolve();
+ client.ws.close();
+ setTimeout(resolve, 200);
+ } catch (_) {
+ resolve();
+ }
+ });
+}
+
+async function main() {
+ assertGlobalWebSocket();
+ const args = parseArgs(process.argv.slice(2));
+
+ if (!args.key) {
+ printHelp();
+ throw new Error('缺少必填参数: --key');
+ }
+ if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) {
+ throw new Error('--timeout-ms 必须是 >= 1000 的数字');
+ }
+
+ const server = normalizeServer(args.server);
+ const key = encodeURIComponent(args.key);
+ const profile = encodeURIComponent(args.profile || 'default');
+ const observerUrl = `${server}/proxycast-chrome-observer/Proxycast_Key=${key}?profileKey=${profile}`;
+ const controlUrl = `${server}/proxycast-chrome-control/Proxycast_Key=${key}`;
+
+ console.log('[E2E] observer:', observerUrl);
+ console.log('[E2E] control :', controlUrl);
+
+ let observer;
+ let control;
+
+ try {
+ observer = await createClient(observerUrl, 'observer', args.timeoutMs);
+ control = await createClient(controlUrl, 'control', args.timeoutMs);
+
+ await waitForMessage(
+ observer,
+ (msg) => msg.type === 'connection_ack',
+ args.timeoutMs,
+ 'observer connection_ack',
+ );
+ await waitForMessage(
+ control,
+ (msg) => msg.type === 'connection_ack',
+ args.timeoutMs,
+ 'control connection_ack',
+ );
+ console.log('[E2E] 连接握手通过');
+
+ send(observer, { type: 'heartbeat', timestamp: Date.now() });
+ send(control, { type: 'heartbeat', timestamp: Date.now() });
+
+ await waitForMessage(
+ observer,
+ (msg) => msg.type === 'heartbeat_ack',
+ args.timeoutMs,
+ 'observer heartbeat_ack',
+ );
+ await waitForMessage(
+ control,
+ (msg) => msg.type === 'heartbeat_ack',
+ args.timeoutMs,
+ 'control heartbeat_ack',
+ );
+ console.log('[E2E] 心跳通道通过');
+
+ const requestId1 = `e2e-${randomUUID()}`;
+ send(control, {
+ type: 'command',
+ data: {
+ requestId: requestId1,
+ command: 'get_page_info',
+ wait_for_page_info: true,
+ },
+ });
+
+ const cmdFromServer1 = await waitForMessage(
+ observer,
+ (msg) => msg.type === 'command' && msg.data?.requestId === requestId1,
+ args.timeoutMs,
+ 'observer 收到 get_page_info 命令',
+ );
+ console.log('[E2E] observer 收到命令:', cmdFromServer1.data?.command);
+
+ send(observer, {
+ type: 'command_result',
+ data: {
+ requestId: requestId1,
+ status: 'success',
+ message: 'get_page_info executed by e2e observer',
+ },
+ });
+ send(observer, {
+ type: 'pageInfoUpdate',
+ data: {
+ markdown: '# E2E Page\nURL: https://example.com/e2e\n\n## 内容\nbridge e2e test',
+ },
+ });
+
+ await waitForMessage(
+ control,
+ (msg) =>
+ msg.type === 'command_result' &&
+ msg.data?.requestId === requestId1 &&
+ msg.data?.status === 'success',
+ args.timeoutMs,
+ 'control 收到 command_result(success)',
+ );
+ await waitForMessage(
+ control,
+ (msg) =>
+ msg.type === 'page_info_update' &&
+ msg.data?.requestId === requestId1 &&
+ typeof msg.data?.markdown === 'string' &&
+ msg.data.markdown.includes('E2E Page'),
+ args.timeoutMs,
+ 'control 收到 page_info_update',
+ );
+ console.log('[E2E] wait_for_page_info 命令链路通过');
+
+ const requestId2 = `e2e-${randomUUID()}`;
+ send(control, {
+ type: 'command',
+ data: {
+ requestId: requestId2,
+ command: 'scroll',
+ text: 'down:300',
+ wait_for_page_info: false,
+ },
+ });
+
+ const cmdFromServer2 = await waitForMessage(
+ observer,
+ (msg) => msg.type === 'command' && msg.data?.requestId === requestId2,
+ args.timeoutMs,
+ 'observer 收到 scroll 命令',
+ );
+ if (cmdFromServer2.data?.command !== 'scroll') {
+ throw new Error(`期望 scroll,实际为 ${cmdFromServer2.data?.command || 'unknown'}`);
+ }
+ send(observer, {
+ type: 'command_result',
+ data: {
+ requestId: requestId2,
+ status: 'success',
+ message: 'scroll executed by e2e observer',
+ },
+ });
+
+ await waitForMessage(
+ control,
+ (msg) =>
+ msg.type === 'command_result' &&
+ msg.data?.requestId === requestId2 &&
+ msg.data?.status === 'success',
+ args.timeoutMs,
+ 'control 收到 scroll command_result',
+ );
+ console.log('[E2E] 非 wait_for_page_info 命令链路通过');
+
+ console.log('\n[E2E] ✅ Chrome Bridge 联调通过');
+ } finally {
+ await closeClient(control);
+ await closeClient(observer);
+ }
+}
+
+main().catch((error) => {
+ console.error('\n[E2E] ❌ Chrome Bridge 联调失败');
+ console.error(error?.stack || error?.message || String(error));
+ process.exit(1);
+});
diff --git a/scripts/test-chrome-bridge.mjs b/scripts/test-chrome-bridge.mjs
new file mode 100755
index 000000000..33a65d3bf
--- /dev/null
+++ b/scripts/test-chrome-bridge.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+
+/**
+ * Chrome Bridge 测试脚本
+ *
+ * 使用方式:
+ * 1. 确保 ProxyCast 服务器正在运行
+ * 2. 确保 Chrome Profile 已打开并连接
+ * 3. 运行: node scripts/test-chrome-bridge.mjs
+ */
+
+import WebSocket from 'ws';
+
+const SERVER_URL = 'ws://127.0.0.1:8999';
+const BRIDGE_KEY = 'Proxycast-key11';
+const PROFILE_KEY = 'search_google';
+
+// 连接 Control 通道
+const controlUrl = `${SERVER_URL}/proxycast-chrome-control/${BRIDGE_KEY}`;
+console.log(`[测试] 连接 Control 通道: ${controlUrl}`);
+
+const ws = new WebSocket(controlUrl);
+
+ws.on('open', async () => {
+ console.log('[测试] ✅ Control 通道连接成功\n');
+
+ // 测试 1: 获取页面信息
+ console.log('=== 测试 1: 获取当前页面信息 ===');
+ ws.send(JSON.stringify({
+ type: 'command',
+ request_id: 'test-1',
+ profile_key: PROFILE_KEY,
+ command: 'get_page_info',
+ wait_for_page_info: true
+ }));
+
+ // 等待 3 秒
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ // 测试 2: 打开 URL
+ console.log('\n=== 测试 2: 打开 Google ===');
+ ws.send(JSON.stringify({
+ type: 'command',
+ request_id: 'test-2',
+ profile_key: PROFILE_KEY,
+ command: 'open_url',
+ url: 'https://www.google.com',
+ wait_for_page_info: true
+ }));
+
+ // 等待 5 秒后关闭
+ setTimeout(() => {
+ console.log('\n[测试] 测试完成,关闭连接');
+ ws.close();
+ }, 8000);
+});
+
+ws.on('message', (data) => {
+ try {
+ const message = JSON.parse(data.toString());
+
+ if (message.type === 'connection_ack') {
+ console.log('[测试] 收到连接确认:', message.message);
+ console.log('[测试] Client ID:', message.data?.clientId);
+ } else if (message.type === 'command_result') {
+ console.log(`\n[结果] Request ID: ${message.request_id}`);
+ console.log(`[结果] 命令: ${message.command}`);
+ console.log(`[结果] 成功: ${message.success}`);
+
+ if (message.message) {
+ console.log(`[结果] 消息: ${message.message}`);
+ }
+
+ if (message.error) {
+ console.log(`[结果] 错误: ${message.error}`);
+ }
+
+ if (message.page_info) {
+ console.log(`[结果] 页面标题: ${message.page_info.title}`);
+ console.log(`[结果] 页面 URL: ${message.page_info.url}`);
+ console.log(`[结果] Markdown 长度: ${message.page_info.markdown.length} 字符`);
+ console.log(`[结果] Markdown 预览:\n${message.page_info.markdown.substring(0, 200)}...`);
+ }
+ } else if (message.type === 'heartbeat_ack') {
+ // 忽略心跳响应
+ } else {
+ console.log('[测试] 收到消息:', message);
+ }
+ } catch (error) {
+ console.error('[测试] 解析消息失败:', error.message);
+ console.log('[测试] 原始消息:', data.toString());
+ }
+});
+
+ws.on('error', (error) => {
+ console.error('[测试] ❌ WebSocket 错误:', error.message);
+});
+
+ws.on('close', (code, reason) => {
+ console.log(`\n[测试] 连接关闭: code=${code}, reason=${reason.toString()}`);
+ process.exit(code === 1000 ? 0 : 1);
+});
diff --git a/scripts/test-ws-connection.mjs b/scripts/test-ws-connection.mjs
new file mode 100644
index 000000000..d004ba1d9
--- /dev/null
+++ b/scripts/test-ws-connection.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+
+import WebSocket from 'ws';
+
+const serverUrl = 'ws://127.0.0.1:8999';
+const bridgeKey = 'Proxycast-key11';
+const profileKey = 'search_google';
+
+const url = `${serverUrl}/proxycast-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${encodeURIComponent(profileKey)}`;
+
+console.log(`[测试] 连接 URL: ${url}`);
+
+const ws = new WebSocket(url);
+
+ws.on('open', () => {
+ console.log('[测试] ✅ WebSocket 连接成功');
+
+ // 发送心跳
+ const heartbeat = JSON.stringify({ type: 'heartbeat' });
+ console.log(`[测试] 发送心跳: ${heartbeat}`);
+ ws.send(heartbeat);
+
+ setTimeout(() => {
+ console.log('[测试] 关闭连接');
+ ws.close();
+ }, 2000);
+});
+
+ws.on('message', (data) => {
+ console.log('[测试] 收到消息:', data.toString());
+});
+
+ws.on('error', (error) => {
+ console.error('[测试] ❌ WebSocket 错误:', error.message);
+});
+
+ws.on('close', (code, reason) => {
+ console.log(`[测试] 连接关闭: code=${code}, reason=${reason.toString()}`);
+ process.exit(code === 1000 ? 0 : 1);
+});
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 4ed3af220..246532d26 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -6685,7 +6685,7 @@ dependencies = [
[[package]]
name = "proxycast"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"anyhow",
"arboard",
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"aster-core",
"async-trait",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6825,7 +6825,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"aster-models",
"async-trait",
@@ -6865,7 +6865,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -6900,7 +6900,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6920,7 +6920,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-trait",
"glob",
@@ -6951,7 +6951,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6970,7 +6970,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"anyhow",
"async-stream",
@@ -7022,7 +7022,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -7032,6 +7032,7 @@ dependencies = [
"dirs 5.0.1",
"futures",
"hex",
+ "once_cell",
"parking_lot",
"proptest",
"proxycast-agent",
@@ -7064,7 +7065,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -7079,7 +7080,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"anyhow",
"aster-core",
@@ -7120,7 +7121,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7136,7 +7137,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7163,7 +7164,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
-version = "0.73.0"
+version = "0.74.0"
dependencies = [
"axum 0.7.9",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index e13d77223..cad58a12c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
-version = "0.73.0"
+version = "0.74.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -189,7 +189,7 @@ version = "2.4"
[package]
name = "proxycast"
-version = "0.73.0"
+version = "0.74.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs
index 1a891092a..fa2aa7f09 100644
--- a/src-tauri/crates/core/src/config/mod.rs
+++ b/src-tauri/crates/core/src/config/mod.rs
@@ -22,18 +22,18 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde};
pub use types::{
generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry,
AsrProviderType, AssistantConfig, AssistantProfile, BaiduConfig, ChannelsConfig,
- ChatAppearanceConfig, Config,
- ContentCreatorConfig, ConversationSettings, CredentialEntry, CredentialPoolConfig,
- CustomProviderConfig, DeliveryConfig, EndpointProvidersConfig, ExperimentalFeatures,
- GeminiApiKeyEntry, HeartbeatExecutionMode, HeartbeatSecurityConfig, HeartbeatSettings,
- HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig,
- InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig,
- MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, NativeAgentConfig,
- NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig,
- ProvidersConfig, QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, RetrySettings,
- RoutingConfig, ScreenshotChatConfig, ServerConfig, TaskSchedule, TlsConfig, UpdateCheckConfig,
- UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig,
- VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WhisperLocalConfig,
- WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
+ ChatAppearanceConfig, Config, ContentCreatorConfig, ConversationSettings, CredentialEntry,
+ CredentialPoolConfig, CustomProviderConfig, DeliveryConfig, EndpointProvidersConfig,
+ ExperimentalFeatures, GeminiApiKeyEntry, HeartbeatExecutionMode, HeartbeatSecurityConfig,
+ HeartbeatSettings, HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig,
+ InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig,
+ MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig,
+ NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig,
+ ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, RateLimitSettings,
+ RemoteManagementConfig, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine,
+ ServerConfig, TaskSchedule, TlsConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
+ VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig,
+ VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WhisperLocalConfig, WhisperModelSize,
+ WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
};
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs
index 738e12305..ba37bc956 100644
--- a/src-tauri/crates/core/src/config/types.rs
+++ b/src-tauri/crates/core/src/config/types.rs
@@ -408,6 +408,9 @@ pub struct Config {
/// 聊天外观配置
#[serde(default)]
pub chat_appearance: ChatAppearanceConfig,
+ /// 网络搜索偏好配置
+ #[serde(default)]
+ pub web_search: WebSearchConfig,
/// 记忆管理配置
#[serde(default)]
pub memory: MemoryConfig,
@@ -1778,6 +1781,7 @@ impl Default for Config {
content_creator: ContentCreatorConfig::default(),
navigation: NavigationConfig::default(),
chat_appearance: ChatAppearanceConfig::default(),
+ web_search: WebSearchConfig::default(),
memory: MemoryConfig::default(),
voice: VoiceConfig::default(),
image_gen: ImageGenConfig::default(),
@@ -1795,6 +1799,25 @@ impl Default for Config {
// ============ 设置页面配置类型 ============
+/// 网络搜索引擎类型
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum SearchEngine {
+ /// Google 搜索(通用网页检索)
+ #[default]
+ Google,
+ /// 小红书搜索(中文生活方式内容)
+ Xiaohongshu,
+}
+
+/// 网络搜索配置
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct WebSearchConfig {
+ /// 默认搜索引擎偏好
+ #[serde(default)]
+ pub engine: SearchEngine,
+}
+
/// 聊天外观配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ChatAppearanceConfig {
@@ -2569,10 +2592,11 @@ impl Default for DeliveryConfig {
}
/// 心跳执行模式
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HeartbeatExecutionMode {
/// 智能模式:通过 AI Agent 执行任务
+ #[default]
Intelligent,
/// 技能模式:调用已注册的技能
Skill,
@@ -2580,12 +2604,6 @@ pub enum HeartbeatExecutionMode {
LogOnly,
}
-impl Default for HeartbeatExecutionMode {
- fn default() -> Self {
- Self::Intelligent
- }
-}
-
/// 心跳引擎配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HeartbeatSettings {
@@ -2717,7 +2735,7 @@ impl Default for ConversationSettings {
}
/// 提示路由配置
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct HintRouterSettings {
#[serde(default)]
pub enabled: bool,
@@ -2725,15 +2743,6 @@ pub struct HintRouterSettings {
pub routes: Vec,
}
-impl Default for HintRouterSettings {
- fn default() -> Self {
- Self {
- enabled: false,
- routes: Vec::new(),
- }
- }
-}
-
/// 提示路由条目(配置层面,provider 为字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HintRouteSettingsEntry {
@@ -2743,18 +2752,12 @@ pub struct HintRouteSettingsEntry {
}
/// 配对认证配置
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PairingSettings {
#[serde(default)]
pub enabled: bool,
}
-impl Default for PairingSettings {
- fn default() -> Self {
- Self { enabled: false }
- }
-}
-
// ============ 渠道配置类型(Telegram / Discord / 飞书 Bot) ============
/// 渠道配置
diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs
index 23935f969..293df52f0 100644
--- a/src-tauri/crates/core/src/database/schema.rs
+++ b/src-tauri/crates/core/src/database/schema.rs
@@ -920,6 +920,174 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
+ // ============================================================================
+ // 小说编排系统相关表
+ // ============================================================================
+
+ // 小说项目表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_projects (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ theme TEXT,
+ target_words INTEGER NOT NULL DEFAULT 100000,
+ status TEXT NOT NULL DEFAULT 'draft',
+ current_word_count INTEGER NOT NULL DEFAULT 0,
+ metadata_json TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_projects_status ON novel_projects(status)",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_projects_updated_at ON novel_projects(updated_at DESC)",
+ [],
+ )?;
+
+ // 小说设定版本表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_settings (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ settings_json TEXT NOT NULL DEFAULT '{}',
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, version)
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_settings_project_version ON novel_settings(project_id, version DESC)",
+ [],
+ )?;
+
+ // 小说大纲版本表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_outlines (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ outline_markdown TEXT NOT NULL DEFAULT '',
+ outline_json TEXT,
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, version)
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_outlines_project_version ON novel_outlines(project_id, version DESC)",
+ [],
+ )?;
+
+ // 小说角色快照表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_characters (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ role_type TEXT NOT NULL DEFAULT 'support',
+ card_json TEXT NOT NULL DEFAULT '{}',
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_characters_project ON novel_characters(project_id)",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_characters_role ON novel_characters(role_type)",
+ [],
+ )?;
+
+ // 小说章节表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_chapters (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ chapter_no INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ word_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'draft',
+ quality_score REAL,
+ metadata_json TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
+ UNIQUE(project_id, chapter_no)
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_chapters_project_no ON novel_chapters(project_id, chapter_no)",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_chapters_status ON novel_chapters(status)",
+ [],
+ )?;
+
+ // 小说生成运行记录
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_generation_runs (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ mode TEXT NOT NULL,
+ input_snapshot_json TEXT,
+ output_snapshot_json TEXT,
+ provider TEXT,
+ model TEXT,
+ latency_ms INTEGER,
+ token_usage_json TEXT,
+ result_status TEXT NOT NULL DEFAULT 'success',
+ error_message TEXT,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_runs_project_time ON novel_generation_runs(project_id, created_at DESC)",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_runs_mode ON novel_generation_runs(mode)",
+ [],
+ )?;
+
+ // 小说一致性检查结果表
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS novel_consistency_checks (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ chapter_id TEXT NOT NULL,
+ issues_json TEXT NOT NULL DEFAULT '[]',
+ score REAL NOT NULL DEFAULT 100,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
+ FOREIGN KEY (chapter_id) REFERENCES novel_chapters(id) ON DELETE CASCADE
+ )",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_consistency_project_chapter ON novel_consistency_checks(project_id, chapter_id)",
+ [],
+ )?;
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_novel_consistency_created ON novel_consistency_checks(created_at DESC)",
+ [],
+ )?;
+
// ============================================================================
// A2UI 表单数据表
// 存储 AI 生成的交互式表单及用户填写的数据
diff --git a/src-tauri/crates/core/src/models/model_registry.rs b/src-tauri/crates/core/src/models/model_registry.rs
index e2418904e..6f28e842f 100644
--- a/src-tauri/crates/core/src/models/model_registry.rs
+++ b/src-tauri/crates/core/src/models/model_registry.rs
@@ -62,10 +62,11 @@ pub struct ModelLimits {
}
/// 模型状态
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ModelStatus {
/// 活跃可用
+ #[default]
Active,
/// 预览版
Preview,
@@ -79,12 +80,6 @@ pub enum ModelStatus {
Legacy,
}
-impl Default for ModelStatus {
- fn default() -> Self {
- Self::Active
- }
-}
-
impl std::fmt::Display for ModelStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -115,23 +110,18 @@ impl std::str::FromStr for ModelStatus {
}
/// 模型服务等级
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ModelTier {
/// 快速响应,适合简单任务
Mini,
/// 均衡性能,适合大多数任务
+ #[default]
Pro,
/// 最强能力,适合复杂任务
Max,
}
-impl Default for ModelTier {
- fn default() -> Self {
- Self::Pro
- }
-}
-
impl std::fmt::Display for ModelTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
diff --git a/src-tauri/crates/core/src/orchestrator/mod.rs b/src-tauri/crates/core/src/orchestrator/mod.rs
index f6d71b27e..69eac0d7b 100644
--- a/src-tauri/crates/core/src/orchestrator/mod.rs
+++ b/src-tauri/crates/core/src/orchestrator/mod.rs
@@ -18,7 +18,7 @@
//! 2. **专家模式**: 直接选择具体模型
mod fallback;
-mod orchestrator;
+mod model_orchestrator;
mod pool_builder;
mod selector;
pub mod strategies;
@@ -26,7 +26,7 @@ mod strategy;
mod tier;
pub use fallback::{FallbackHandler, FallbackPolicy, FallbackResult};
-pub use orchestrator::{
+pub use model_orchestrator::{
get_global_orchestrator, init_global_orchestrator, ModelOrchestrator, OrchestratorConfig,
PoolStats,
};
diff --git a/src-tauri/crates/core/src/orchestrator/orchestrator.rs b/src-tauri/crates/core/src/orchestrator/model_orchestrator.rs
similarity index 100%
rename from src-tauri/crates/core/src/orchestrator/orchestrator.rs
rename to src-tauri/crates/core/src/orchestrator/model_orchestrator.rs
diff --git a/src-tauri/crates/core/src/orchestrator/pool_builder.rs b/src-tauri/crates/core/src/orchestrator/pool_builder.rs
index b4decdb84..56704afd6 100644
--- a/src-tauri/crates/core/src/orchestrator/pool_builder.rs
+++ b/src-tauri/crates/core/src/orchestrator/pool_builder.rs
@@ -22,7 +22,7 @@ pub enum ProviderType {
impl ProviderType {
/// 从字符串解析
- pub fn from_str(s: &str) -> Option {
+ pub fn parse_str(s: &str) -> Option {
match s.to_lowercase().as_str() {
"anthropic" => Some(ProviderType::Anthropic),
"openai" => Some(ProviderType::OpenAI),
diff --git a/src-tauri/crates/core/src/orchestrator/tier.rs b/src-tauri/crates/core/src/orchestrator/tier.rs
index ec79882f0..84df37ed9 100644
--- a/src-tauri/crates/core/src/orchestrator/tier.rs
+++ b/src-tauri/crates/core/src/orchestrator/tier.rs
@@ -48,7 +48,7 @@ impl ServiceTier {
}
/// 从字符串解析
- pub fn from_str(s: &str) -> Option {
+ pub fn parse_str(s: &str) -> Option {
match s.to_lowercase().as_str() {
"mini" => Some(ServiceTier::Mini),
"pro" => Some(ServiceTier::Pro),
@@ -291,9 +291,9 @@ mod tests {
assert_eq!(ServiceTier::Pro.level(), 2);
assert_eq!(ServiceTier::Max.level(), 3);
- assert_eq!(ServiceTier::from_str("mini"), Some(ServiceTier::Mini));
- assert_eq!(ServiceTier::from_str("PRO"), Some(ServiceTier::Pro));
- assert_eq!(ServiceTier::from_str("invalid"), None);
+ assert_eq!(ServiceTier::parse_str("mini"), Some(ServiceTier::Mini));
+ assert_eq!(ServiceTier::parse_str("PRO"), Some(ServiceTier::Pro));
+ assert_eq!(ServiceTier::parse_str("invalid"), None);
}
#[test]
diff --git a/src-tauri/crates/core/src/plugin/installer/mod.rs b/src-tauri/crates/core/src/plugin/installer/mod.rs
index 67e9db49f..7af8d856f 100644
--- a/src-tauri/crates/core/src/plugin/installer/mod.rs
+++ b/src-tauri/crates/core/src/plugin/installer/mod.rs
@@ -8,13 +8,13 @@
//! - 安装进度回调
mod downloader;
-mod installer;
+mod plugin_installer;
mod registry;
mod types;
mod validator;
pub use downloader::PluginDownloader;
-pub use installer::PluginInstaller;
+pub use plugin_installer::PluginInstaller;
pub use registry::PluginRegistry;
pub use types::{
GitHubRelease, InstallError, InstallProgress, InstallSource, InstallStage, InstalledPlugin,
diff --git a/src-tauri/crates/core/src/plugin/installer/installer.rs b/src-tauri/crates/core/src/plugin/installer/plugin_installer.rs
similarity index 99%
rename from src-tauri/crates/core/src/plugin/installer/installer.rs
rename to src-tauri/crates/core/src/plugin/installer/plugin_installer.rs
index dbba6e77b..2d323baa7 100644
--- a/src-tauri/crates/core/src/plugin/installer/installer.rs
+++ b/src-tauri/crates/core/src/plugin/installer/plugin_installer.rs
@@ -485,7 +485,7 @@ impl PluginInstaller {
&self,
src: &Path,
dst: &Path,
- progress: &dyn ProgressCallback,
+ _progress: &dyn ProgressCallback,
) -> Result<(), InstallError> {
for entry in fs::read_dir(src)? {
let entry = entry?;
@@ -494,7 +494,7 @@ impl PluginInstaller {
if src_path.is_dir() {
fs::create_dir_all(&dst_path)?;
- self.copy_dir_recursive(&src_path, &dst_path, progress)?;
+ self.copy_dir_recursive(&src_path, &dst_path, _progress)?;
} else {
fs::copy(&src_path, &dst_path)?;
diff --git a/src-tauri/crates/core/src/session/sticky_config.rs b/src-tauri/crates/core/src/session/sticky_config.rs
index 8a3d88fa7..bba259da2 100644
--- a/src-tauri/crates/core/src/session/sticky_config.rs
+++ b/src-tauri/crates/core/src/session/sticky_config.rs
@@ -5,22 +5,17 @@
use serde::{Deserialize, Serialize};
/// 调度模式枚举
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SchedulingMode {
/// 缓存优先 (Cache-first): 尽可能锁定同一账号,限流时优先等待,极大提升 Prompt Caching 命中率
CacheFirst,
- /// 平衡模式 (Balance): 锁定同一账号,限流时立即切换到备选账号,兼顾成功率和性能
+ /// 平衡模式 (Balance): 锁定同一账号,限流时立即切换到备选账号,兼顾成功率和性能
+ #[default]
Balance,
/// 性能优先 (Performance-first): 纯轮询模式 (Round-robin),账号负载最均衡,但不利用缓存
PerformanceFirst,
}
-impl Default for SchedulingMode {
- fn default() -> Self {
- Self::Balance
- }
-}
-
impl std::fmt::Display for SchedulingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
diff --git a/src-tauri/crates/server/Cargo.toml b/src-tauri/crates/server/Cargo.toml
index 6c1df6c6f..379fb3ab8 100644
--- a/src-tauri/crates/server/Cargo.toml
+++ b/src-tauri/crates/server/Cargo.toml
@@ -40,6 +40,7 @@ rand.workspace = true
sha2.workspace = true
tokio-util.workspace = true
dirs.workspace = true
+once_cell.workspace = true
[dev-dependencies]
proptest.workspace = true
diff --git a/src-tauri/crates/server/src/chrome_bridge.rs b/src-tauri/crates/server/src/chrome_bridge.rs
new file mode 100644
index 000000000..3470fbf69
--- /dev/null
+++ b/src-tauri/crates/server/src/chrome_bridge.rs
@@ -0,0 +1,1139 @@
+//! ChromeBridge WebSocket 会话桥接
+//!
+//! 提供双通道能力:
+//! - observer 通道:Chrome 扩展上报页面信息、心跳、命令执行结果
+//! - control 通道:外部控制端下发命令并接收回传
+//! - API 通道:Tauri 命令直接触发命令并等待结果
+
+use chrono::{DateTime, Utc};
+use once_cell::sync::Lazy;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tokio::sync::{mpsc, oneshot, Mutex};
+use uuid::Uuid;
+
+const DEFAULT_PROFILE_KEY: &str = "default";
+const DEFAULT_COMMAND_TIMEOUT_MS: u64 = 30_000;
+const MIN_COMMAND_TIMEOUT_MS: u64 = 1_000;
+const MAX_COMMAND_TIMEOUT_MS: u64 = 120_000;
+const DEFAULT_MESSAGE_PREVIEW_LIMIT: usize = 2_000;
+
+const COMMAND_WHITELIST: &[&str] = &[
+ "open_url",
+ "click",
+ "type",
+ "scroll",
+ "scroll_page",
+ "get_page_info",
+ "refresh_page",
+ "go_back",
+ "go_forward",
+ "switch_tab",
+];
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgePageInfo {
+ pub title: Option,
+ pub url: Option,
+ pub markdown: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgeObserverSnapshot {
+ pub client_id: String,
+ pub profile_key: String,
+ pub connected_at: String,
+ pub user_agent: Option,
+ pub last_heartbeat_at: Option,
+ pub last_page_info: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgeControlSnapshot {
+ pub client_id: String,
+ pub connected_at: String,
+ pub user_agent: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgePendingCommandSnapshot {
+ pub request_id: String,
+ pub source_type: String,
+ pub command: String,
+ pub observer_client_id: String,
+ pub wait_for_page_info: bool,
+ pub command_completed: bool,
+ pub created_at: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgeStatusSnapshot {
+ pub observer_count: usize,
+ pub control_count: usize,
+ pub pending_command_count: usize,
+ pub observers: Vec,
+ pub controls: Vec,
+ pub pending_commands: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgeCommandRequest {
+ #[serde(default)]
+ pub profile_key: Option,
+ pub command: String,
+ #[serde(default)]
+ pub target: Option,
+ #[serde(default)]
+ pub text: Option,
+ #[serde(default)]
+ pub url: Option,
+ #[serde(default)]
+ pub wait_for_page_info: bool,
+ #[serde(default)]
+ pub timeout_ms: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChromeBridgeCommandResult {
+ pub success: bool,
+ pub request_id: String,
+ pub command: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub message: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub page_info: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ObserverCommandResultPayload {
+ #[serde(alias = "requestId")]
+ pub request_id: String,
+ pub status: String,
+ #[serde(default)]
+ pub message: Option,
+ #[serde(default)]
+ pub error: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ControlCommandPayload {
+ #[serde(alias = "requestId")]
+ pub request_id: String,
+ pub command: String,
+ #[serde(default)]
+ pub target: Option,
+ #[serde(default)]
+ pub text: Option,
+ #[serde(default)]
+ pub url: Option,
+ #[serde(default)]
+ pub wait_for_page_info: bool,
+}
+
+pub struct ChromeBridgeHub {
+ inner: Mutex,
+}
+
+struct ChromeBridgeState {
+ observers: HashMap,
+ controls: HashMap,
+ pending_commands: HashMap,
+}
+
+#[derive(Clone)]
+struct ObserverConnection {
+ sender: mpsc::UnboundedSender,
+ profile_key: String,
+ connected_at: DateTime,
+ user_agent: Option,
+ last_heartbeat_at: Option>,
+ last_page_info: Option,
+}
+
+#[derive(Clone)]
+struct ControlConnection {
+ sender: mpsc::UnboundedSender,
+ connected_at: DateTime,
+ user_agent: Option,
+}
+
+enum PendingSource {
+ Api(oneshot::Sender),
+ Control { control_client_id: String },
+}
+
+struct PendingCommand {
+ request_id: String,
+ source: PendingSource,
+ command: String,
+ observer_client_id: String,
+ wait_for_page_info: bool,
+ command_completed: bool,
+ execution_message: Option,
+ created_at: DateTime,
+ expires_at: Instant,
+}
+
+impl PendingCommand {
+ fn source_type(&self) -> &'static str {
+ match self.source {
+ PendingSource::Api(_) => "api",
+ PendingSource::Control { .. } => "control",
+ }
+ }
+}
+
+impl ChromeBridgeState {
+ fn new() -> Self {
+ Self {
+ observers: HashMap::new(),
+ controls: HashMap::new(),
+ pending_commands: HashMap::new(),
+ }
+ }
+}
+
+impl ChromeBridgeHub {
+ pub fn new() -> Self {
+ Self {
+ inner: Mutex::new(ChromeBridgeState::new()),
+ }
+ }
+
+ pub async fn register_observer(
+ &self,
+ client_id: String,
+ profile_key: Option,
+ user_agent: Option,
+ sender: mpsc::UnboundedSender,
+ ) {
+ let mut inner = self.inner.lock().await;
+ inner.observers.insert(
+ client_id,
+ ObserverConnection {
+ sender,
+ profile_key: normalize_profile_key(profile_key),
+ connected_at: Utc::now(),
+ user_agent,
+ last_heartbeat_at: None,
+ last_page_info: None,
+ },
+ );
+ }
+
+ pub async fn unregister_observer(&self, client_id: &str) {
+ let pending = {
+ let mut inner = self.inner.lock().await;
+ inner.observers.remove(client_id);
+ take_pending_by_observer(&mut inner.pending_commands, client_id)
+ };
+ self.resolve_pending_with_disconnect(pending).await;
+ }
+
+ pub async fn register_control(
+ &self,
+ client_id: String,
+ user_agent: Option,
+ sender: mpsc::UnboundedSender,
+ ) {
+ let mut inner = self.inner.lock().await;
+ inner.controls.insert(
+ client_id,
+ ControlConnection {
+ sender,
+ connected_at: Utc::now(),
+ user_agent,
+ },
+ );
+ }
+
+ pub async fn unregister_control(&self, client_id: &str) {
+ let mut inner = self.inner.lock().await;
+ inner.controls.remove(client_id);
+ let pending_ids: Vec = inner
+ .pending_commands
+ .iter()
+ .filter_map(|(request_id, pending)| match &pending.source {
+ PendingSource::Control { control_client_id } if control_client_id == client_id => {
+ Some(request_id.clone())
+ }
+ _ => None,
+ })
+ .collect();
+
+ for request_id in pending_ids {
+ inner.pending_commands.remove(&request_id);
+ }
+ }
+
+ pub async fn execute_api_command(
+ &self,
+ request: ChromeBridgeCommandRequest,
+ ) -> Result {
+ self.sweep_expired_pending().await;
+ validate_command(&request.command, &request.url)?;
+
+ let timeout = normalize_timeout_ms(request.timeout_ms);
+ let source_client_id = format!("proxycast-api-{}", Uuid::new_v4());
+ let request_id = format!("cb-api-{}", Uuid::new_v4());
+
+ let (observer_id, observer_sender) = {
+ let inner = self.inner.lock().await;
+ select_observer(&inner.observers, request.profile_key.as_deref())
+ .ok_or_else(|| "没有可用的 Chrome observer 连接,请先连接扩展。".to_string())?
+ };
+
+ let command = request.command.trim().to_string();
+ let payload = build_command_payload(
+ &request_id,
+ &source_client_id,
+ &command,
+ request.target.clone(),
+ request.text.clone(),
+ request.url.clone(),
+ request.wait_for_page_info,
+ );
+
+ let (tx, rx) = oneshot::channel();
+ {
+ let mut inner = self.inner.lock().await;
+ inner.pending_commands.insert(
+ request_id.clone(),
+ PendingCommand {
+ request_id: request_id.clone(),
+ source: PendingSource::Api(tx),
+ command: command.clone(),
+ observer_client_id: observer_id.clone(),
+ wait_for_page_info: request.wait_for_page_info,
+ command_completed: false,
+ execution_message: None,
+ created_at: Utc::now(),
+ expires_at: Instant::now() + Duration::from_millis(timeout),
+ },
+ );
+ }
+
+ if observer_sender.send(payload.to_string()).is_err() {
+ let pending = {
+ let mut inner = self.inner.lock().await;
+ inner.pending_commands.remove(&request_id)
+ };
+ if let Some(pending_cmd) = pending {
+ self.dispatch_pending_result(
+ pending_cmd,
+ ChromeBridgeCommandResult {
+ success: false,
+ request_id,
+ command,
+ message: None,
+ error: Some("observer 通道发送失败,连接可能已断开。".to_string()),
+ page_info: None,
+ },
+ None,
+ )
+ .await;
+ }
+ return Err("observer 通道发送失败,连接可能已断开。".to_string());
+ }
+
+ let wait_result = tokio::time::timeout(Duration::from_millis(timeout), rx).await;
+ match wait_result {
+ Ok(Ok(result)) => Ok(result),
+ Ok(Err(_)) => Err("命令结果通道关闭,命令未完成。".to_string()),
+ Err(_) => {
+ let pending = {
+ let mut inner = self.inner.lock().await;
+ inner.pending_commands.remove(&request_id)
+ };
+ if let Some(pending_cmd) = pending {
+ self.dispatch_pending_result(
+ pending_cmd,
+ ChromeBridgeCommandResult {
+ success: false,
+ request_id,
+ command,
+ message: None,
+ error: Some("等待 Chrome 执行结果超时。".to_string()),
+ page_info: None,
+ },
+ None,
+ )
+ .await;
+ }
+ Err("等待 Chrome 执行结果超时。".to_string())
+ }
+ }
+ }
+
+ pub async fn handle_control_command(
+ &self,
+ control_client_id: &str,
+ payload: ControlCommandPayload,
+ ) {
+ self.sweep_expired_pending().await;
+
+ if let Err(error) = validate_command(&payload.command, &payload.url) {
+ self.send_control_error(control_client_id, &payload.request_id, &error)
+ .await;
+ return;
+ }
+
+ let (observer_id, observer_sender) = {
+ let inner = self.inner.lock().await;
+ select_observer(&inner.observers, None)
+ .map(|(id, sender)| (id, sender))
+ .unwrap_or_else(|| (String::new(), mpsc::unbounded_channel::().0))
+ };
+
+ if observer_id.is_empty() {
+ self.send_control_error(
+ control_client_id,
+ &payload.request_id,
+ "没有可用的 Chrome observer 连接。",
+ )
+ .await;
+ return;
+ }
+
+ let message = build_command_payload(
+ &payload.request_id,
+ control_client_id,
+ &payload.command,
+ payload.target,
+ payload.text,
+ payload.url,
+ payload.wait_for_page_info,
+ )
+ .to_string();
+
+ {
+ let mut inner = self.inner.lock().await;
+ inner.pending_commands.insert(
+ payload.request_id.clone(),
+ PendingCommand {
+ request_id: payload.request_id.clone(),
+ source: PendingSource::Control {
+ control_client_id: control_client_id.to_string(),
+ },
+ command: payload.command,
+ observer_client_id: observer_id,
+ wait_for_page_info: payload.wait_for_page_info,
+ command_completed: false,
+ execution_message: None,
+ created_at: Utc::now(),
+ expires_at: Instant::now() + Duration::from_millis(DEFAULT_COMMAND_TIMEOUT_MS),
+ },
+ );
+ }
+
+ if observer_sender.send(message).is_err() {
+ let pending = {
+ let mut inner = self.inner.lock().await;
+ inner.pending_commands.remove(&payload.request_id)
+ };
+ if let Some(pending_cmd) = pending {
+ self.dispatch_pending_result(
+ pending_cmd,
+ ChromeBridgeCommandResult {
+ success: false,
+ request_id: payload.request_id,
+ command: "unknown".to_string(),
+ message: None,
+ error: Some("observer 通道发送失败,连接可能已断开。".to_string()),
+ page_info: None,
+ },
+ None,
+ )
+ .await;
+ }
+ }
+ }
+
+ pub async fn handle_observer_heartbeat(&self, observer_client_id: &str) {
+ let sender = {
+ let mut inner = self.inner.lock().await;
+ if let Some(observer) = inner.observers.get_mut(observer_client_id) {
+ observer.last_heartbeat_at = Some(Utc::now());
+ Some(observer.sender.clone())
+ } else {
+ None
+ }
+ };
+
+ if let Some(observer_sender) = sender {
+ let _ = observer_sender.send(
+ json!({
+ "type": "heartbeat_ack",
+ "timestamp": Utc::now().timestamp_millis(),
+ })
+ .to_string(),
+ );
+ }
+ }
+
+ pub async fn handle_observer_command_result(
+ &self,
+ observer_client_id: &str,
+ payload: ObserverCommandResultPayload,
+ ) {
+ let status = payload.status.trim().to_ascii_lowercase();
+ let mut control_success_notice: Option<(String, String, String)> = None;
+
+ let pending = {
+ let mut inner = self.inner.lock().await;
+ let Some(existing) = inner.pending_commands.get(&payload.request_id) else {
+ return;
+ };
+
+ if existing.observer_client_id != observer_client_id {
+ return;
+ }
+
+ if status != "success" {
+ inner.pending_commands.remove(&payload.request_id)
+ } else {
+ let wait_for_page_info = inner
+ .pending_commands
+ .get(&payload.request_id)
+ .map(|pending| pending.wait_for_page_info)
+ .unwrap_or(false);
+
+ if wait_for_page_info {
+ if let Some(pending) = inner.pending_commands.get_mut(&payload.request_id) {
+ pending.command_completed = true;
+ pending.execution_message = payload.message.clone();
+
+ // control source 在成功后先回 command_result,再等待 pageInfoUpdate。
+ if let PendingSource::Control { control_client_id } = &pending.source {
+ let success_message = payload
+ .message
+ .clone()
+ .unwrap_or_else(|| "命令执行成功".to_string());
+ control_success_notice = Some((
+ control_client_id.clone(),
+ pending.request_id.clone(),
+ success_message,
+ ));
+ }
+ }
+ None
+ } else {
+ inner.pending_commands.remove(&payload.request_id)
+ }
+ }
+ };
+
+ if let Some((control_id, request_id, success_message)) = control_success_notice {
+ self.send_message_to_control(
+ &control_id,
+ json!({
+ "type": "command_result",
+ "data": {
+ "requestId": request_id,
+ "status": "success",
+ "message": success_message,
+ }
+ }),
+ )
+ .await;
+ }
+
+ if let Some(pending_cmd) = pending {
+ let result = if status == "success" {
+ ChromeBridgeCommandResult {
+ success: true,
+ request_id: payload.request_id,
+ command: pending_cmd.command.clone(),
+ message: payload.message.or(Some("命令执行成功".to_string())),
+ error: None,
+ page_info: None,
+ }
+ } else {
+ ChromeBridgeCommandResult {
+ success: false,
+ request_id: payload.request_id,
+ command: pending_cmd.command.clone(),
+ message: None,
+ error: payload.error.or(Some("命令执行失败".to_string())),
+ page_info: None,
+ }
+ };
+
+ self.dispatch_pending_result(pending_cmd, result, None)
+ .await;
+ }
+ }
+
+ pub async fn handle_observer_page_info_update(
+ &self,
+ observer_client_id: &str,
+ markdown: String,
+ ) {
+ let page_info = parse_page_info(markdown);
+
+ let pending_to_resolve = {
+ let mut inner = self.inner.lock().await;
+ if let Some(observer) = inner.observers.get_mut(observer_client_id) {
+ observer.last_page_info = Some(page_info.clone());
+ }
+
+ let request_ids: Vec = inner
+ .pending_commands
+ .iter()
+ .filter_map(|(request_id, pending)| {
+ if pending.observer_client_id == observer_client_id
+ && pending.wait_for_page_info
+ && pending.command_completed
+ {
+ Some(request_id.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ let mut removed = Vec::new();
+ for request_id in request_ids {
+ if let Some(pending) = inner.pending_commands.remove(&request_id) {
+ removed.push(pending);
+ }
+ }
+ removed
+ };
+
+ for pending_cmd in pending_to_resolve {
+ let result = ChromeBridgeCommandResult {
+ success: true,
+ request_id: pending_cmd.request_id.clone(),
+ command: pending_cmd.command.clone(),
+ message: pending_cmd.execution_message.clone(),
+ error: None,
+ page_info: Some(page_info.clone()),
+ };
+ self.dispatch_pending_result(pending_cmd, result, Some(true))
+ .await;
+ }
+ }
+
+ pub async fn get_status_snapshot(&self) -> ChromeBridgeStatusSnapshot {
+ self.sweep_expired_pending().await;
+ let inner = self.inner.lock().await;
+
+ let observers = inner
+ .observers
+ .iter()
+ .map(|(client_id, conn)| ChromeBridgeObserverSnapshot {
+ client_id: client_id.clone(),
+ profile_key: conn.profile_key.clone(),
+ connected_at: conn.connected_at.to_rfc3339(),
+ user_agent: conn.user_agent.clone(),
+ last_heartbeat_at: conn.last_heartbeat_at.map(|v| v.to_rfc3339()),
+ last_page_info: conn.last_page_info.clone(),
+ })
+ .collect::>();
+
+ let controls = inner
+ .controls
+ .iter()
+ .map(|(client_id, conn)| ChromeBridgeControlSnapshot {
+ client_id: client_id.clone(),
+ connected_at: conn.connected_at.to_rfc3339(),
+ user_agent: conn.user_agent.clone(),
+ })
+ .collect::>();
+
+ let pending_commands = inner
+ .pending_commands
+ .values()
+ .map(|pending| ChromeBridgePendingCommandSnapshot {
+ request_id: pending.request_id.clone(),
+ source_type: pending.source_type().to_string(),
+ command: pending.command.clone(),
+ observer_client_id: pending.observer_client_id.clone(),
+ wait_for_page_info: pending.wait_for_page_info,
+ command_completed: pending.command_completed,
+ created_at: pending.created_at.to_rfc3339(),
+ })
+ .collect::>();
+
+ ChromeBridgeStatusSnapshot {
+ observer_count: observers.len(),
+ control_count: controls.len(),
+ pending_command_count: pending_commands.len(),
+ observers,
+ controls,
+ pending_commands,
+ }
+ }
+
+ pub async fn send_message_to_control(&self, control_client_id: &str, message: Value) {
+ let sender = {
+ let inner = self.inner.lock().await;
+ inner
+ .controls
+ .get(control_client_id)
+ .map(|conn| conn.sender.clone())
+ };
+ if let Some(tx) = sender {
+ let _ = tx.send(message.to_string());
+ }
+ }
+
+ async fn send_control_error(&self, control_client_id: &str, request_id: &str, error: &str) {
+ self.send_message_to_control(
+ control_client_id,
+ json!({
+ "type": "command_result",
+ "data": {
+ "requestId": request_id,
+ "status": "error",
+ "error": truncate_message(error),
+ }
+ }),
+ )
+ .await;
+ }
+
+ async fn sweep_expired_pending(&self) {
+ let expired = {
+ let mut inner = self.inner.lock().await;
+ let now = Instant::now();
+ let expired_ids: Vec = inner
+ .pending_commands
+ .iter()
+ .filter_map(|(request_id, pending)| {
+ if pending.expires_at <= now {
+ Some(request_id.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ let mut expired = Vec::new();
+ for request_id in expired_ids {
+ if let Some(pending) = inner.pending_commands.remove(&request_id) {
+ expired.push(pending);
+ }
+ }
+ expired
+ };
+
+ for pending in expired {
+ let result = ChromeBridgeCommandResult {
+ success: false,
+ request_id: pending.request_id.clone(),
+ command: pending.command.clone(),
+ message: None,
+ error: Some("命令执行超时。".to_string()),
+ page_info: None,
+ };
+ self.dispatch_pending_result(pending, result, None).await;
+ }
+ }
+
+ async fn resolve_pending_with_disconnect(&self, pendings: Vec) {
+ for pending in pendings {
+ let result = ChromeBridgeCommandResult {
+ success: false,
+ request_id: pending.request_id.clone(),
+ command: pending.command.clone(),
+ message: None,
+ error: Some("observer 已断开连接。".to_string()),
+ page_info: None,
+ };
+ self.dispatch_pending_result(pending, result, None).await;
+ }
+ }
+
+ async fn dispatch_pending_result(
+ &self,
+ pending: PendingCommand,
+ mut result: ChromeBridgeCommandResult,
+ from_page_info_update: Option,
+ ) {
+ result.message = result.message.map(|v| truncate_message(&v));
+ result.error = result.error.map(|v| truncate_message(&v));
+
+ match pending.source {
+ PendingSource::Api(tx) => {
+ let _ = tx.send(result);
+ }
+ PendingSource::Control { control_client_id } => {
+ let is_page_info_event = from_page_info_update.unwrap_or(false);
+ if is_page_info_event {
+ let page_payload = result.page_info.clone().map(|page| {
+ json!({
+ "requestId": result.request_id,
+ "markdown": page.markdown,
+ "title": page.title,
+ "url": page.url,
+ "updatedAt": page.updated_at,
+ })
+ });
+ if let Some(page_data) = page_payload {
+ self.send_message_to_control(
+ &control_client_id,
+ json!({
+ "type": "page_info_update",
+ "data": page_data,
+ }),
+ )
+ .await;
+ }
+ return;
+ }
+
+ if result.success {
+ self.send_message_to_control(
+ &control_client_id,
+ json!({
+ "type": "command_result",
+ "data": {
+ "requestId": result.request_id,
+ "status": "success",
+ "message": result.message,
+ }
+ }),
+ )
+ .await;
+ } else {
+ self.send_message_to_control(
+ &control_client_id,
+ json!({
+ "type": "command_result",
+ "data": {
+ "requestId": result.request_id,
+ "status": "error",
+ "error": result.error,
+ }
+ }),
+ )
+ .await;
+ }
+ }
+ }
+ }
+}
+
+impl Default for ChromeBridgeHub {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+fn validate_command(command: &str, url: &Option) -> Result<(), String> {
+ let normalized = command.trim().to_ascii_lowercase();
+ if !COMMAND_WHITELIST.contains(&normalized.as_str()) {
+ return Err(format!(
+ "不允许的命令: {},仅允许: {}",
+ command,
+ COMMAND_WHITELIST.join(", ")
+ ));
+ }
+
+ if normalized == "open_url" {
+ let Some(url_value) = url else {
+ return Err("open_url 命令需要提供 url。".to_string());
+ };
+ if url_value.trim().is_empty() {
+ return Err("open_url 命令的 url 不能为空。".to_string());
+ }
+ }
+
+ Ok(())
+}
+
+fn take_pending_by_observer(
+ pending_map: &mut HashMap,
+ observer_client_id: &str,
+) -> Vec {
+ let request_ids: Vec = pending_map
+ .iter()
+ .filter_map(|(request_id, pending)| {
+ if pending.observer_client_id == observer_client_id {
+ Some(request_id.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ let mut removed = Vec::new();
+ for request_id in request_ids {
+ if let Some(pending) = pending_map.remove(&request_id) {
+ removed.push(pending);
+ }
+ }
+ removed
+}
+
+fn normalize_timeout_ms(input: Option) -> u64 {
+ input
+ .unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS)
+ .clamp(MIN_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS)
+}
+
+fn normalize_profile_key(input: Option) -> String {
+ let raw = input.unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string());
+ let normalized: String = raw
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
+ ch
+ } else {
+ '_'
+ }
+ })
+ .collect();
+
+ if normalized.trim_matches('_').is_empty() {
+ DEFAULT_PROFILE_KEY.to_string()
+ } else {
+ normalized
+ }
+}
+
+fn select_observer(
+ observers: &HashMap,
+ profile_key: Option<&str>,
+) -> Option<(String, mpsc::UnboundedSender)> {
+ if observers.is_empty() {
+ return None;
+ }
+
+ if let Some(profile_key_value) = profile_key {
+ let normalized_profile = normalize_profile_key(Some(profile_key_value.to_string()));
+ if let Some((client_id, conn)) = observers
+ .iter()
+ .find(|(_, conn)| conn.profile_key == normalized_profile)
+ {
+ return Some((client_id.clone(), conn.sender.clone()));
+ }
+ }
+
+ observers
+ .iter()
+ .next()
+ .map(|(client_id, conn)| (client_id.clone(), conn.sender.clone()))
+}
+
+fn build_command_payload(
+ request_id: &str,
+ source_client_id: &str,
+ command: &str,
+ target: Option,
+ text: Option,
+ url: Option,
+ wait_for_page_info: bool,
+) -> Value {
+ json!({
+ "type": "command",
+ "data": {
+ "requestId": request_id,
+ "sourceClientId": source_client_id,
+ "command": command,
+ "target": target,
+ "text": text,
+ "url": url,
+ "wait_for_page_info": wait_for_page_info,
+ }
+ })
+}
+
+fn parse_page_info(markdown: String) -> ChromeBridgePageInfo {
+ let mut title = None;
+ let mut url = None;
+
+ for line in markdown.lines().take(6) {
+ let trimmed = line.trim();
+ if title.is_none() && trimmed.starts_with('#') {
+ let extracted = trimmed.trim_start_matches('#').trim();
+ if !extracted.is_empty() {
+ title = Some(extracted.to_string());
+ }
+ }
+ if url.is_none() {
+ let lower = trimmed.to_ascii_lowercase();
+ if lower.starts_with("url:") {
+ let extracted = trimmed[4..].trim();
+ if !extracted.is_empty() {
+ url = Some(extracted.to_string());
+ }
+ }
+ }
+ if title.is_some() && url.is_some() {
+ break;
+ }
+ }
+
+ ChromeBridgePageInfo {
+ title,
+ url,
+ markdown,
+ updated_at: Utc::now().to_rfc3339(),
+ }
+}
+
+fn truncate_message(input: &str) -> String {
+ let mut chars = input.chars();
+ let truncated: String = chars.by_ref().take(DEFAULT_MESSAGE_PREVIEW_LIMIT).collect();
+ if chars.next().is_some() {
+ format!("{}...", truncated)
+ } else {
+ truncated
+ }
+}
+
+static CHROME_BRIDGE_HUB: Lazy> =
+ Lazy::new(|| Arc::new(ChromeBridgeHub::new()));
+
+pub fn chrome_bridge_hub() -> Arc {
+ CHROME_BRIDGE_HUB.clone()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_normalize_profile_key() {
+ assert_eq!(
+ normalize_profile_key(Some("search/google".to_string())),
+ "search_google"
+ );
+ assert_eq!(normalize_profile_key(Some("___".to_string())), "default");
+ assert_eq!(normalize_profile_key(None), "default");
+ }
+
+ #[test]
+ fn should_validate_command_whitelist() {
+ assert!(validate_command("open_url", &Some("https://example.com".to_string())).is_ok());
+ assert!(validate_command("click", &None).is_ok());
+ assert!(validate_command("eval_js", &None).is_err());
+ }
+
+ #[test]
+ fn should_parse_page_info() {
+ let markdown = "# Title\nURL: https://example.com\ncontent".to_string();
+ let parsed = parse_page_info(markdown.clone());
+ assert_eq!(parsed.title.as_deref(), Some("Title"));
+ assert_eq!(parsed.url.as_deref(), Some("https://example.com"));
+ assert_eq!(parsed.markdown, markdown);
+ }
+
+ #[tokio::test]
+ async fn api_command_should_timeout_without_result() {
+ let hub = ChromeBridgeHub::new();
+ let (observer_tx, _observer_rx) = mpsc::unbounded_channel::();
+ hub.register_observer(
+ "observer-a".to_string(),
+ Some("search_google".to_string()),
+ None,
+ observer_tx,
+ )
+ .await;
+
+ let result = hub
+ .execute_api_command(ChromeBridgeCommandRequest {
+ profile_key: Some("search_google".to_string()),
+ command: "click".to_string(),
+ target: Some("#btn".to_string()),
+ text: None,
+ url: None,
+ wait_for_page_info: false,
+ timeout_ms: Some(10),
+ })
+ .await;
+
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn should_cleanup_pending_when_observer_disconnects() {
+ let hub = Arc::new(ChromeBridgeHub::new());
+
+ let (observer_tx, _observer_rx) = mpsc::unbounded_channel::();
+ hub.register_observer(
+ "observer-a".to_string(),
+ Some("search_google".to_string()),
+ None,
+ observer_tx,
+ )
+ .await;
+
+ let (result_tx, result_rx) = oneshot::channel();
+ {
+ let mut inner = hub.inner.lock().await;
+ inner.pending_commands.insert(
+ "req-1".to_string(),
+ PendingCommand {
+ request_id: "req-1".to_string(),
+ source: PendingSource::Api(result_tx),
+ command: "click".to_string(),
+ observer_client_id: "observer-a".to_string(),
+ wait_for_page_info: false,
+ command_completed: false,
+ execution_message: None,
+ created_at: Utc::now(),
+ expires_at: Instant::now() + Duration::from_secs(30),
+ },
+ );
+ }
+
+ hub.unregister_observer("observer-a").await;
+ let result = result_rx.await.expect("must receive disconnect result");
+ assert!(!result.success);
+ assert!(result.error.unwrap_or_default().contains("observer"));
+ }
+
+ #[tokio::test]
+ async fn wait_for_page_info_should_resolve_after_update() {
+ let hub = Arc::new(ChromeBridgeHub::new());
+ let (observer_tx, _observer_rx) = mpsc::unbounded_channel::();
+ hub.register_observer(
+ "observer-a".to_string(),
+ Some("default".to_string()),
+ None,
+ observer_tx,
+ )
+ .await;
+
+ let (result_tx, result_rx) = oneshot::channel();
+ {
+ let mut inner = hub.inner.lock().await;
+ inner.pending_commands.insert(
+ "req-2".to_string(),
+ PendingCommand {
+ request_id: "req-2".to_string(),
+ source: PendingSource::Api(result_tx),
+ command: "open_url".to_string(),
+ observer_client_id: "observer-a".to_string(),
+ wait_for_page_info: true,
+ command_completed: true,
+ execution_message: Some("ok".to_string()),
+ created_at: Utc::now(),
+ expires_at: Instant::now() + Duration::from_secs(30),
+ },
+ );
+ }
+
+ hub.handle_observer_page_info_update(
+ "observer-a",
+ "# T\nURL: https://example.com".to_string(),
+ )
+ .await;
+
+ let result = result_rx.await.expect("must receive page info result");
+ assert!(result.success);
+ assert!(result.page_info.is_some());
+ }
+}
diff --git a/src-tauri/crates/server/src/handlers/chrome_bridge_ws.rs b/src-tauri/crates/server/src/handlers/chrome_bridge_ws.rs
new file mode 100644
index 000000000..5d61dd80f
--- /dev/null
+++ b/src-tauri/crates/server/src/handlers/chrome_bridge_ws.rs
@@ -0,0 +1,260 @@
+//! ChromeBridge WebSocket 路由处理器
+
+use crate::chrome_bridge::{
+ chrome_bridge_hub, ControlCommandPayload, ObserverCommandResultPayload,
+};
+use crate::AppState;
+use axum::extract::{
+ ws::{Message, WebSocket},
+ Path, Query, State, WebSocketUpgrade,
+};
+use axum::http::HeaderMap;
+use axum::response::IntoResponse;
+use futures::{SinkExt, StreamExt};
+use serde::Deserialize;
+use serde_json::Value;
+use tokio::sync::mpsc;
+use uuid::Uuid;
+
+#[derive(Debug, Deserialize, Default)]
+pub struct ObserverQuery {
+ #[serde(default, alias = "profileKey")]
+ pub profile_key: Option,
+}
+
+pub async fn chrome_observer_ws_upgrade(
+ ws: WebSocketUpgrade,
+ State(state): State,
+ Path(proxycast_key): Path,
+ Query(query): Query,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ if proxycast_key != state.api_key {
+ return axum::http::Response::builder()
+ .status(401)
+ .body(axum::body::Body::from("Invalid Proxycast_Key"))
+ .unwrap()
+ .into_response();
+ }
+
+ let user_agent = headers
+ .get("user-agent")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_string());
+
+ ws.on_upgrade(move |socket| {
+ handle_observer_socket(socket, query.profile_key.clone(), user_agent)
+ })
+}
+
+pub async fn chrome_control_ws_upgrade(
+ ws: WebSocketUpgrade,
+ State(state): State,
+ Path(proxycast_key): Path,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ if proxycast_key != state.api_key {
+ return axum::http::Response::builder()
+ .status(401)
+ .body(axum::body::Body::from("Invalid Proxycast_Key"))
+ .unwrap()
+ .into_response();
+ }
+
+ let user_agent = headers
+ .get("user-agent")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_string());
+
+ ws.on_upgrade(move |socket| handle_control_socket(socket, user_agent))
+}
+
+async fn handle_observer_socket(
+ socket: WebSocket,
+ profile_key: Option,
+ user_agent: Option,
+) {
+ let hub = chrome_bridge_hub();
+ let client_id = format!("observer-{}", Uuid::new_v4());
+
+ let (mut ws_sender, mut ws_receiver) = socket.split();
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+
+ hub.register_observer(
+ client_id.clone(),
+ profile_key.clone(),
+ user_agent.clone(),
+ tx.clone(),
+ )
+ .await;
+
+ let _ = tx.send(
+ serde_json::json!({
+ "type": "connection_ack",
+ "message": "Chrome observer connected",
+ "data": {
+ "clientId": client_id,
+ "profileKey": profile_key,
+ }
+ })
+ .to_string(),
+ );
+
+ let send_task = tokio::spawn(async move {
+ while let Some(message) = rx.recv().await {
+ if ws_sender.send(Message::Text(message)).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ while let Some(Ok(message)) = ws_receiver.next().await {
+ match message {
+ Message::Text(text) => {
+ if let Ok(value) = serde_json::from_str::(&text) {
+ handle_observer_message(&hub, &client_id, value).await;
+ }
+ }
+ Message::Ping(payload) => {
+ let _ = tx.send(
+ serde_json::json!({
+ "type": "pong",
+ "timestamp": chrono::Utc::now().timestamp_millis(),
+ "payload_len": payload.len(),
+ })
+ .to_string(),
+ );
+ }
+ Message::Pong(_) => {}
+ Message::Binary(_) => {}
+ Message::Close(_) => break,
+ }
+ }
+
+ send_task.abort();
+ hub.unregister_observer(&client_id).await;
+}
+
+async fn handle_control_socket(socket: WebSocket, user_agent: Option) {
+ let hub = chrome_bridge_hub();
+ let client_id = format!("control-{}", Uuid::new_v4());
+
+ let (mut ws_sender, mut ws_receiver) = socket.split();
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+
+ hub.register_control(client_id.clone(), user_agent.clone(), tx.clone())
+ .await;
+
+ let _ = tx.send(
+ serde_json::json!({
+ "type": "connection_ack",
+ "message": "Chrome control connected",
+ "data": {
+ "clientId": client_id,
+ }
+ })
+ .to_string(),
+ );
+
+ let send_task = tokio::spawn(async move {
+ while let Some(message) = rx.recv().await {
+ if ws_sender.send(Message::Text(message)).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ while let Some(Ok(message)) = ws_receiver.next().await {
+ match message {
+ Message::Text(text) => {
+ if let Ok(value) = serde_json::from_str::(&text) {
+ handle_control_message(&hub, &client_id, value).await;
+ }
+ }
+ Message::Ping(payload) => {
+ let _ = tx.send(
+ serde_json::json!({
+ "type": "pong",
+ "timestamp": chrono::Utc::now().timestamp_millis(),
+ "payload_len": payload.len(),
+ })
+ .to_string(),
+ );
+ }
+ Message::Pong(_) => {}
+ Message::Binary(_) => {}
+ Message::Close(_) => break,
+ }
+ }
+
+ send_task.abort();
+ hub.unregister_control(&client_id).await;
+}
+
+async fn handle_observer_message(
+ hub: &std::sync::Arc,
+ observer_client_id: &str,
+ payload: Value,
+) {
+ let msg_type = payload
+ .get("type")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string();
+
+ match msg_type.as_str() {
+ "heartbeat" => {
+ hub.handle_observer_heartbeat(observer_client_id).await;
+ }
+ "pageInfoUpdate" => {
+ let markdown = payload
+ .get("data")
+ .and_then(|v| v.get("markdown"))
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string();
+ hub.handle_observer_page_info_update(observer_client_id, markdown)
+ .await;
+ }
+ "command_result" => {
+ let data = payload.get("data").cloned().unwrap_or(Value::Null);
+ if let Ok(parsed) = serde_json::from_value::(data) {
+ hub.handle_observer_command_result(observer_client_id, parsed)
+ .await;
+ }
+ }
+ _ => {}
+ }
+}
+
+async fn handle_control_message(
+ hub: &std::sync::Arc,
+ control_client_id: &str,
+ payload: Value,
+) {
+ let msg_type = payload
+ .get("type")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string();
+
+ match msg_type.as_str() {
+ "command" => {
+ let data = payload.get("data").cloned().unwrap_or(Value::Null);
+ if let Ok(parsed) = serde_json::from_value::(data) {
+ hub.handle_control_command(control_client_id, parsed).await;
+ }
+ }
+ "heartbeat" => {
+ hub.send_message_to_control(
+ control_client_id,
+ serde_json::json!({
+ "type": "heartbeat_ack",
+ "timestamp": chrono::Utc::now().timestamp_millis(),
+ }),
+ )
+ .await;
+ }
+ _ => {}
+ }
+}
diff --git a/src-tauri/crates/server/src/handlers/mod.rs b/src-tauri/crates/server/src/handlers/mod.rs
index 37353e52e..cc742c66a 100644
--- a/src-tauri/crates/server/src/handlers/mod.rs
+++ b/src-tauri/crates/server/src/handlers/mod.rs
@@ -6,6 +6,7 @@ pub mod api;
pub mod api_key_provider_utils;
pub mod batch_api;
pub mod batch_executor;
+pub mod chrome_bridge_ws;
pub mod credentials_api;
pub mod image_handler;
pub mod kiro_credential;
@@ -14,6 +15,7 @@ pub mod websocket;
pub use api::*;
pub use batch_api::*;
+pub use chrome_bridge_ws::*;
pub use credentials_api::*;
pub use image_handler::*;
// 避免 SelectCredentialRequest 歧义 glob re-export(credentials_api 和 kiro_credential 都定义了同名类型)
diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs
index f4fdc197f..3c7c4a2c5 100644
--- a/src-tauri/crates/server/src/lib.rs
+++ b/src-tauri/crates/server/src/lib.rs
@@ -1,6 +1,7 @@
//! HTTP API 服务器
pub mod auth;
+pub mod chrome_bridge;
pub mod client_detector;
pub mod middleware;
@@ -1040,6 +1041,14 @@ async fn run_server(
// WebSocket 路由
.route("/v1/ws", get(handlers::ws_upgrade_handler))
.route("/ws", get(handlers::ws_upgrade_handler))
+ .route(
+ "/proxycast-chrome-observer/:proxycast_key",
+ get(handlers::chrome_observer_ws_upgrade),
+ )
+ .route(
+ "/proxycast-chrome-control/:proxycast_key",
+ get(handlers::chrome_control_ws_upgrade),
+ )
// 多供应商路由
.route(
"/{selector}/v1/messages",
diff --git a/src-tauri/crates/voice-core/src/threaded_recorder.rs b/src-tauri/crates/voice-core/src/threaded_recorder.rs
index 837879a5d..5a39b26f6 100644
--- a/src-tauri/crates/voice-core/src/threaded_recorder.rs
+++ b/src-tauri/crates/voice-core/src/threaded_recorder.rs
@@ -325,7 +325,7 @@ fn recording_thread_main(
let count = callback_count_clone.fetch_add(1, Ordering::SeqCst);
if count == 0 {
tracing::info!("[录音线程] 首次收到音频数据,数据长度: {}", data.len());
- } else if count % 100 == 0 {
+ } else if count.is_multiple_of(100) {
tracing::debug!("[录音线程] 已收到 {} 次音频回调", count);
}
@@ -338,7 +338,7 @@ fn recording_thread_main(
let level = ((rms * 1500.0).min(100.0)) as u32;
// 每 50 次回调打印一次音量(用于调试)
- if count % 50 == 0 {
+ if count.is_multiple_of(50) {
tracing::debug!("[录音线程] RMS: {:.6}, 音量: {}%", rms, level);
}
diff --git a/src-tauri/crates/voice-core/src/types.rs b/src-tauri/crates/voice-core/src/types.rs
index f64405d7d..300338498 100644
--- a/src-tauri/crates/voice-core/src/types.rs
+++ b/src-tauri/crates/voice-core/src/types.rs
@@ -143,17 +143,13 @@ impl WhisperModel {
/// 输出模式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
+#[derive(Default)]
pub enum OutputMode {
/// 模拟键盘输入
+ #[default]
Type,
/// 复制到剪贴板
Clipboard,
/// 两者都做
Both,
}
-
-impl Default for OutputMode {
- fn default() -> Self {
- Self::Type
- }
-}
diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs
index e3f00fbdb..147c96849 100644
--- a/src-tauri/src/app/bootstrap.rs
+++ b/src-tauri/src/app/bootstrap.rs
@@ -20,7 +20,9 @@ use crate::commands::session_files_cmd::SessionFilesState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::terminal_cmd::TerminalManagerState;
use crate::commands::tool_hooks::ToolHooksServiceState;
-use crate::commands::webview_cmd::{WebviewManagerState, WebviewManagerWrapper};
+use crate::commands::webview_cmd::{
+ ChromeProfileManagerWrapper, WebviewManagerState, WebviewManagerWrapper,
+};
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database::{self, DbConnection};
use crate::logger;
@@ -67,6 +69,7 @@ pub struct AppStates {
pub global_config_manager: GlobalConfigManagerState,
pub terminal_manager: TerminalManagerState,
pub webview_manager: WebviewManagerWrapper,
+ pub chrome_profile_manager: ChromeProfileManagerWrapper,
pub update_check_service: UpdateCheckServiceState,
pub session_files: SessionFilesState,
pub context_memory_service: ContextMemoryServiceState,
@@ -211,6 +214,8 @@ pub fn init_states(config: &Config) -> Result {
// 初始化 Webview 管理器状态
let webview_manager_state =
WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new())));
+ let chrome_profile_manager_state =
+ ChromeProfileManagerWrapper(crate::commands::webview_cmd::shared_chrome_profile_manager());
// 初始化更新检查服务
let update_check_service_state = UpdateCheckServiceState::new();
@@ -278,6 +283,7 @@ pub fn init_states(config: &Config) -> Result {
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
+ chrome_profile_manager: chrome_profile_manager_state,
update_check_service: update_check_service_state,
session_files: session_files_state,
context_memory_service: context_memory_service_state,
diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs
index d02eae5f5..c3ba829a2 100644
--- a/src-tauri/src/app/runner.rs
+++ b/src-tauri/src/app/runner.rs
@@ -68,6 +68,7 @@ pub fn run() {
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
+ chrome_profile_manager: chrome_profile_manager_state,
update_check_service: update_check_service_state,
session_files: session_files_state,
context_memory_service,
@@ -143,6 +144,7 @@ pub fn run() {
.manage(global_config_manager_state)
.manage(terminal_manager_state)
.manage(webview_manager_state)
+ .manage(chrome_profile_manager_state)
.manage(update_check_service_state)
.manage(session_files_state)
.manage(context_memory_service)
@@ -1168,6 +1170,17 @@ pub fn run() {
commands::webview_cmd::resize_webview_panel,
commands::webview_cmd::get_webview_panels,
commands::webview_cmd::focus_webview_panel,
+ commands::webview_cmd::open_chrome_profile_window,
+ commands::webview_cmd::get_chrome_profile_sessions,
+ commands::webview_cmd::close_chrome_profile_session,
+ commands::webview_cmd::get_chrome_bridge_endpoint_info,
+ commands::webview_cmd::get_chrome_bridge_status,
+ commands::webview_cmd::chrome_bridge_execute_command,
+ commands::webview_cmd::get_browser_backends_status,
+ commands::webview_cmd::get_browser_backend_policy,
+ commands::webview_cmd::set_browser_backend_policy,
+ commands::webview_cmd::browser_execute_action,
+ commands::webview_cmd::get_browser_action_audit_logs,
// Screenshot Chat commands
// _Requirements: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_
commands::screenshot_cmd::get_experimental_config,
@@ -1307,6 +1320,18 @@ pub fn run() {
commands::content_cmd::content_delete,
commands::content_cmd::content_reorder,
commands::content_cmd::content_stats,
+ // Novel Orchestrator commands
+ commands::novel_cmd::novel_create_project,
+ commands::novel_cmd::novel_update_settings,
+ commands::novel_cmd::novel_generate_outline,
+ commands::novel_cmd::novel_generate_characters,
+ commands::novel_cmd::novel_generate_chapter,
+ commands::novel_cmd::novel_continue_chapter,
+ commands::novel_cmd::novel_rewrite_chapter,
+ commands::novel_cmd::novel_polish_chapter,
+ commands::novel_cmd::novel_check_consistency,
+ commands::novel_cmd::novel_get_project_snapshot,
+ commands::novel_cmd::novel_list_runs,
// Memory commands (Character, WorldBuilding, StyleGuide, Outline)
commands::memory_cmd::character_create,
commands::memory_cmd::character_get,
diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs
index 141c36ef8..b839cb1ac 100644
--- a/src-tauri/src/commands/agent_cmd.rs
+++ b/src-tauri/src/commands/agent_cmd.rs
@@ -4,10 +4,12 @@
//! 内部使用 Aster Agent 实现
use crate::agent::{AgentMessage, AgentSession, AsterAgentState};
+use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
use crate::config::GlobalConfigManagerState;
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
+use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
use crate::workspace::WorkspaceManager;
use crate::AppState;
use serde::{Deserialize, Serialize};
@@ -95,6 +97,7 @@ pub async fn agent_start_process(
};
agent_state.init_agent_with_db(&db).await?;
+ ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
let base_url = if gateway_running {
Some(format!("http://{host}:{port}"))
} else {
@@ -196,6 +199,7 @@ pub async fn agent_create_session(
// 初始化 Agent(使用带数据库的版本)
agent_state.init_agent_with_db(&db).await?;
+ ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
// 生成会话 ID
let session_id = uuid::Uuid::new_v4().to_string();
@@ -211,8 +215,9 @@ pub async fn agent_create_session(
// 构建包含 Skills 的 System Prompt,并附加记忆画像偏好
let base_system_prompt = build_system_prompt_with_skills(system_prompt, skills.as_ref());
- let final_system_prompt =
- merge_system_prompt_with_memory_profile(base_system_prompt, &config_manager.config());
+ let config = config_manager.config();
+ let prompt_with_memory = merge_system_prompt_with_memory_profile(base_system_prompt, &config);
+ let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config);
// 保存会话到数据库
let now = chrono::Utc::now().to_rfc3339();
diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs
index 6f2a0c1bf..93b892359 100644
--- a/src-tauri/src/commands/aster_agent_cmd.rs
+++ b/src-tauri/src/commands/aster_agent_cmd.rs
@@ -9,6 +9,9 @@ use crate::agent::{
AsterAgentState, AsterAgentWrapper, HeartbeatServiceAdapter, SessionDetail, SessionInfo,
TauriAgentEvent,
};
+use crate::commands::webview_cmd::{
+ browser_execute_action_global, BrowserActionRequest, BrowserBackendType,
+};
use crate::config::GlobalConfigManagerState;
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
@@ -16,9 +19,11 @@ use crate::mcp::{McpManagerState, McpServerConfig};
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOptions, RunSource};
use crate::services::heartbeat_service::HeartbeatServiceState;
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
+use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
use crate::workspace::WorkspaceManager;
use aster::agents::extension::{Envs, ExtensionConfig};
use aster::agents::{Agent, AgentEvent};
+use aster::chrome_mcp::get_chrome_mcp_tools;
use aster::conversation::message::{Message, MessageContent};
use aster::permission::{
ParameterRestriction, PermissionScope, RestrictionType, ToolPermission, ToolPermissionManager,
@@ -175,6 +180,7 @@ pub async fn aster_agent_init(
tracing::info!("[AsterAgent] 初始化 Agent");
state.init_agent_with_db(&db).await?;
+ ensure_browser_mcp_tools_registered(state.inner()).await?;
let provider_config = state.get_provider_config().await;
@@ -837,6 +843,159 @@ impl Tool for WorkspaceTaskTool {
}
}
+#[derive(Debug, Clone)]
+struct ProxycastBrowserMcpTool {
+ tool_name: String,
+ action_name: String,
+ description: String,
+ input_schema: serde_json::Value,
+}
+
+impl ProxycastBrowserMcpTool {
+ fn new(
+ tool_name: String,
+ action_name: String,
+ description: String,
+ input_schema: serde_json::Value,
+ ) -> Self {
+ Self {
+ tool_name,
+ action_name,
+ description,
+ input_schema,
+ }
+ }
+
+ fn parse_backend(params: &serde_json::Value) -> Option {
+ let raw = params.get("backend")?.as_str()?.trim().to_ascii_lowercase();
+ match raw.as_str() {
+ "aster_compat" => Some(BrowserBackendType::AsterCompat),
+ "proxycast_extension_bridge" => Some(BrowserBackendType::ProxycastExtensionBridge),
+ "cdp_direct" => Some(BrowserBackendType::CdpDirect),
+ _ => None,
+ }
+ }
+
+ fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option {
+ if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) {
+ let trimmed = value.trim();
+ if !trimmed.is_empty() {
+ return Some(trimmed.to_string());
+ }
+ }
+ context
+ .environment
+ .get("PROXYCAST_BROWSER_PROFILE_KEY")
+ .cloned()
+ }
+}
+
+#[async_trait]
+impl Tool for ProxycastBrowserMcpTool {
+ fn name(&self) -> &str {
+ &self.tool_name
+ }
+
+ fn description(&self) -> &str {
+ &self.description
+ }
+
+ fn input_schema(&self) -> serde_json::Value {
+ self.input_schema.clone()
+ }
+
+ fn options(&self) -> ToolOptions {
+ ToolOptions::new()
+ .with_max_retries(1)
+ .with_base_timeout(Duration::from_secs(90))
+ .with_dynamic_timeout(false)
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ _context: &ToolContext,
+ ) -> Result {
+ let backend = Self::parse_backend(¶ms);
+ let profile_key = Self::extract_profile_key(¶ms, _context);
+ let timeout_ms = params.get("timeout_ms").and_then(|v| v.as_u64());
+ let request = BrowserActionRequest {
+ profile_key,
+ backend,
+ action: self.action_name.clone(),
+ args: params,
+ timeout_ms,
+ };
+
+ let result = browser_execute_action_global(request)
+ .await
+ .map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?;
+
+ let payload = serde_json::to_string_pretty(&result)
+ .unwrap_or_else(|_| format!("{{\"success\": {}}}", result.success));
+
+ if result.success {
+ Ok(ToolResult::success(payload)
+ .with_metadata("action", serde_json::json!(self.action_name))
+ .with_metadata("selected_backend", serde_json::json!(result.backend))
+ .with_metadata("attempt_count", serde_json::json!(result.attempts.len())))
+ } else {
+ Ok(ToolResult::error(
+ result
+ .error
+ .clone()
+ .unwrap_or_else(|| "浏览器动作执行失败".to_string()),
+ )
+ .with_metadata("action", serde_json::json!(self.action_name))
+ .with_metadata("selected_backend", serde_json::json!(result.backend))
+ .with_metadata("attempts", serde_json::json!(result.attempts))
+ .with_metadata("result", serde_json::json!(result)))
+ }
+ }
+}
+
+fn browser_mcp_tool_names() -> Vec {
+ let mut names = Vec::new();
+ for tool in get_chrome_mcp_tools() {
+ names.push(format!("mcp__proxycast-browser__{}", tool.name));
+ names.push(format!("mcp__claude-in-chrome__{}", tool.name));
+ }
+ names
+}
+
+fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
+ let tool_defs = get_chrome_mcp_tools();
+ for tool_def in tool_defs {
+ for prefix in ["mcp__proxycast-browser__", "mcp__claude-in-chrome__"] {
+ let full_name = format!("{prefix}{}", tool_def.name);
+ if registry.contains(&full_name) {
+ continue;
+ }
+ let tool = ProxycastBrowserMcpTool::new(
+ full_name,
+ tool_def.name.clone(),
+ tool_def.description.clone(),
+ tool_def.input_schema.clone(),
+ );
+ registry.register(Box::new(tool));
+ }
+ }
+}
+
+pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Result<(), String> {
+ let agent_arc = state.get_agent_arc();
+ let guard = agent_arc.read().await;
+ let agent = guard
+ .as_ref()
+ .ok_or_else(|| "Agent not initialized".to_string())?;
+ let registry_arc = agent.tool_registry().clone();
+ drop(guard);
+
+ let mut registry = registry_arc.write().await;
+ register_browser_mcp_tools_to_registry(&mut registry);
+ Ok(())
+}
+
fn build_workspace_shell_allow_pattern(
escaped_root: &str,
allow_extended_shell_commands: bool,
@@ -1312,6 +1471,20 @@ async fn apply_workspace_sandbox_permissions(
});
}
+ for tool_name in browser_mcp_tool_names() {
+ permissions.push(ToolPermission {
+ tool: tool_name,
+ allowed: true,
+ priority: 88,
+ conditions: Vec::new(),
+ parameter_restrictions: Vec::new(),
+ scope: PermissionScope::Session,
+ reason: Some("允许浏览器 MCP 兼容工具".to_string()),
+ expires_at: None,
+ metadata: HashMap::new(),
+ });
+ }
+
permissions.push(ToolPermission {
tool: "*".to_string(),
allowed: false,
@@ -1364,6 +1537,9 @@ async fn apply_workspace_sandbox_permissions(
let heartbeat_tool = proxycast_agent::tools::HeartbeatTool::new(Arc::new(heartbeat_adapter));
registry.register(Box::new(heartbeat_tool));
+ // 注册浏览器 MCP 兼容工具(两套前缀:proxycast / claude-in-chrome)
+ register_browser_mcp_tools_to_registry(&mut registry);
+
Ok(apply_outcome)
}
@@ -1522,8 +1698,11 @@ pub async fn aster_agent_chat_stream(
}
};
- let merged_prompt =
- merge_system_prompt_with_memory_profile(resolved_prompt, &config_manager.config());
+ let config = config_manager.config();
+ let merged_prompt = merge_system_prompt_with_web_search(
+ merge_system_prompt_with_memory_profile(resolved_prompt, &config),
+ &config,
+ );
(merged_prompt, persisted)
};
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 524023ffd..34a306f92 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -30,6 +30,7 @@ pub mod model_registry_cmd;
pub mod models_cmd;
pub mod music_cmd;
pub mod network_cmd;
+pub mod novel_cmd;
pub mod oauth_cmd;
pub mod orchestrator_cmd;
pub mod persona_cmd;
diff --git a/src-tauri/src/commands/novel_cmd.rs b/src-tauri/src/commands/novel_cmd.rs
new file mode 100644
index 000000000..f07d35750
--- /dev/null
+++ b/src-tauri/src/commands/novel_cmd.rs
@@ -0,0 +1,111 @@
+//! 小说创作命令
+//!
+//! 兼容层:对外保持 tauri command 名称不变,内部转发到主题模块实现。
+
+use crate::database::DbConnection;
+use crate::services::novel_service::{
+ NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelGenerateChapterRequest,
+ NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, NovelListRunsRequest,
+ NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, NovelRewriteChapterRequest,
+ NovelSettingsRecord, NovelUpdateSettingsRequest,
+};
+use tauri::State;
+
+/// 创建小说项目
+#[tauri::command]
+pub async fn novel_create_project(
+ db: State<'_, DbConnection>,
+ request: NovelCreateProjectRequest,
+) -> Result {
+ crate::theme::novel::command::novel_create_project(db, request).await
+}
+
+/// 更新小说设定(自动版本递增)
+#[tauri::command]
+pub async fn novel_update_settings(
+ db: State<'_, DbConnection>,
+ request: NovelUpdateSettingsRequest,
+) -> Result {
+ crate::theme::novel::command::novel_update_settings(db, request).await
+}
+
+/// 生成小说大纲
+#[tauri::command]
+pub async fn novel_generate_outline(
+ db: State<'_, DbConnection>,
+ request: NovelGenerateRequest,
+) -> Result {
+ crate::theme::novel::command::novel_generate_outline(db, request).await
+}
+
+/// 生成角色卡
+#[tauri::command]
+pub async fn novel_generate_characters(
+ db: State<'_, DbConnection>,
+ request: NovelGenerateRequest,
+) -> Result {
+ crate::theme::novel::command::novel_generate_characters(db, request).await
+}
+
+/// 生成章节
+#[tauri::command]
+pub async fn novel_generate_chapter(
+ db: State<'_, DbConnection>,
+ request: NovelGenerateChapterRequest,
+) -> Result {
+ crate::theme::novel::command::novel_generate_chapter(db, request).await
+}
+
+/// 续写下一章
+#[tauri::command]
+pub async fn novel_continue_chapter(
+ db: State<'_, DbConnection>,
+ request: NovelGenerateRequest,
+) -> Result {
+ crate::theme::novel::command::novel_continue_chapter(db, request).await
+}
+
+/// 重写章节
+#[tauri::command]
+pub async fn novel_rewrite_chapter(
+ db: State<'_, DbConnection>,
+ request: NovelRewriteChapterRequest,
+) -> Result {
+ crate::theme::novel::command::novel_rewrite_chapter(db, request).await
+}
+
+/// 润色章节
+#[tauri::command]
+pub async fn novel_polish_chapter(
+ db: State<'_, DbConnection>,
+ request: NovelPolishChapterRequest,
+) -> Result {
+ crate::theme::novel::command::novel_polish_chapter(db, request).await
+}
+
+/// 章节一致性检查
+#[tauri::command]
+pub async fn novel_check_consistency(
+ db: State<'_, DbConnection>,
+ request: NovelCheckConsistencyRequest,
+) -> Result {
+ crate::theme::novel::command::novel_check_consistency(db, request).await
+}
+
+/// 获取项目完整快照
+#[tauri::command]
+pub async fn novel_get_project_snapshot(
+ db: State<'_, DbConnection>,
+ project_id: String,
+) -> Result {
+ crate::theme::novel::command::novel_get_project_snapshot(db, project_id).await
+}
+
+/// 获取生成运行记录
+#[tauri::command]
+pub async fn novel_list_runs(
+ db: State<'_, DbConnection>,
+ request: NovelListRunsRequest,
+) -> Result, String> {
+ crate::theme::novel::command::novel_list_runs(db, request).await
+}
diff --git a/src-tauri/src/commands/orchestrator_cmd.rs b/src-tauri/src/commands/orchestrator_cmd.rs
index f9abcd640..0052a4ef9 100644
--- a/src-tauri/src/commands/orchestrator_cmd.rs
+++ b/src-tauri/src/commands/orchestrator_cmd.rs
@@ -224,7 +224,7 @@ pub async fn get_tier_models(tier: String) -> Result, String
let orchestrator = get_global_orchestrator().ok_or("编排器未初始化")?;
let service_tier =
- ServiceTier::from_str(&tier).ok_or_else(|| format!("无效的服务等级: {tier}"))?;
+ ServiceTier::parse_str(&tier).ok_or_else(|| format!("无效的服务等级: {tier}"))?;
Ok(orchestrator.get_models(service_tier).await)
}
@@ -257,7 +257,7 @@ impl From for CredentialInfo {
let original_provider_type = req.provider_type.clone();
CredentialInfo {
id: req.id,
- provider_type: ProviderType::from_str(&req.provider_type)
+ provider_type: ProviderType::parse_str(&req.provider_type)
.unwrap_or(ProviderType::Custom),
original_provider_type: Some(original_provider_type),
supported_models: req.supported_models,
@@ -349,7 +349,7 @@ pub struct SelectionRequest {
pub async fn select_model(request: SelectionRequest) -> Result {
let orchestrator = get_global_orchestrator().ok_or("编排器未初始化")?;
- let tier = ServiceTier::from_str(&request.tier)
+ let tier = ServiceTier::parse_str(&request.tier)
.ok_or_else(|| format!("无效的服务等级: {}", request.tier))?;
let mut ctx = SelectionContext::new(tier);
@@ -406,7 +406,7 @@ pub async fn select_model_for_task(tier: String, task: String) -> Result TaskHint::Coding,
diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs
index 39e87119f..a88aa7c52 100644
--- a/src-tauri/src/commands/persona_cmd.rs
+++ b/src-tauri/src/commands/persona_cmd.rs
@@ -19,6 +19,7 @@
use serde::{Deserialize, Serialize};
use tauri::State;
+use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
use crate::database::DbConnection;
use crate::models::project_model::{
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest,
@@ -292,6 +293,7 @@ pub async fn generate_persona(
if !agent_state.is_initialized().await {
agent_state.init_agent_with_db(&db).await?;
}
+ ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
// 创建临时会话 ID
let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4());
diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs
index c7628728c..34444e978 100644
--- a/src-tauri/src/commands/skill_exec_cmd.rs
+++ b/src-tauri/src/commands/skill_exec_cmd.rs
@@ -24,6 +24,7 @@ use aster::conversation::message::Message;
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::{AsterAgentState, TauriAgentEvent};
+use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
use crate::commands::skill_error::{
format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE,
SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED,
@@ -216,6 +217,14 @@ pub async fn execute_skill(
})?;
tracing::info!("[execute_skill] Agent 初始化完成");
}
+ ensure_browser_mcp_tools_registered(aster_state.inner())
+ .await
+ .map_err(|e| {
+ format_skill_error(
+ SKILL_ERR_SESSION_INIT_FAILED,
+ format!("注册浏览器工具失败: {e}"),
+ )
+ })?;
// 4. 配置 Provider(从凭证池选择,支持 fallback)
let preferred_provider = provider_override
diff --git a/src-tauri/src/commands/unified_chat_cmd.rs b/src-tauri/src/commands/unified_chat_cmd.rs
index 452a49639..4297270c0 100644
--- a/src-tauri/src/commands/unified_chat_cmd.rs
+++ b/src-tauri/src/commands/unified_chat_cmd.rs
@@ -15,10 +15,12 @@
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::{AsterAgentState, TauriAgentEvent};
+use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
use crate::config::GlobalConfigManagerState;
use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession};
use crate::database::DbConnection;
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
+use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
use aster::conversation::message::Message;
use futures::StreamExt;
use proxycast_agent::event_converter::convert_agent_event;
@@ -112,9 +114,10 @@ pub async fn chat_create_session(
let now = chrono::Utc::now().to_rfc3339();
let session_id = uuid::Uuid::new_v4().to_string();
- let merged_system_prompt = merge_system_prompt_with_memory_profile(
- request.system_prompt.clone(),
- &config_manager.config(),
+ let config = config_manager.config();
+ let merged_system_prompt = merge_system_prompt_with_web_search(
+ merge_system_prompt_with_memory_profile(request.system_prompt.clone(), &config),
+ &config,
);
// 创建会话
@@ -347,9 +350,10 @@ pub async fn chat_send_message(
tracing::debug!("[UnifiedChat] 数据库查询耗时: {:?}", db_elapsed);
// 根据模式处理
- let merged_system_prompt = merge_system_prompt_with_memory_profile(
- session.system_prompt.clone(),
- &config_manager.config(),
+ let config = config_manager.config();
+ let merged_system_prompt = merge_system_prompt_with_web_search(
+ merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
+ &config,
);
let result = match session.mode {
@@ -363,7 +367,7 @@ pub async fn chat_send_message(
&request.message,
&request.event_name,
merged_system_prompt.as_deref(),
- config_manager.config().memory.enabled,
+ config.memory.enabled,
)
.await
}
@@ -377,7 +381,7 @@ pub async fn chat_send_message(
&request.message,
&request.event_name,
merged_system_prompt.as_deref(),
- config_manager.config().memory.enabled,
+ config.memory.enabled,
)
.await
}
@@ -411,6 +415,7 @@ async fn send_message_with_aster(
if !agent_state.is_initialized().await {
agent_state.init_agent_with_db(db).await?;
}
+ ensure_browser_mcp_tools_registered(agent_state).await?;
let init_elapsed = init_start.elapsed();
tracing::debug!("[UnifiedChat] Agent 初始化检查耗时: {:?}", init_elapsed);
diff --git a/src-tauri/src/commands/video_generation_cmd.rs b/src-tauri/src/commands/video_generation_cmd.rs
index 13e02ac5c..56461a897 100644
--- a/src-tauri/src/commands/video_generation_cmd.rs
+++ b/src-tauri/src/commands/video_generation_cmd.rs
@@ -1,43 +1,16 @@
//! 视频生成命令
//!
-//! 提供视频任务创建、轮询、列表和取消命令。
+//! 兼容层:对外保持 tauri command 名称不变,内部转发到主题模块实现。
-use once_cell::sync::Lazy;
-use serde::{Deserialize, Serialize};
use tauri::State;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::database::DbConnection;
-use proxycast_core::database::dao::video_generation_task_dao::VideoGenerationTask;
-use proxycast_services::video_generation_service::{
- CreateVideoGenerationRequest, VideoGenerationService,
+pub use crate::theme::video::command::{
+ CancelVideoTaskRequest, GetVideoTaskRequest, ListVideoTasksRequest,
};
-
-static VIDEO_GENERATION_SERVICE: Lazy =
- Lazy::new(VideoGenerationService::new);
-
-/// 获取视频任务请求参数
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetVideoTaskRequest {
- pub task_id: String,
- pub refresh_status: Option,
-}
-
-/// 列表视频任务请求参数
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct ListVideoTasksRequest {
- pub project_id: String,
- pub limit: Option,
-}
-
-/// 取消视频任务请求参数
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct CancelVideoTaskRequest {
- pub task_id: String,
-}
+use proxycast_core::database::dao::video_generation_task_dao::VideoGenerationTask;
+use proxycast_services::video_generation_service::CreateVideoGenerationRequest;
#[tauri::command]
pub async fn create_video_generation_task(
@@ -45,9 +18,12 @@ pub async fn create_video_generation_task(
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
request: CreateVideoGenerationRequest,
) -> Result {
- VIDEO_GENERATION_SERVICE
- .create_task(&db, &api_key_provider_service.0, request)
- .await
+ crate::theme::video::command::create_video_generation_task(
+ db,
+ api_key_provider_service,
+ request,
+ )
+ .await
}
#[tauri::command]
@@ -56,13 +32,7 @@ pub async fn get_video_generation_task(
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
request: GetVideoTaskRequest,
) -> Result