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 + + + +
+

Proxycast Browser Bridge

+

连接 Chrome 与 Proxycast 服务

+
+ +
+ 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, String> { - VIDEO_GENERATION_SERVICE - .get_task( - &db, - &api_key_provider_service.0, - &request.task_id, - request.refresh_status.unwrap_or(true), - ) + crate::theme::video::command::get_video_generation_task(db, api_key_provider_service, request) .await } @@ -71,11 +41,7 @@ pub fn list_video_generation_tasks( db: State<'_, DbConnection>, request: ListVideoTasksRequest, ) -> Result, String> { - VIDEO_GENERATION_SERVICE.list_tasks( - &db, - &request.project_id, - request.limit.unwrap_or(50).clamp(1, 200), - ) + crate::theme::video::command::list_video_generation_tasks(db, request) } #[tauri::command] @@ -84,7 +50,10 @@ pub async fn cancel_video_generation_task( api_key_provider_service: State<'_, ApiKeyProviderServiceState>, request: CancelVideoTaskRequest, ) -> Result, String> { - VIDEO_GENERATION_SERVICE - .cancel_task(&db, &api_key_provider_service.0, &request.task_id) - .await + crate::theme::video::command::cancel_video_generation_task( + db, + api_key_provider_service, + request, + ) + .await } diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 063779731..b9cec2899 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -8,11 +8,23 @@ //! - 管理窗口生命周期 //! - 控制窗口位置和大小 +use crate::app::AppState; +use aster::chrome_mcp::{ + get_chrome_mcp_tools, is_chrome_integration_configured, is_chrome_integration_supported, +}; +use once_cell::sync::Lazy; +use proxycast_server::chrome_bridge::{ + self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeStatusSnapshot, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; use std::sync::Arc; +use std::time::Duration; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; /// Webview 面板信息 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -56,6 +68,62 @@ impl Default for WebviewManagerState { /// Webview 管理器状态包装 pub struct WebviewManagerWrapper(pub Arc>); +/// Chrome Profile 进程内部状态 +struct ChromeProfileProcess { + profile_key: String, + browser_source: String, + browser_path: String, + profile_dir: String, + remote_debugging_port: u16, + started_at: String, + last_url: String, + child: Child, +} + +impl ChromeProfileProcess { + fn as_info(&self) -> ChromeProfileSessionInfo { + ChromeProfileSessionInfo { + profile_key: self.profile_key.clone(), + browser_source: self.browser_source.clone(), + browser_path: self.browser_path.clone(), + profile_dir: self.profile_dir.clone(), + remote_debugging_port: self.remote_debugging_port, + pid: self.child.id(), + started_at: self.started_at.clone(), + last_url: self.last_url.clone(), + } + } +} + +/// Chrome Profile 会话管理器状态 +pub struct ChromeProfileManagerState { + sessions: HashMap, +} + +impl ChromeProfileManagerState { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + } + } +} + +impl Default for ChromeProfileManagerState { + fn default() -> Self { + Self::new() + } +} + +/// Chrome Profile 管理器状态包装 +pub struct ChromeProfileManagerWrapper(pub Arc>); + +static SHARED_CHROME_PROFILE_MANAGER: Lazy>> = + Lazy::new(|| Arc::new(Mutex::new(ChromeProfileManagerState::new()))); + +pub fn shared_chrome_profile_manager() -> Arc> { + SHARED_CHROME_PROFILE_MANAGER.clone() +} + /// 创建嵌入式 webview 的请求参数 #[derive(Debug, Deserialize)] pub struct CreateWebviewRequest { @@ -75,6 +143,12 @@ pub struct CreateWebviewRequest { pub width: f64, /// 高度 pub height: f64, + /// Profile 隔离键(用于区分不同站点/用途) + #[serde(default)] + pub profile_key: Option, + /// 是否启用持久化 profile(独立 cookies/localStorage) + #[serde(default)] + pub persistent_profile: bool, } /// 创建 webview 面板的响应 @@ -88,6 +162,200 @@ pub struct CreateWebviewResponse { pub error: Option, } +/// 启动外部 Chrome Profile 的请求参数 +#[derive(Debug, Deserialize)] +pub struct OpenChromeProfileRequest { + /// Profile 隔离键(用于不同用途隔离) + pub profile_key: String, + /// 要打开的 URL + pub url: String, +} + +/// 启动外部 Chrome Profile 的响应 +#[derive(Debug, Serialize)] +pub struct OpenChromeProfileResponse { + /// 是否成功 + pub success: bool, + /// 是否复用已有会话 + pub reused: bool, + /// 浏览器来源:system / playwright + pub browser_source: Option, + /// 浏览器可执行文件路径 + pub browser_path: Option, + /// Profile 数据目录 + pub profile_dir: Option, + /// Chrome 远程调试端口 + pub remote_debugging_port: Option, + /// Chrome 进程 PID + pub pid: Option, + /// DevTools HTTP 端点 + pub devtools_http_url: Option, + /// 错误信息(如果有) + pub error: Option, +} + +/// Chrome Profile 会话信息 +#[derive(Debug, Clone, Serialize)] +pub struct ChromeProfileSessionInfo { + /// Profile 隔离键 + pub profile_key: String, + /// 浏览器来源 + pub browser_source: String, + /// 浏览器可执行文件路径 + pub browser_path: String, + /// Profile 目录 + pub profile_dir: String, + /// 远程调试端口 + pub remote_debugging_port: u16, + /// 进程 PID + pub pid: u32, + /// 启动时间(RFC3339) + pub started_at: String, + /// 最近一次打开的 URL + pub last_url: String, +} + +/// Chrome 扩展桥接端点信息 +#[derive(Debug, Clone, Serialize)] +pub struct ChromeBridgeEndpointInfo { + /// 当前服务器是否运行 + pub server_running: bool, + /// WebSocket 主机 + pub host: String, + /// WebSocket 端口 + pub port: u16, + /// observer 通道 URL + pub observer_ws_url: String, + /// control 通道 URL + pub control_ws_url: String, + /// Bridge Key(与 server.api_key 一致) + pub bridge_key: String, +} + +const ASTER_CHROME_TOOL_PREFIX: &str = "mcp__claude-in-chrome__"; +const DEFAULT_BROWSER_ACTION_TIMEOUT_MS: u64 = 30_000; +const MIN_BROWSER_ACTION_TIMEOUT_MS: u64 = 1_000; +const MAX_BROWSER_ACTION_TIMEOUT_MS: u64 = 120_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum BrowserBackendType { + AsterCompat, + ProxycastExtensionBridge, + CdpDirect, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BrowserBackendPolicy { + pub priority: Vec, + #[serde(default = "default_browser_auto_fallback")] + pub auto_fallback: bool, +} + +fn default_browser_auto_fallback() -> bool { + true +} + +impl Default for BrowserBackendPolicy { + fn default() -> Self { + Self { + priority: vec![ + BrowserBackendType::AsterCompat, + BrowserBackendType::ProxycastExtensionBridge, + BrowserBackendType::CdpDirect, + ], + auto_fallback: true, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct BrowserBackendStatusItem { + pub backend: BrowserBackendType, + pub available: bool, + pub reason: Option, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BrowserBackendsStatusSnapshot { + pub policy: BrowserBackendPolicy, + pub bridge_observer_count: usize, + pub bridge_control_count: usize, + pub running_profile_count: usize, + pub cdp_alive_profile_count: usize, + pub aster_native_host_supported: bool, + pub aster_native_host_configured: bool, + pub backends: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BrowserActionRequest { + #[serde(default)] + pub profile_key: Option, + #[serde(default)] + pub backend: Option, + pub action: String, + #[serde(default)] + pub args: Value, + #[serde(default)] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BrowserActionAttempt { + pub backend: BrowserBackendType, + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BrowserActionResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + pub action: String, + pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub attempts: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct CdpTargetInfo { + id: String, + title: String, + url: String, + #[serde(rename = "type")] + target_type: String, + #[serde(rename = "webSocketDebuggerUrl")] + web_socket_debugger_url: Option, +} + +static BROWSER_BACKEND_POLICY: Lazy> = + Lazy::new(|| RwLock::new(BrowserBackendPolicy::default())); + +const BROWSER_AUDIT_LOG_MAX: usize = 200; + +#[derive(Debug, Clone, Serialize)] +pub struct BrowserActionAuditRecord { + pub id: String, + pub created_at: String, + pub action: String, + pub profile_key: Option, + pub requested_backend: Option, + pub selected_backend: Option, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub attempts: Vec, +} + +static BROWSER_ACTION_AUDIT_LOGS: Lazy>> = + Lazy::new(|| Mutex::new(VecDeque::new())); + /// 创建独立的浏览器窗口 /// /// 使用 Tauri 2.x 的 WebviewWindow 创建独立的浏览器窗口。 @@ -109,21 +377,9 @@ pub async fn create_webview_panel( request.height ); - // 检查是否已存在同 ID 的窗口 - { - let manager = state.0.read().await; - if manager.panels.contains_key(&panel_id) { - return Ok(CreateWebviewResponse { - success: false, - panel_id, - error: Some("窗口已存在".to_string()), - }); - } - } - // 解析 URL - let webview_url = match url.parse::() { - Ok(parsed_url) => WebviewUrl::External(parsed_url), + let parsed_url = match url.parse::() { + Ok(parsed_url) => parsed_url, Err(e) => { return Ok(CreateWebviewResponse { success: false, @@ -132,16 +388,61 @@ pub async fn create_webview_panel( }); } }; + let webview_url = WebviewUrl::External(parsed_url.clone()); + + // 若窗口已存在,复用并导航 + if let Some(window) = app.get_webview_window(&panel_id) { + let js_url = + serde_json::to_string(parsed_url.as_str()).map_err(|e| format!("URL 编码失败: {e}"))?; + let js = format!("window.location.href = {js_url};"); + if let Err(e) = window.eval(&js) { + tracing::warn!("[Webview] 已存在窗口导航失败: {}", e); + } + let _ = window.set_title(&title); + let _ = window.set_focus(); + + let mut manager = state.0.write().await; + manager.panels.insert( + panel_id.clone(), + WebviewPanelInfo { + id: panel_id.clone(), + url, + title, + x: 0.0, + y: 0.0, + width: request.width, + height: request.height, + }, + ); + + tracing::info!("[Webview] 复用已存在窗口: {}", panel_id); + return Ok(CreateWebviewResponse { + success: true, + panel_id, + error: None, + }); + } // 创建独立的 WebviewWindow - match WebviewWindowBuilder::new(&app, &panel_id, webview_url) + let mut builder = WebviewWindowBuilder::new(&app, &panel_id, webview_url) .title(&title) .inner_size(request.width, request.height) .min_inner_size(400.0, 300.0) .resizable(true) - .center() - .build() - { + .center(); + + if request.persistent_profile { + let profile_key = request.profile_key.as_deref().unwrap_or(&panel_id); + let profile_dir = resolve_profile_data_dir(&app, profile_key)?; + std::fs::create_dir_all(&profile_dir).map_err(|e| format!("创建 profile 目录失败: {e}"))?; + builder = builder.data_directory(profile_dir); + #[cfg(any(target_os = "macos", target_os = "ios"))] + { + builder = builder.data_store_identifier(profile_data_store_identifier(profile_key)); + } + } + + match builder.build() { Ok(_window) => { // 记录窗口信息 let mut manager = state.0.write().await; @@ -177,6 +478,1392 @@ pub async fn create_webview_panel( } } +/// 使用独立 profile 启动外部 Chrome 窗口 +#[tauri::command] +pub async fn open_chrome_profile_window( + app: AppHandle, + app_state: tauri::State<'_, AppState>, + state: tauri::State<'_, ChromeProfileManagerWrapper>, + request: OpenChromeProfileRequest, +) -> Result { + let profile_key = normalize_profile_key(&request.profile_key); + let parsed_url = match request.url.parse::() { + Ok(url) => url, + Err(e) => { + return Ok(OpenChromeProfileResponse { + success: false, + reused: false, + browser_source: None, + browser_path: None, + profile_dir: None, + remote_debugging_port: None, + pid: None, + devtools_http_url: None, + error: Some(format!("无效的 URL: {e}")), + }); + } + }; + let url_text = parsed_url.to_string(); + + let (browser_path, browser_source) = match get_available_chrome_path() { + Some(v) => v, + None => { + return Ok(OpenChromeProfileResponse { + success: false, + reused: false, + browser_source: None, + browser_path: None, + profile_dir: None, + remote_debugging_port: None, + pid: None, + devtools_http_url: None, + error: Some( + "未找到可用的 Chrome/Chromium。请安装 Google Chrome 或运行: npx playwright install chromium" + .to_string(), + ), + }); + } + }; + + let profile_dir = resolve_chrome_profile_data_dir(&app, &profile_key)?; + std::fs::create_dir_all(&profile_dir) + .map_err(|e| format!("创建 Chrome profile 目录失败: {e}"))?; + let remote_port = profile_remote_debugging_port(&profile_key); + let devtools_http_url = format!("http://127.0.0.1:{remote_port}/json/version"); + + // 准备 Chrome 扩展(获取 server 配置并生成 auto_config.json) + let extension_dir = { + let state_guard = app_state.read().await; + let status = state_guard.status(); + let host = normalize_bridge_host(&status.host); + let port = status.port; + let bridge_key = state_guard.config.server.api_key.clone(); + let server_url = format!("ws://{host}:{port}"); + + prepare_chrome_extension(&app, &profile_dir, &server_url, &bridge_key, &profile_key)? + }; + + { + let mut manager = state.0.lock().await; + if let Some(existing) = manager.sessions.get_mut(&profile_key) { + match existing.child.try_wait() { + Ok(None) => { + // reuse 场景:不重复加载扩展 + spawn_chrome_with_profile( + &existing.browser_path, + Path::new(&existing.profile_dir), + existing.remote_debugging_port, + &url_text, + true, + None, + )?; + existing.last_url = url_text.clone(); + return Ok(OpenChromeProfileResponse { + success: true, + reused: true, + browser_source: Some(existing.browser_source.clone()), + browser_path: Some(existing.browser_path.clone()), + profile_dir: Some(existing.profile_dir.clone()), + remote_debugging_port: Some(existing.remote_debugging_port), + pid: Some(existing.child.id()), + devtools_http_url: Some(format!( + "http://127.0.0.1:{}/json/version", + existing.remote_debugging_port + )), + error: None, + }); + } + Ok(Some(_)) | Err(_) => { + manager.sessions.remove(&profile_key); + } + } + } + } + + let child = spawn_chrome_with_profile( + &browser_path, + &profile_dir, + remote_port, + &url_text, + true, + Some(&extension_dir), + )?; + let pid = child.id(); + + tracing::info!( + "[ChromeProfile] 启动浏览器: source={}, path={}, profile_key={}, pid={}, port={}", + browser_source, + browser_path, + profile_key, + pid, + remote_port + ); + + { + let mut manager = state.0.lock().await; + manager.sessions.insert( + profile_key.clone(), + ChromeProfileProcess { + profile_key, + browser_source: browser_source.clone(), + browser_path: browser_path.clone(), + profile_dir: profile_dir.to_string_lossy().to_string(), + remote_debugging_port: remote_port, + started_at: chrono::Utc::now().to_rfc3339(), + last_url: url_text, + child, + }, + ); + } + + Ok(OpenChromeProfileResponse { + success: true, + reused: false, + browser_source: Some(browser_source), + browser_path: Some(browser_path), + profile_dir: Some(profile_dir.to_string_lossy().to_string()), + remote_debugging_port: Some(remote_port), + pid: Some(pid), + devtools_http_url: Some(devtools_http_url), + error: None, + }) +} + +#[tauri::command] +pub async fn get_chrome_profile_sessions( + state: tauri::State<'_, ChromeProfileManagerWrapper>, +) -> Result, String> { + let mut manager = state.0.lock().await; + let mut stale_keys = Vec::new(); + let mut sessions = Vec::new(); + + for (key, process) in &mut manager.sessions { + match process.child.try_wait() { + Ok(None) => sessions.push(process.as_info()), + Ok(Some(_status)) => stale_keys.push(key.clone()), + Err(e) => { + tracing::warn!("[ChromeProfile] 读取进程状态失败: key={}, err={}", key, e); + stale_keys.push(key.clone()); + } + } + } + + for key in stale_keys { + manager.sessions.remove(&key); + } + + Ok(sessions) +} + +#[tauri::command] +pub async fn close_chrome_profile_session( + state: tauri::State<'_, ChromeProfileManagerWrapper>, + profile_key: String, +) -> Result { + let key = normalize_profile_key(&profile_key); + let mut manager = state.0.lock().await; + + if let Some(mut process) = manager.sessions.remove(&key) { + match process.child.try_wait() { + Ok(Some(_)) => Ok(true), + Ok(None) => { + if let Err(e) = process.child.kill() { + tracing::warn!("[ChromeProfile] 结束进程失败: key={}, err={}", key, e); + } + let _ = process.child.wait(); + Ok(true) + } + Err(e) => { + tracing::warn!("[ChromeProfile] 读取进程状态失败: key={}, err={}", key, e); + Ok(true) + } + } + } else { + Ok(false) + } +} + +/// 获取 ChromeBridge 连接端点信息 +#[tauri::command] +pub async fn get_chrome_bridge_endpoint_info( + app_state: tauri::State<'_, AppState>, +) -> Result { + let state = app_state.read().await; + let status = state.status(); + let host = normalize_bridge_host(&status.host); + let port = status.port; + let bridge_key = state.config.server.api_key.clone(); + + Ok(ChromeBridgeEndpointInfo { + server_running: status.running, + observer_ws_url: format!("ws://{host}:{port}/proxycast-chrome-observer/{bridge_key}"), + control_ws_url: format!("ws://{host}:{port}/proxycast-chrome-control/{bridge_key}"), + host, + port, + bridge_key, + }) +} + +/// 获取 ChromeBridge 状态快照(observer/control/pending) +#[tauri::command] +pub async fn get_chrome_bridge_status() -> Result { + Ok(chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await) +} + +/// 通过 ChromeBridge 执行命令(用于设置页测试) +#[tauri::command] +pub async fn chrome_bridge_execute_command( + request: ChromeBridgeCommandRequest, +) -> Result { + chrome_bridge::chrome_bridge_hub() + .execute_api_command(request) + .await +} + +/// 获取浏览器后端策略 +#[tauri::command] +pub async fn get_browser_backend_policy() -> Result { + Ok(BROWSER_BACKEND_POLICY.read().await.clone()) +} + +/// 设置浏览器后端策略 +#[tauri::command] +pub async fn set_browser_backend_policy( + policy: BrowserBackendPolicy, +) -> Result { + let normalized = normalize_backend_policy(policy)?; + { + let mut guard = BROWSER_BACKEND_POLICY.write().await; + *guard = normalized.clone(); + } + Ok(normalized) +} + +/// 获取浏览器后端状态快照 +#[tauri::command] +pub async fn get_browser_backends_status( + state: tauri::State<'_, ChromeProfileManagerWrapper>, +) -> Result { + let policy = BROWSER_BACKEND_POLICY.read().await.clone(); + let bridge_status = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await; + let sessions = list_alive_profile_sessions(state.0.clone()).await; + let mut cdp_alive = 0usize; + for session in &sessions { + if is_cdp_endpoint_alive(session.remote_debugging_port).await { + cdp_alive += 1; + } + } + + let extension_available = bridge_status.observer_count > 0; + let cdp_available = cdp_alive > 0; + let aster_supported = is_chrome_integration_supported(); + let aster_configured = is_chrome_integration_configured().await; + let aster_available = extension_available || cdp_available || aster_configured; + + Ok(BrowserBackendsStatusSnapshot { + policy, + bridge_observer_count: bridge_status.observer_count, + bridge_control_count: bridge_status.control_count, + running_profile_count: sessions.len(), + cdp_alive_profile_count: cdp_alive, + aster_native_host_supported: aster_supported, + aster_native_host_configured: aster_configured, + backends: vec![ + BrowserBackendStatusItem { + backend: BrowserBackendType::AsterCompat, + available: aster_available, + reason: if aster_available { + None + } else { + Some("aster 兼容层当前无可用下游连接(扩展/CDP/native-host)".to_string()) + }, + capabilities: aster_backend_capabilities(), + }, + BrowserBackendStatusItem { + backend: BrowserBackendType::ProxycastExtensionBridge, + available: extension_available, + reason: if extension_available { + None + } else { + Some("未检测到扩展 observer 连接".to_string()) + }, + capabilities: extension_backend_capabilities(), + }, + BrowserBackendStatusItem { + backend: BrowserBackendType::CdpDirect, + available: cdp_available, + reason: if cdp_available { + None + } else { + Some("未检测到可连接的 CDP 调试端口".to_string()) + }, + capabilities: cdp_backend_capabilities(), + }, + ], + }) +} + +/// 通过统一编排层执行浏览器动作 +#[tauri::command] +pub async fn browser_execute_action( + state: tauri::State<'_, ChromeProfileManagerWrapper>, + request: BrowserActionRequest, +) -> Result { + browser_execute_action_with_manager(state.0.clone(), request).await +} + +/// 获取浏览器动作审计日志 +#[tauri::command] +pub async fn get_browser_action_audit_logs( + limit: Option, +) -> Result, String> { + let max_count = limit + .unwrap_or(BROWSER_AUDIT_LOG_MAX) + .min(BROWSER_AUDIT_LOG_MAX); + let logs = BROWSER_ACTION_AUDIT_LOGS.lock().await; + let mut result = logs.iter().cloned().collect::>(); + result.reverse(); + result.truncate(max_count); + Ok(result) +} + +/// 使用指定 profile manager 执行动作(供非 Tauri 命令入口复用) +pub async fn browser_execute_action_with_manager( + manager: Arc>, + request: BrowserActionRequest, +) -> Result { + let action = normalize_action_name(&request.action)?; + let request_id = format!("browser-{}", uuid::Uuid::new_v4()); + let policy = BROWSER_BACKEND_POLICY.read().await.clone(); + let candidates = build_backend_candidates(request.backend.clone(), &policy); + let allow_fallback = request.backend.is_none() && policy.auto_fallback; + let profile_key = request + .profile_key + .as_deref() + .map(normalize_profile_key) + .or_else(|| Some("default".to_string())); + + let mut attempts = Vec::new(); + for (idx, backend) in candidates.iter().enumerate() { + match execute_browser_action_with_backend( + backend.clone(), + &action, + request.args.clone(), + profile_key.clone(), + request.timeout_ms, + manager.clone(), + ) + .await + { + Ok(data) => { + attempts.push(BrowserActionAttempt { + backend: backend.clone(), + success: true, + message: "执行成功".to_string(), + }); + let result = BrowserActionResult { + success: true, + backend: Some(backend.clone()), + action, + request_id: request_id.clone(), + data: Some(data), + error: None, + attempts: attempts.clone(), + }; + append_browser_action_audit(BrowserActionAuditRecord { + id: request_id, + created_at: chrono::Utc::now().to_rfc3339(), + action: result.action.clone(), + profile_key: profile_key.clone(), + requested_backend: request.backend.clone(), + selected_backend: result.backend.clone(), + success: true, + error: None, + attempts, + }) + .await; + return Ok(result); + } + Err(error) => { + attempts.push(BrowserActionAttempt { + backend: backend.clone(), + success: false, + message: error.clone(), + }); + if !allow_fallback || idx + 1 >= candidates.len() { + let result = BrowserActionResult { + success: false, + backend: None, + action: action.clone(), + request_id: request_id.clone(), + data: None, + error: Some(error.clone()), + attempts: attempts.clone(), + }; + append_browser_action_audit(BrowserActionAuditRecord { + id: request_id, + created_at: chrono::Utc::now().to_rfc3339(), + action: result.action.clone(), + profile_key: profile_key.clone(), + requested_backend: request.backend.clone(), + selected_backend: None, + success: false, + error: Some(error), + attempts, + }) + .await; + return Ok(result); + } + } + } + } + + let result = BrowserActionResult { + success: false, + backend: None, + action: action.clone(), + request_id: request_id.clone(), + data: None, + error: Some("没有可用的浏览器后端".to_string()), + attempts: attempts.clone(), + }; + append_browser_action_audit(BrowserActionAuditRecord { + id: request_id, + created_at: chrono::Utc::now().to_rfc3339(), + action, + profile_key, + requested_backend: request.backend, + selected_backend: None, + success: false, + error: result.error.clone(), + attempts, + }) + .await; + Ok(result) +} + +/// 使用全局 profile manager 执行动作(供 Agent 工具复用) +pub async fn browser_execute_action_global( + request: BrowserActionRequest, +) -> Result { + browser_execute_action_with_manager(shared_chrome_profile_manager(), request).await +} + +async fn append_browser_action_audit(record: BrowserActionAuditRecord) { + let mut logs = BROWSER_ACTION_AUDIT_LOGS.lock().await; + logs.push_back(record); + while logs.len() > BROWSER_AUDIT_LOG_MAX { + logs.pop_front(); + } +} + +fn normalize_backend_policy(policy: BrowserBackendPolicy) -> Result { + let mut priority = Vec::new(); + for backend in policy.priority { + if !priority.contains(&backend) { + priority.push(backend); + } + } + for backend in [ + BrowserBackendType::AsterCompat, + BrowserBackendType::ProxycastExtensionBridge, + BrowserBackendType::CdpDirect, + ] { + if !priority.contains(&backend) { + priority.push(backend); + } + } + if priority.is_empty() { + return Err("后端优先级不能为空".to_string()); + } + Ok(BrowserBackendPolicy { + priority, + auto_fallback: policy.auto_fallback, + }) +} + +fn build_backend_candidates( + forced_backend: Option, + policy: &BrowserBackendPolicy, +) -> Vec { + if let Some(backend) = forced_backend { + return vec![backend]; + } + if policy.priority.is_empty() { + return BrowserBackendPolicy::default().priority; + } + policy.priority.clone() +} + +fn normalize_action_name(action: &str) -> Result { + let raw = action.trim(); + if raw.is_empty() { + return Err("action 不能为空".to_string()); + } + let stripped = raw + .strip_prefix(ASTER_CHROME_TOOL_PREFIX) + .unwrap_or(raw) + .trim(); + if stripped.is_empty() { + return Err("action 无效".to_string()); + } + Ok(stripped.to_ascii_lowercase()) +} + +fn normalize_action_timeout(timeout_ms: Option) -> u64 { + timeout_ms + .unwrap_or(DEFAULT_BROWSER_ACTION_TIMEOUT_MS) + .clamp(MIN_BROWSER_ACTION_TIMEOUT_MS, MAX_BROWSER_ACTION_TIMEOUT_MS) +} + +fn aster_backend_capabilities() -> Vec { + get_chrome_mcp_tools() + .into_iter() + .map(|tool| tool.name) + .collect() +} + +fn extension_backend_capabilities() -> Vec { + vec![ + "navigate".to_string(), + "read_page".to_string(), + "get_page_text".to_string(), + "find".to_string(), + "computer".to_string(), + "form_input".to_string(), + "tabs_context_mcp".to_string(), + "open_url".to_string(), + "click".to_string(), + "type".to_string(), + "scroll".to_string(), + "scroll_page".to_string(), + "get_page_info".to_string(), + "refresh_page".to_string(), + "go_back".to_string(), + "go_forward".to_string(), + "switch_tab".to_string(), + ] +} + +fn cdp_backend_capabilities() -> Vec { + vec![ + "tabs_context_mcp".to_string(), + "navigate".to_string(), + "read_page".to_string(), + "get_page_text".to_string(), + ] +} + +fn action_arg_string(args: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + args.get(*key) + .and_then(Value::as_str) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }) +} + +fn action_arg_bool(args: &Value, key: &str, default: bool) -> bool { + args.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn action_arg_u64(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_u64) +} + +async fn execute_browser_action_with_backend( + backend: BrowserBackendType, + action: &str, + args: Value, + profile_key: Option, + timeout_ms: Option, + manager: Arc>, +) -> Result { + match backend { + BrowserBackendType::ProxycastExtensionBridge => { + execute_extension_backend_action(action, args, profile_key, timeout_ms, manager).await + } + BrowserBackendType::CdpDirect => { + execute_cdp_backend_action(action, args, profile_key, manager).await + } + BrowserBackendType::AsterCompat => { + execute_aster_compat_action(action, args, profile_key, timeout_ms, manager).await + } + } +} + +async fn execute_aster_compat_action( + action: &str, + args: Value, + profile_key: Option, + timeout_ms: Option, + manager: Arc>, +) -> Result { + match action { + "tabs_context_mcp" | "read_page" | "get_page_text" => { + if let Ok(result) = execute_cdp_backend_action( + action, + args.clone(), + profile_key.clone(), + manager.clone(), + ) + .await + { + return Ok(result); + } + execute_extension_backend_action(action, args, profile_key, timeout_ms, manager).await + } + "read_console_messages" | "read_network_requests" => { + execute_cdp_backend_action(action, args, profile_key, manager).await + } + "tabs_create_mcp" => { + let mut next_args = args; + if action_arg_string(&next_args, &["url"]).is_none() { + next_args["url"] = Value::String("about:blank".to_string()); + } + next_args["action"] = Value::String("goto".to_string()); + execute_extension_backend_action( + "navigate", + next_args, + profile_key, + timeout_ms, + manager, + ) + .await + } + "shortcuts_list" => Ok(json!({ + "supported": false, + "message": "当前后端尚未实现 shortcuts_list", + "shortcuts": [], + })), + "update_plan" => Ok(json!({ + "accepted": true, + "plan": action_arg_string(&args, &["plan"]).unwrap_or_default(), + })), + "shortcuts_execute" | "gif_creator" | "upload_image" | "resize_window" + | "javascript_tool" => Err(format!( + "aster 兼容层暂不支持 {action},请切换为扩展桥接或补充实现" + )), + _ => execute_extension_backend_action(action, args, profile_key, timeout_ms, manager).await, + } +} + +async fn execute_extension_backend_action( + action: &str, + args: Value, + profile_key: Option, + timeout_ms: Option, + manager: Arc>, +) -> Result { + match action { + "navigate" => { + let nav_action = + action_arg_string(&args, &["action"]).unwrap_or_else(|| "goto".to_string()); + match nav_action.as_str() { + "goto" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "open_url".to_string(), + target: None, + text: None, + url: action_arg_string(&args, &["url"]), + wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), + timeout_ms: Some(normalize_action_timeout( + action_arg_u64(&args, "timeout_ms").or(timeout_ms), + )), + }) + .await + .map(bridge_result_to_value), + "back" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "go_back".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), + "forward" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "go_forward".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), + "reload" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "refresh_page".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), + _ => Err(format!("不支持的 navigate.action: {nav_action}")), + } + } + "read_page" | "get_page_text" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "get_page_info".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: true, + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), + "find" => { + let query = action_arg_string(&args, &["query"]) + .ok_or_else(|| "find 需要 query 参数".to_string())?; + let response = execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "get_page_info".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: true, + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await?; + let markdown = response + .page_info + .as_ref() + .map(|v| v.markdown.clone()) + .unwrap_or_default(); + let q = query.to_ascii_lowercase(); + let matches = markdown + .lines() + .filter(|line| line.to_ascii_lowercase().contains(&q)) + .take(30) + .map(|line| line.to_string()) + .collect::>(); + Ok(json!({ + "query": query, + "match_count": matches.len(), + "matches": matches, + "page_info": response.page_info, + })) + } + "form_input" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "type".to_string(), + target: action_arg_string(&args, &["ref_id", "target"]), + text: action_arg_string(&args, &["value", "text"]), + url: None, + wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", false), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), + "computer" => { + let computer_action = + action_arg_string(&args, &["action"]).unwrap_or_else(|| "click".to_string()); + let (command, wait_for_page_info) = match computer_action.as_str() { + "click" => ("click".to_string(), false), + "type" => ("type".to_string(), false), + "scroll" => ("scroll_page".to_string(), false), + _ => { + return Err(format!( + "扩展桥接暂不支持 computer.action={computer_action}" + )); + } + }; + let text_payload = if computer_action == "scroll" { + let direction = + action_arg_string(&args, &["direction"]).unwrap_or_else(|| "down".to_string()); + let amount = action_arg_u64(&args, "amount").unwrap_or(500); + Some(format!("{direction}:{amount}")) + } else { + action_arg_string(&args, &["text"]) + }; + execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command, + target: action_arg_string(&args, &["ref_id", "target"]), + text: text_payload, + url: action_arg_string(&args, &["url"]), + wait_for_page_info: action_arg_bool( + &args, + "wait_for_page_info", + wait_for_page_info, + ), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value) + } + "tabs_context_mcp" => { + let bridge_status = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await; + let sessions = list_alive_profile_sessions(manager).await; + Ok(json!({ + "bridge": { + "observer_count": bridge_status.observer_count, + "control_count": bridge_status.control_count, + "observers": bridge_status.observers, + }, + "profiles": sessions, + })) + } + "open_url" | "click" | "type" | "scroll" | "scroll_page" | "get_page_info" + | "refresh_page" | "go_back" | "go_forward" | "switch_tab" => { + execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: action.to_string(), + target: action_arg_string(&args, &["target", "ref_id"]), + text: action_arg_string(&args, &["text", "value"]), + url: action_arg_string(&args, &["url"]), + wait_for_page_info: action_arg_bool( + &args, + "wait_for_page_info", + action == "get_page_info", + ), + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value) + } + "read_console_messages" | "read_network_requests" => { + Err(format!("扩展桥接暂不支持 {action}")) + } + _ => Err(format!("扩展桥接不支持动作: {action}")), + } +} + +async fn execute_cdp_backend_action( + action: &str, + args: Value, + profile_key: Option, + manager: Arc>, +) -> Result { + let session = select_profile_session(manager, profile_key).await?; + if !is_cdp_endpoint_alive(session.remote_debugging_port).await { + return Err(format!( + "CDP 调试端口不可用: 127.0.0.1:{}", + session.remote_debugging_port + )); + } + + match action { + "tabs_context_mcp" => { + let tabs = fetch_cdp_targets(session.remote_debugging_port).await?; + Ok(json!({ + "profile_key": session.profile_key, + "remote_debugging_port": session.remote_debugging_port, + "tabs": tabs, + })) + } + "navigate" => { + let nav_action = + action_arg_string(&args, &["action"]).unwrap_or_else(|| "goto".to_string()); + if nav_action != "goto" { + return Err(format!( + "CDP 直连初版仅支持 navigate.action=goto,收到: {nav_action}" + )); + } + let url = action_arg_string(&args, &["url"]) + .ok_or_else(|| "navigate 需要提供 url".to_string())?; + let endpoint = format!( + "http://127.0.0.1:{}/json/new?{}", + session.remote_debugging_port, + urlencoding::encode(&url) + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + let response = match client.put(&endpoint).send().await { + Ok(resp) => resp, + Err(_) => client + .get(&endpoint) + .send() + .await + .map_err(|e| format!("CDP navigate 调用失败: {e}"))?, + }; + if !response.status().is_success() { + return Err(format!("CDP navigate 返回失败状态: {}", response.status())); + } + let text = response.text().await.unwrap_or_default(); + Ok(json!({ + "profile_key": session.profile_key, + "remote_debugging_port": session.remote_debugging_port, + "url": url, + "response": text, + })) + } + "read_page" | "get_page_text" => { + let tabs = fetch_cdp_targets(session.remote_debugging_port).await?; + let current = tabs + .iter() + .find(|tab| tab.target_type == "page") + .or_else(|| tabs.first()) + .cloned() + .ok_or_else(|| "CDP 未返回可用标签页".to_string())?; + let markdown = format!( + "# {}\nURL: {}\n\nCDP 直连初版仅返回标签页元信息,完整 DOM/控制能力将后续补齐。", + current.title, current.url + ); + Ok(json!({ + "profile_key": session.profile_key, + "tab": current, + "markdown": markdown, + })) + } + "read_console_messages" | "read_network_requests" => Err(format!( + "CDP 直连初版暂不支持 {},需要建立 WebSocket DevTools 会话后补齐", + action + )), + _ => Err(format!("CDP 直连不支持动作: {action}")), + } +} + +async fn execute_bridge_api_command( + request: ChromeBridgeCommandRequest, +) -> Result { + chrome_bridge::chrome_bridge_hub() + .execute_api_command(request) + .await +} + +fn bridge_result_to_value(result: ChromeBridgeCommandResult) -> Value { + json!({ + "success": result.success, + "request_id": result.request_id, + "command": result.command, + "message": result.message, + "error": result.error, + "page_info": result.page_info, + }) +} + +async fn list_alive_profile_sessions( + manager: Arc>, +) -> Vec { + let mut guard = manager.lock().await; + let mut stale_keys = Vec::new(); + let mut sessions = Vec::new(); + + for (key, process) in &mut guard.sessions { + match process.child.try_wait() { + Ok(None) => sessions.push(process.as_info()), + Ok(Some(_)) => stale_keys.push(key.clone()), + Err(_) => stale_keys.push(key.clone()), + } + } + + for key in stale_keys { + guard.sessions.remove(&key); + } + + sessions +} + +async fn select_profile_session( + manager: Arc>, + profile_key: Option, +) -> Result { + let sessions = list_alive_profile_sessions(manager).await; + if sessions.is_empty() { + return Err("没有可用的 Chrome profile 会话,请先打开独立浏览器窗口".to_string()); + } + + if let Some(key) = profile_key { + let normalized = normalize_profile_key(&key); + if let Some(session) = sessions.into_iter().find(|v| v.profile_key == normalized) { + return Ok(session); + } + return Err(format!("未找到 profile_key={} 的会话", normalized)); + } + + sessions + .into_iter() + .next() + .ok_or_else(|| "没有可用的 Chrome profile 会话".to_string()) +} + +async fn fetch_cdp_targets(port: u16) -> Result, String> { + let endpoint = format!("http://127.0.0.1:{port}/json/list"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + let response = client + .get(&endpoint) + .send() + .await + .map_err(|e| format!("读取 CDP 标签页失败: {e}"))?; + if !response.status().is_success() { + return Err(format!("读取 CDP 标签页失败: {}", response.status())); + } + response + .json::>() + .await + .map_err(|e| format!("解析 CDP 标签页失败: {e}")) +} + +async fn is_cdp_endpoint_alive(port: u16) -> bool { + let endpoint = format!("http://127.0.0.1:{port}/json/version"); + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + { + Ok(client) => client, + Err(_) => return false, + }; + match client.get(endpoint).send().await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } +} + +fn sanitize_profile_key(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +fn normalize_profile_key(input: &str) -> String { + let safe_key = sanitize_profile_key(input); + if safe_key.trim_matches('_').is_empty() { + "default".to_string() + } else { + safe_key + } +} + +fn normalize_bridge_host(host: &str) -> String { + match host.trim() { + "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(), + value => value.to_string(), + } +} + +fn profile_remote_debugging_port(profile_key: &str) -> u16 { + const BASE_PORT: u16 = 13000; + const RANGE: u16 = 4000; + + let mut hash: u64 = 1469598103934665603; + for byte in profile_key.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(1099511628211); + } + BASE_PORT + (hash as u16 % RANGE) +} + +/// 递归复制目录 +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + std::fs::create_dir_all(dst).map_err(|e| format!("创建目标目录失败: {e}"))?; + + for entry in std::fs::read_dir(src).map_err(|e| format!("读取源目录失败: {e}"))? { + let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; + let path = entry.path(); + let file_name = entry.file_name(); + let dst_path = dst.join(&file_name); + + if path.is_dir() { + copy_dir_recursive(&path, &dst_path)?; + } else { + std::fs::copy(&path, &dst_path) + .map_err(|e| format!("复制文件失败 {:?}: {e}", file_name))?; + } + } + Ok(()) +} + +/// 准备 Chrome 扩展(复制到 profile 目录并生成配置) +fn prepare_chrome_extension( + app: &AppHandle, + profile_dir: &Path, + server_url: &str, + bridge_key: &str, + profile_key: &str, +) -> Result { + // 确定扩展源路径 + let extension_src = if cfg!(debug_assertions) { + // 开发模式:从当前目录向上查找项目根目录 + let current_dir = std::env::current_dir().map_err(|e| format!("获取当前目录失败: {e}"))?; + + // 如果当前目录是 src-tauri,则向上一级 + let project_root = if current_dir.ends_with("src-tauri") { + current_dir + .parent() + .ok_or_else(|| "无法获取项目根目录".to_string())? + .to_path_buf() + } else { + current_dir + }; + + project_root.join("extensions").join("proxycast-chrome") + } else { + // 打包模式:使用资源目录 + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("获取资源目录失败: {e}"))?; + resource_dir.join("extensions").join("proxycast-chrome") + }; + + if !extension_src.exists() { + return Err(format!("扩展源目录不存在: {:?}", extension_src)); + } + + // 目标路径:profile_dir/proxycast_extension + let extension_dst = profile_dir.join("proxycast_extension"); + + // 如果目标目录已存在,先删除(确保使用最新版本) + if extension_dst.exists() { + std::fs::remove_dir_all(&extension_dst).map_err(|e| format!("删除旧扩展目录失败: {e}"))?; + } + + // 复制扩展文件 + copy_dir_recursive(&extension_src, &extension_dst)?; + + // 生成 auto_config.json + let auto_config = json!({ + "serverUrl": server_url, + "bridgeKey": bridge_key, + "profileKey": profile_key, + "monitoringEnabled": true, + }); + + let config_path = extension_dst.join("auto_config.json"); + std::fs::write( + &config_path, + serde_json::to_string_pretty(&auto_config).unwrap(), + ) + .map_err(|e| format!("写入 auto_config.json 失败: {e}"))?; + + tracing::info!( + "[ChromeExtension] 扩展已准备: dst={:?}, config={:?}", + extension_dst, + config_path + ); + + Ok(extension_dst) +} + +fn spawn_chrome_with_profile( + browser_path: &str, + profile_dir: &Path, + remote_debugging_port: u16, + url: &str, + new_window: bool, + extension_dir: Option<&Path>, +) -> Result { + let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy()); + let mut cmd = Command::new(browser_path); + cmd.arg(profile_arg) + .arg(format!("--remote-debugging-port={remote_debugging_port}")) + .arg("--remote-allow-origins=*") + .arg("--no-first-run") + .arg("--no-default-browser-check"); + + // 如果提供了扩展目录,添加 --load-extension 参数 + if let Some(ext_dir) = extension_dir { + cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy())); + } + + if new_window { + cmd.arg("--new-window"); + } + cmd.arg(url); + cmd.spawn().map_err(|e| format!("启动 Chrome 失败: {e}")) +} + +fn resolve_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> PathBuf { + let effective_key = normalize_profile_key(profile_key); + base_dir.join("webview_profiles").join(effective_key) +} + +fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result { + let base_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("获取应用数据目录失败: {e}"))?; + Ok(resolve_profile_data_dir_from_base(&base_dir, profile_key)) +} + +fn resolve_chrome_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result { + let base_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("获取应用数据目录失败: {e}"))?; + Ok(base_dir + .join("chrome_profiles") + .join(normalize_profile_key(profile_key))) +} + +fn get_system_chrome_path() -> Option { + #[cfg_attr( + not(any(target_os = "macos", target_os = "windows")), + allow(unused_variables) + )] + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + let paths = [ + PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), + home.join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + #[cfg(target_os = "windows")] + { + let paths = [ + PathBuf::from("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"), + PathBuf::from("C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"), + home.join("AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + #[cfg(target_os = "linux")] + { + let paths = [ + PathBuf::from("/usr/bin/google-chrome"), + PathBuf::from("/usr/bin/google-chrome-stable"), + PathBuf::from("/usr/bin/chromium"), + PathBuf::from("/usr/bin/chromium-browser"), + PathBuf::from("/snap/bin/chromium"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + None +} + +fn get_playwright_cache_dir() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + home.join("Library").join("Caches").join("ms-playwright") + } + + #[cfg(target_os = "windows")] + { + home.join("AppData").join("Local").join("ms-playwright") + } + + #[cfg(target_os = "linux")] + { + home.join(".cache").join("ms-playwright") + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + home.join(".cache").join("ms-playwright") + } +} + +fn get_playwright_chrome_path() -> Option { + let cache_dir = get_playwright_cache_dir(); + let entries = std::fs::read_dir(&cache_dir).ok()?; + let mut candidates: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.starts_with("chromium-") || name == "chromium") + .unwrap_or(false) + }) + .collect(); + + candidates.sort_by(|a, b| b.cmp(a)); + + for base in candidates { + #[cfg(target_os = "macos")] + let exec_path = base + .join("chrome-mac") + .join("Chromium.app") + .join("Contents") + .join("MacOS") + .join("Chromium"); + + #[cfg(target_os = "windows")] + let exec_path = base.join("chrome-win").join("chrome.exe"); + + #[cfg(target_os = "linux")] + let exec_path = base.join("chrome-linux").join("chrome"); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + let exec_path = base.join("chrome-linux").join("chrome"); + + if exec_path.exists() { + return Some(exec_path.to_string_lossy().to_string()); + } + } + + None +} + +fn get_available_chrome_path() -> Option<(String, String)> { + if let Some(path) = get_system_chrome_path() { + return Some((path, "system".to_string())); + } + get_playwright_chrome_path().map(|path| (path, "playwright".to_string())) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +fn profile_data_store_identifier(profile_key: &str) -> [u8; 16] { + const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + + fn fnv1a64(bytes: &[u8], seed: u64) -> u64 { + let mut hash = FNV_OFFSET_BASIS ^ seed; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash + } + + let bytes = profile_key.as_bytes(); + let h1 = fnv1a64(bytes, 0x9e3779b185ebca87); + let h2 = fnv1a64(bytes, 0xc2b2ae3d27d4eb4f); + + let mut out = [0_u8; 16]; + out[..8].copy_from_slice(&h1.to_le_bytes()); + out[8..].copy_from_slice(&h2.to_le_bytes()); + out +} + /// 关闭浏览器窗口 #[tauri::command] pub async fn close_webview_panel( @@ -304,3 +1991,93 @@ pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), image_gen: proxycast_core::config::ImageGenConfig::default(), @@ -445,6 +446,7 @@ fn arb_valid_config() -> impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), image_gen: proxycast_core::config::ImageGenConfig::default(), @@ -508,6 +510,7 @@ fn arb_invalid_config() -> impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), image_gen: proxycast_core::config::ImageGenConfig::default(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bcbbc62e..25e692049 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ mod data; #[allow(dead_code)] mod dev_bridge; mod logger; +mod theme; use proxycast_core::models; // 测试模块 diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index 95a27d4ce..6480a8c07 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -10,6 +10,7 @@ ## 文件索引 - `mod.rs` - 模块入口 +- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查) - `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询) - `token_cache_service.rs` - Token 缓存服务 - `mcp_service.rs` - MCP 服务器管理 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index b125f138d..5ffceaf41 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -13,6 +13,8 @@ pub mod memory_import_parser_service; pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; +pub mod novel_service; pub mod sysinfo_service; pub mod update_check_service; pub mod update_window; +pub mod web_search_prompt_service; diff --git a/src-tauri/src/services/novel_service.rs b/src-tauri/src/services/novel_service.rs new file mode 100644 index 000000000..dde27d90d --- /dev/null +++ b/src-tauri/src/services/novel_service.rs @@ -0,0 +1,2407 @@ +//! 小说领域服务 +//! +//! 提供小说项目、设定、章节生成与一致性检查能力。 + +use crate::database::{lock_db, DbConnection}; +use proxycast_services::api_key_provider_service::ApiKeyProviderService; +use proxycast_services::provider_pool_service::ProviderPoolService; +use proxycast_skills::{LlmProvider, ProxyCastLlmProvider}; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::Instant; +use uuid::Uuid; + +const DEFAULT_TARGET_WORDS: i64 = 100_000; +const DEFAULT_MODEL: &str = "default"; +const DEFAULT_RECENT_CHAPTERS: usize = 3; +const NOVEL_SETTINGS_SCHEMA_VERSION: i32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MainCharacter { + pub name: String, + pub gender: String, + pub age: String, + pub personality: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SideCharacter { + pub id: String, + pub name: String, + pub nickname: String, + pub gender: String, + pub age: String, + pub relationship: String, + #[serde(rename = "relationshipCustom")] + pub relationship_custom: String, + #[serde(rename = "personalityTags")] + pub personality_tags: Vec, + pub background: String, + pub abilities: String, + pub role: String, + pub arc: String, + #[serde(rename = "arcCustom")] + pub arc_custom: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Antagonist { + pub id: String, + pub name: String, + pub nickname: String, + pub gender: String, + pub age: String, + pub relationship: String, + #[serde(rename = "relationshipCustom")] + pub relationship_custom: String, + #[serde(rename = "personalityTags")] + pub personality_tags: Vec, + pub background: String, + pub abilities: String, + pub role: String, + pub arc: String, + #[serde(rename = "arcCustom")] + pub arc_custom: String, + pub motive: String, + pub fate: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorldDetails { + #[serde(rename = "powerSystem")] + pub power_system: String, + pub factions: String, + #[serde(rename = "historyEvents")] + pub history_events: String, + #[serde(rename = "importantLocations")] + pub important_locations: String, + #[serde(rename = "cultureAndTaboos")] + pub culture_and_taboos: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlotBeat { + pub id: String, + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WritingStyle { + pub narration: String, + pub tones: Vec, + #[serde(rename = "cheatLevel")] + pub cheat_level: String, + #[serde(rename = "focusAreas")] + pub focus_areas: Vec, + #[serde(rename = "wordsPerChapter")] + pub words_per_chapter: i64, + pub temperature: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TabooRule { + pub id: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceWork { + pub id: String, + pub title: String, + pub inspiration: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelSettingsV1 { + pub genres: Vec, + #[serde(rename = "oneLinePitch")] + pub one_line_pitch: String, + #[serde(rename = "mainCharacter")] + pub main_character: MainCharacter, + #[serde(rename = "sideCharacters")] + pub side_characters: Vec, + pub antagonists: Vec, + #[serde(rename = "worldSummary")] + pub world_summary: String, + #[serde(rename = "conflictTheme")] + pub conflict_theme: String, + #[serde(rename = "worldDetails")] + pub world_details: WorldDetails, + pub opening: String, + #[serde(rename = "middleBeats")] + pub middle_beats: Vec, + #[serde(rename = "endingType")] + pub ending_type: String, + pub subplots: Vec, + #[serde(rename = "writingStyle")] + pub writing_style: WritingStyle, + #[serde(rename = "totalWords")] + pub total_words: i64, + #[serde(rename = "chapterWords")] + pub chapter_words: i64, + pub nsfw: bool, + #[serde(rename = "systemNovel")] + pub system_novel: bool, + pub harem: bool, + pub taboos: Vec, + pub references: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelSettingsEnvelope { + pub schema_version: i32, + pub data: NovelSettingsV1, +} + +impl Default for MainCharacter { + fn default() -> Self { + Self { + name: String::new(), + gender: "男".to_string(), + age: String::new(), + personality: String::new(), + } + } +} + +impl Default for SideCharacter { + fn default() -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: String::new(), + nickname: String::new(), + gender: "男".to_string(), + age: String::new(), + relationship: String::new(), + relationship_custom: String::new(), + personality_tags: Vec::new(), + background: String::new(), + abilities: String::new(), + role: String::new(), + arc: String::new(), + arc_custom: String::new(), + } + } +} + +impl Default for Antagonist { + fn default() -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: String::new(), + nickname: String::new(), + gender: "男".to_string(), + age: String::new(), + relationship: String::new(), + relationship_custom: String::new(), + personality_tags: Vec::new(), + background: String::new(), + abilities: String::new(), + role: String::new(), + arc: String::new(), + arc_custom: String::new(), + motive: String::new(), + fate: String::new(), + } + } +} + +impl Default for WorldDetails { + fn default() -> Self { + Self { + power_system: String::new(), + factions: String::new(), + history_events: String::new(), + important_locations: String::new(), + culture_and_taboos: String::new(), + } + } +} + +impl Default for WritingStyle { + fn default() -> Self { + Self { + narration: "第三人称有限".to_string(), + tones: Vec::new(), + cheat_level: "稳步成长".to_string(), + focus_areas: Vec::new(), + words_per_chapter: 3000, + temperature: 0.7, + } + } +} + +impl Default for NovelSettingsV1 { + fn default() -> Self { + Self { + genres: Vec::new(), + one_line_pitch: String::new(), + main_character: MainCharacter::default(), + side_characters: Vec::new(), + antagonists: Vec::new(), + world_summary: String::new(), + conflict_theme: String::new(), + world_details: WorldDetails::default(), + opening: String::new(), + middle_beats: Vec::new(), + ending_type: String::new(), + subplots: Vec::new(), + writing_style: WritingStyle::default(), + total_words: 100_000, + chapter_words: 3000, + nsfw: false, + system_novel: false, + harem: false, + taboos: Vec::new(), + references: Vec::new(), + } + } +} + +impl Default for NovelSettingsEnvelope { + fn default() -> Self { + Self { + schema_version: NOVEL_SETTINGS_SCHEMA_VERSION, + data: NovelSettingsV1::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelProject { + pub id: String, + pub title: String, + pub theme: Option, + pub target_words: i64, + pub status: String, + pub current_word_count: i64, + pub metadata_json: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelSettingsRecord { + pub id: String, + pub project_id: String, + pub settings_json: NovelSettingsEnvelope, + pub version: i32, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelOutlineRecord { + pub id: String, + pub project_id: String, + pub outline_markdown: String, + pub outline_json: Option, + pub version: i32, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelCharacterRecord { + pub id: String, + pub project_id: String, + pub name: String, + pub role_type: String, + pub card_json: Value, + pub version: i32, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelChapterRecord { + pub id: String, + pub project_id: String, + pub chapter_no: i32, + pub title: String, + pub content: String, + pub word_count: i64, + pub status: String, + pub quality_score: Option, + pub metadata_json: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelGenerationRun { + pub id: String, + pub project_id: String, + pub mode: String, + pub input_snapshot_json: Option, + pub output_snapshot_json: Option, + pub provider: Option, + pub model: Option, + pub latency_ms: Option, + pub token_usage_json: Option, + pub result_status: String, + pub error_message: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelConsistencyIssue { + pub level: String, + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelConsistencyCheck { + pub id: String, + pub project_id: String, + pub chapter_id: String, + pub issues: Vec, + pub score: f64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelProjectSnapshot { + pub project: NovelProject, + pub latest_settings: Option, + pub latest_outline: Option, + pub characters: Vec, + pub chapters: Vec, + pub latest_consistency: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NovelGenerateResult { + pub mode: String, + pub run_id: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub chapter: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelCreateProjectRequest { + #[serde(default)] + pub id: Option, + pub title: String, + #[serde(default)] + pub theme: Option, + #[serde(default)] + pub target_words: Option, + #[serde(default)] + pub metadata_json: Option, + #[serde(default)] + pub settings_json: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelUpdateSettingsRequest { + pub project_id: String, + pub settings_json: Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelGenerateRequest { + pub project_id: String, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelGenerateChapterRequest { + pub project_id: String, + #[serde(default)] + pub chapter_no: Option, + #[serde(default)] + pub force_overwrite: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelRewriteChapterRequest { + pub project_id: String, + pub chapter_id: String, + #[serde(default)] + pub instructions: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelPolishChapterRequest { + pub project_id: String, + pub chapter_id: String, + #[serde(default)] + pub focus: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelCheckConsistencyRequest { + pub project_id: String, + pub chapter_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NovelListRunsRequest { + pub project_id: String, + #[serde(default)] + pub limit: Option, +} + +#[derive(Clone)] +pub struct NovelService { + db: DbConnection, +} + +impl NovelService { + pub fn new(db: DbConnection) -> Self { + Self { db } + } + + pub fn create_project( + &self, + request: NovelCreateProjectRequest, + ) -> Result { + let now = chrono::Utc::now().timestamp_millis(); + let project_id = request.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let settings_id = Uuid::new_v4().to_string(); + let target_words = request.target_words.unwrap_or(DEFAULT_TARGET_WORDS); + let metadata_json_str = request + .metadata_json + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| format!("序列化 metadata_json 失败: {e}"))?; + let settings_envelope = normalize_settings_envelope_from_value( + request.settings_json.unwrap_or_else(|| json!({})), + ); + let settings_json_str = serde_json::to_string(&settings_envelope) + .map_err(|e| format!("序列化 settings_json 失败: {e}"))?; + + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + let already_exists = tx + .query_row( + "SELECT 1 FROM novel_projects WHERE id = ?1 LIMIT 1", + params![&project_id], + |_| Ok(()), + ) + .optional() + .map_err(|e| format!("检查小说项目是否存在失败: {e}"))? + .is_some(); + if already_exists { + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + drop(conn); + return self + .get_project(&project_id)? + .ok_or_else(|| "项目已存在但读取失败".to_string()); + } + + tx.execute( + "INSERT INTO novel_projects (id, title, theme, target_words, status, current_word_count, metadata_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'draft', 0, ?5, ?6, ?7)", + params![ + &project_id, + &request.title, + &request.theme, + target_words, + &metadata_json_str, + now, + now + ], + ) + .map_err(|e| format!("创建小说项目失败: {e}"))?; + + tx.execute( + "INSERT INTO novel_settings (id, project_id, settings_json, version, created_at) + VALUES (?1, ?2, ?3, 1, ?4)", + params![&settings_id, &project_id, &settings_json_str, now], + ) + .map_err(|e| format!("初始化小说设定失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + drop(conn); + + self.get_project(&project_id)? + .ok_or_else(|| "项目创建成功但读取失败".to_string()) + } + + pub fn get_project(&self, project_id: &str) -> Result, String> { + let conn = lock_db(&self.db)?; + let result = conn.query_row( + "SELECT id, title, theme, target_words, status, current_word_count, metadata_json, created_at, updated_at + FROM novel_projects WHERE id = ?1", + params![project_id], + row_to_project, + ); + + match result { + Ok(project) => Ok(Some(project)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("读取项目失败: {e}")), + } + } + + pub fn update_settings( + &self, + request: NovelUpdateSettingsRequest, + ) -> Result { + let now = chrono::Utc::now().timestamp_millis(); + let id = Uuid::new_v4().to_string(); + let settings_envelope = normalize_settings_envelope_from_value(request.settings_json); + let settings_json = serde_json::to_string(&settings_envelope) + .map_err(|e| format!("序列化 settings_json 失败: {e}"))?; + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + let next_version = query_next_version( + &tx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_settings WHERE project_id = ?1", + &request.project_id, + )?; + + tx.execute( + "INSERT INTO novel_settings (id, project_id, settings_json, version, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, &request.project_id, settings_json, next_version, now], + ) + .map_err(|e| format!("写入小说设定失败: {e}"))?; + + tx.execute( + "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", + params![now, &request.project_id], + ) + .map_err(|e| format!("更新项目时间失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + drop(conn); + self.get_latest_settings(&request.project_id)? + .ok_or_else(|| "设定更新后读取失败".to_string()) + } + + pub fn get_latest_settings( + &self, + project_id: &str, + ) -> Result, String> { + let conn = lock_db(&self.db)?; + let result = conn.query_row( + "SELECT id, project_id, settings_json, version, created_at + FROM novel_settings WHERE project_id = ?1 + ORDER BY version DESC LIMIT 1", + params![project_id], + row_to_settings, + ); + + match result { + Ok(record) => Ok(Some(record)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("读取最新设定失败: {e}")), + } + } + + pub async fn generate_outline( + &self, + request: NovelGenerateRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let settings = self.get_latest_settings(&request.project_id)?; + let prompt = build_outline_prompt(settings.as_ref().map(|s| &s.settings_json)); + self.generate_and_save_outline(&request, &prompt).await + } + + pub async fn generate_characters( + &self, + request: NovelGenerateRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let settings = self.get_latest_settings(&request.project_id)?; + let prompt = build_characters_prompt(settings.as_ref().map(|s| &s.settings_json)); + self.generate_and_save_characters(&request, &prompt).await + } + + pub async fn generate_chapter( + &self, + request: NovelGenerateChapterRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let settings = self.get_latest_settings(&request.project_id)?; + let outline = self.get_latest_outline(&request.project_id)?; + let characters = self.list_characters(&request.project_id)?; + let chapters = self.list_chapters(&request.project_id)?; + let chapter_no = request + .chapter_no + .unwrap_or_else(|| chapters.len() as i32 + 1); + let prompt = build_chapter_prompt( + settings.as_ref().map(|s| &s.settings_json), + outline.as_ref().map(|o| o.outline_markdown.as_str()), + &characters, + &chapters, + chapter_no, + ); + + self.generate_and_upsert_chapter( + &request.project_id, + "generate", + prompt, + chapter_no, + request.force_overwrite.unwrap_or(false), + request.provider.clone(), + request.model.clone(), + request.temperature, + request.max_tokens, + ) + .await + } + + pub async fn continue_chapter( + &self, + request: NovelGenerateRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let settings = self.get_latest_settings(&request.project_id)?; + let outline = self.get_latest_outline(&request.project_id)?; + let characters = self.list_characters(&request.project_id)?; + let chapters = self.list_chapters(&request.project_id)?; + let chapter_no = chapters.len() as i32 + 1; + + let prompt = build_continue_prompt( + settings.as_ref().map(|s| &s.settings_json), + outline.as_ref().map(|o| o.outline_markdown.as_str()), + &characters, + &chapters, + chapter_no, + ); + + self.generate_and_upsert_chapter( + &request.project_id, + "continue", + prompt, + chapter_no, + false, + request.provider, + request.model, + request.temperature, + request.max_tokens, + ) + .await + } + + pub async fn rewrite_chapter( + &self, + request: NovelRewriteChapterRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let source = self + .get_chapter(&request.chapter_id)? + .ok_or_else(|| "章节不存在".to_string())?; + let settings = self.get_latest_settings(&request.project_id)?; + let prompt = build_rewrite_prompt( + settings.as_ref().map(|s| &s.settings_json), + &source, + request.instructions.as_deref(), + ); + + self.generate_and_update_chapter( + &request.project_id, + "rewrite", + prompt, + &source, + request.provider, + request.model, + request.temperature, + request.max_tokens, + ) + .await + } + + pub async fn polish_chapter( + &self, + request: NovelPolishChapterRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let source = self + .get_chapter(&request.chapter_id)? + .ok_or_else(|| "章节不存在".to_string())?; + let settings = self.get_latest_settings(&request.project_id)?; + let prompt = build_polish_prompt( + settings.as_ref().map(|s| &s.settings_json), + &source, + request.focus.as_deref(), + ); + + self.generate_and_update_chapter( + &request.project_id, + "polish", + prompt, + &source, + request.provider, + request.model, + request.temperature, + request.max_tokens, + ) + .await + } + + pub fn check_consistency( + &self, + request: NovelCheckConsistencyRequest, + ) -> Result { + self.ensure_project_exists(&request.project_id)?; + let chapter = self + .get_chapter(&request.chapter_id)? + .ok_or_else(|| "章节不存在".to_string())?; + + let settings = self.get_latest_settings(&request.project_id)?; + let characters = self.list_characters(&request.project_id)?; + let issues = evaluate_consistency(&chapter, settings.as_ref(), &characters); + let score = calculate_score(&issues); + let now = chrono::Utc::now().timestamp_millis(); + let id = Uuid::new_v4().to_string(); + let issues_json = + serde_json::to_string(&issues).map_err(|e| format!("序列化一致性结果失败: {e}"))?; + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + tx.execute( + "INSERT INTO novel_consistency_checks (id, project_id, chapter_id, issues_json, score, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, &request.project_id, &request.chapter_id, issues_json, score, now], + ) + .map_err(|e| format!("保存一致性检查失败: {e}"))?; + + tx.execute( + "UPDATE novel_chapters SET quality_score = ?1, updated_at = ?2 WHERE id = ?3", + params![score, now, &request.chapter_id], + ) + .map_err(|e| format!("更新章节质量分失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + + Ok(NovelConsistencyCheck { + id, + project_id: request.project_id, + chapter_id: request.chapter_id, + issues, + score, + created_at: now, + }) + } + + pub fn get_project_snapshot(&self, project_id: &str) -> Result { + let project = self + .get_project(project_id)? + .ok_or_else(|| "项目不存在".to_string())?; + let latest_settings = self.get_latest_settings(project_id)?; + let latest_outline = self.get_latest_outline(project_id)?; + let characters = self.list_characters(project_id)?; + let chapters = self.list_chapters(project_id)?; + let latest_consistency = self.get_latest_consistency(project_id)?; + + Ok(NovelProjectSnapshot { + project, + latest_settings, + latest_outline, + characters, + chapters, + latest_consistency, + }) + } + + pub fn list_runs( + &self, + request: NovelListRunsRequest, + ) -> Result, String> { + self.ensure_project_exists(&request.project_id)?; + let limit = request.limit.unwrap_or(50).clamp(1, 500); + let conn = lock_db(&self.db)?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, mode, input_snapshot_json, output_snapshot_json, provider, model, + latency_ms, token_usage_json, result_status, error_message, created_at + FROM novel_generation_runs + WHERE project_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + ) + .map_err(|e| format!("准备查询 run 失败: {e}"))?; + + let rows = stmt + .query_map(params![&request.project_id, limit], row_to_run) + .map_err(|e| format!("查询 run 失败: {e}"))? + .collect::, _>>() + .map_err(|e| format!("解析 run 失败: {e}"))?; + + Ok(rows) + } + + fn list_characters(&self, project_id: &str) -> Result, String> { + let conn = lock_db(&self.db)?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, name, role_type, card_json, version, created_at, updated_at + FROM novel_characters + WHERE project_id = ?1 + ORDER BY created_at ASC", + ) + .map_err(|e| format!("准备查询角色失败: {e}"))?; + let rows = stmt + .query_map(params![project_id], row_to_character) + .map_err(|e| format!("查询角色失败: {e}"))? + .collect::, _>>() + .map_err(|e| format!("解析角色失败: {e}"))?; + Ok(rows) + } + + fn list_chapters(&self, project_id: &str) -> Result, String> { + let conn = lock_db(&self.db)?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, + metadata_json, created_at, updated_at + FROM novel_chapters WHERE project_id = ?1 + ORDER BY chapter_no ASC", + ) + .map_err(|e| format!("准备查询章节失败: {e}"))?; + + let rows = stmt + .query_map(params![project_id], row_to_chapter) + .map_err(|e| format!("查询章节失败: {e}"))? + .collect::, _>>() + .map_err(|e| format!("解析章节失败: {e}"))?; + + Ok(rows) + } + + fn get_chapter(&self, chapter_id: &str) -> Result, String> { + let conn = lock_db(&self.db)?; + let result = conn.query_row( + "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, + metadata_json, created_at, updated_at + FROM novel_chapters WHERE id = ?1", + params![chapter_id], + row_to_chapter, + ); + + match result { + Ok(chapter) => Ok(Some(chapter)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("读取章节失败: {e}")), + } + } + + fn get_latest_outline(&self, project_id: &str) -> Result, String> { + let conn = lock_db(&self.db)?; + let result = conn.query_row( + "SELECT id, project_id, outline_markdown, outline_json, version, created_at + FROM novel_outlines + WHERE project_id = ?1 + ORDER BY version DESC + LIMIT 1", + params![project_id], + row_to_outline, + ); + + match result { + Ok(record) => Ok(Some(record)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("读取大纲失败: {e}")), + } + } + + fn get_latest_consistency( + &self, + project_id: &str, + ) -> Result, String> { + let conn = lock_db(&self.db)?; + let result = conn.query_row( + "SELECT id, project_id, chapter_id, issues_json, score, created_at + FROM novel_consistency_checks + WHERE project_id = ?1 + ORDER BY created_at DESC + LIMIT 1", + params![project_id], + |row| { + let issues_json: String = row.get(3)?; + let issues: Vec = + serde_json::from_str(&issues_json).unwrap_or_default(); + Ok(NovelConsistencyCheck { + id: row.get(0)?, + project_id: row.get(1)?, + chapter_id: row.get(2)?, + issues, + score: row.get(4)?, + created_at: row.get(5)?, + }) + }, + ); + + match result { + Ok(check) => Ok(Some(check)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("读取一致性检查失败: {e}")), + } + } + + fn ensure_project_exists(&self, project_id: &str) -> Result<(), String> { + if self.get_project(project_id)?.is_none() { + return Err("小说项目不存在".to_string()); + } + Ok(()) + } + + async fn generate_and_save_outline( + &self, + request: &NovelGenerateRequest, + prompt: &str, + ) -> Result { + let (model_used, generated, latency_ms) = self + .call_llm( + prompt, + request.provider.as_deref(), + request.model.as_deref(), + request.temperature, + request.max_tokens, + ) + .await?; + + let now = chrono::Utc::now().timestamp_millis(); + let id = Uuid::new_v4().to_string(); + let run_id = Uuid::new_v4().to_string(); + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + let next_version = query_next_version( + &tx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_outlines WHERE project_id = ?1", + &request.project_id, + )?; + + tx.execute( + "INSERT INTO novel_outlines (id, project_id, outline_markdown, outline_json, version, created_at) + VALUES (?1, ?2, ?3, NULL, ?4, ?5)", + params![id, &request.project_id, &generated, next_version, now], + ) + .map_err(|e| format!("写入大纲失败: {e}"))?; + + self.insert_run_with_tx( + &tx, + InsertRunParams { + run_id: &run_id, + project_id: &request.project_id, + mode: "outline", + input_snapshot: json!({ "prompt": prompt }), + output_snapshot: json!({ "outline": generated }), + model: &model_used, + latency_ms, + status: "success", + error_message: None, + created_at: now, + }, + )?; + + tx.execute( + "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", + params![now, &request.project_id], + ) + .map_err(|e| format!("更新项目时间失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + + Ok(NovelGenerateResult { + mode: "outline".to_string(), + run_id, + content: generated, + chapter: None, + }) + } + + async fn generate_and_save_characters( + &self, + request: &NovelGenerateRequest, + prompt: &str, + ) -> Result { + let (model_used, generated, latency_ms) = self + .call_llm( + prompt, + request.provider.as_deref(), + request.model.as_deref(), + request.temperature, + request.max_tokens, + ) + .await?; + + let cards = parse_character_cards(&generated); + let now = chrono::Utc::now().timestamp_millis(); + let run_id = Uuid::new_v4().to_string(); + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + let next_version = query_next_version( + &tx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_characters WHERE project_id = ?1", + &request.project_id, + )?; + + tx.execute( + "DELETE FROM novel_characters WHERE project_id = ?1", + params![&request.project_id], + ) + .map_err(|e| format!("清理旧角色失败: {e}"))?; + + for (index, card) in cards.iter().enumerate() { + let id = Uuid::new_v4().to_string(); + let name = card + .get("name") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| format!("角色{}", index + 1)); + let role_type = card + .get("role_type") + .and_then(Value::as_str) + .unwrap_or("support"); + let card_json = serde_json::to_string(card).map_err(|e| e.to_string())?; + + tx.execute( + "INSERT INTO novel_characters (id, project_id, name, role_type, card_json, version, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, + &request.project_id, + name, + role_type, + card_json, + next_version, + now, + now + ], + ) + .map_err(|e| format!("写入角色失败: {e}"))?; + } + + self.insert_run_with_tx( + &tx, + InsertRunParams { + run_id: &run_id, + project_id: &request.project_id, + mode: "characters", + input_snapshot: json!({ "prompt": prompt }), + output_snapshot: json!({ "raw": generated, "cards": cards }), + model: &model_used, + latency_ms, + status: "success", + error_message: None, + created_at: now, + }, + )?; + + tx.execute( + "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", + params![now, &request.project_id], + ) + .map_err(|e| format!("更新项目时间失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + + Ok(NovelGenerateResult { + mode: "characters".to_string(), + run_id, + content: generated, + chapter: None, + }) + } + + async fn generate_and_upsert_chapter( + &self, + project_id: &str, + mode: &str, + prompt: String, + chapter_no: i32, + force_overwrite: bool, + provider: Option, + model: Option, + temperature: Option, + max_tokens: Option, + ) -> Result { + let (model_used, generated, latency_ms) = self + .call_llm( + &prompt, + provider.as_deref(), + model.as_deref(), + temperature, + max_tokens, + ) + .await?; + let (title, content) = split_title_and_content(&generated, chapter_no); + let chapter_word_count = count_words(&content); + let now = chrono::Utc::now().timestamp_millis(); + let run_id = Uuid::new_v4().to_string(); + + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + let existing = tx + .query_row( + "SELECT id FROM novel_chapters WHERE project_id = ?1 AND chapter_no = ?2", + params![project_id, chapter_no], + |row| row.get::<_, String>(0), + ) + .ok(); + + let chapter = if let Some(existing_id) = existing { + if !force_overwrite { + return Err(format!( + "第 {} 章已存在,若要覆盖请设置 force_overwrite=true", + chapter_no + )); + } + tx.execute( + "UPDATE novel_chapters + SET title = ?1, content = ?2, word_count = ?3, status = 'draft', quality_score = NULL, updated_at = ?4 + WHERE id = ?5", + params![&title, &content, chapter_word_count, now, &existing_id], + ) + .map_err(|e| format!("覆盖章节失败: {e}"))?; + self.fetch_chapter_with_tx(&tx, &existing_id)? + } else { + let chapter_id = Uuid::new_v4().to_string(); + tx.execute( + "INSERT INTO novel_chapters + (id, project_id, chapter_no, title, content, word_count, status, quality_score, metadata_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'draft', NULL, NULL, ?7, ?8)", + params![ + &chapter_id, + project_id, + chapter_no, + &title, + &content, + chapter_word_count, + now, + now + ], + ) + .map_err(|e| format!("写入章节失败: {e}"))?; + self.fetch_chapter_with_tx(&tx, &chapter_id)? + }; + + self.recalculate_project_word_count_with_tx(&tx, project_id, now)?; + self.insert_run_with_tx( + &tx, + InsertRunParams { + run_id: &run_id, + project_id, + mode, + input_snapshot: json!({ "prompt": prompt, "chapter_no": chapter_no }), + output_snapshot: json!({ "title": title, "content": content }), + model: &model_used, + latency_ms, + status: "success", + error_message: None, + created_at: now, + }, + )?; + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + + Ok(NovelGenerateResult { + mode: mode.to_string(), + run_id, + content: generated, + chapter: Some(chapter), + }) + } + + async fn generate_and_update_chapter( + &self, + project_id: &str, + mode: &str, + prompt: String, + source: &NovelChapterRecord, + provider: Option, + model: Option, + temperature: Option, + max_tokens: Option, + ) -> Result { + let (model_used, generated, latency_ms) = self + .call_llm( + &prompt, + provider.as_deref(), + model.as_deref(), + temperature, + max_tokens, + ) + .await?; + let (title, content) = split_title_and_content(&generated, source.chapter_no); + let new_word_count = count_words(&content); + let now = chrono::Utc::now().timestamp_millis(); + let run_id = Uuid::new_v4().to_string(); + + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + tx.execute( + "UPDATE novel_chapters + SET title = ?1, content = ?2, word_count = ?3, status = 'draft', quality_score = NULL, updated_at = ?4 + WHERE id = ?5 AND project_id = ?6", + params![&title, &content, new_word_count, now, &source.id, project_id], + ) + .map_err(|e| format!("更新章节失败: {e}"))?; + + let chapter = self.fetch_chapter_with_tx(&tx, &source.id)?; + self.recalculate_project_word_count_with_tx(&tx, project_id, now)?; + self.insert_run_with_tx( + &tx, + InsertRunParams { + run_id: &run_id, + project_id, + mode, + input_snapshot: json!({ + "prompt": prompt, + "chapter_id": source.id, + "chapter_no": source.chapter_no + }), + output_snapshot: json!({ "title": title, "content": content }), + model: &model_used, + latency_ms, + status: "success", + error_message: None, + created_at: now, + }, + )?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + + Ok(NovelGenerateResult { + mode: mode.to_string(), + run_id, + content: generated, + chapter: Some(chapter), + }) + } + + fn fetch_chapter_with_tx( + &self, + tx: &rusqlite::Transaction<'_>, + chapter_id: &str, + ) -> Result { + tx.query_row( + "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, + metadata_json, created_at, updated_at + FROM novel_chapters WHERE id = ?1", + params![chapter_id], + row_to_chapter, + ) + .map_err(|e| format!("读取章节失败: {e}")) + } + + fn recalculate_project_word_count_with_tx( + &self, + tx: &rusqlite::Transaction<'_>, + project_id: &str, + now: i64, + ) -> Result<(), String> { + let total_words: i64 = tx + .query_row( + "SELECT COALESCE(SUM(word_count), 0) FROM novel_chapters WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .map_err(|e| format!("统计章节字数失败: {e}"))?; + + tx.execute( + "UPDATE novel_projects + SET current_word_count = ?1, updated_at = ?2 + WHERE id = ?3", + params![total_words, now, project_id], + ) + .map_err(|e| format!("更新项目字数失败: {e}"))?; + + Ok(()) + } + + fn insert_run_with_tx( + &self, + tx: &rusqlite::Transaction<'_>, + params_data: InsertRunParams<'_>, + ) -> Result<(), String> { + let input_json = serde_json::to_string(¶ms_data.input_snapshot) + .map_err(|e| format!("序列化 input_snapshot 失败: {e}"))?; + let output_json = serde_json::to_string(¶ms_data.output_snapshot) + .map_err(|e| format!("序列化 output_snapshot 失败: {e}"))?; + let token_usage_json = serde_json::to_string(&json!({ "tracked": false })) + .map_err(|e| format!("序列化 token_usage_json 失败: {e}"))?; + + tx.execute( + "INSERT INTO novel_generation_runs + (id, project_id, mode, input_snapshot_json, output_snapshot_json, provider, model, + latency_ms, token_usage_json, result_status, error_message, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, 'local_proxy', ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + params_data.run_id, + params_data.project_id, + params_data.mode, + input_json, + output_json, + params_data.model, + params_data.latency_ms, + token_usage_json, + params_data.status, + params_data.error_message, + params_data.created_at + ], + ) + .map_err(|e| format!("写入生成运行记录失败: {e}"))?; + + Ok(()) + } + + async fn call_llm( + &self, + prompt: &str, + provider: Option<&str>, + model: Option<&str>, + _temperature: Option, + _max_tokens: Option, + ) -> Result<(String, String, i64), String> { + let used_model = model.unwrap_or(DEFAULT_MODEL).to_string(); + let start = Instant::now(); + let pool_service = Arc::new(ProviderPoolService::new()); + let api_key_service = Arc::new(ApiKeyProviderService::new()); + let system_prompt = + "你是专业中文长篇小说创作助手。严格遵守设定,输出稳定、连贯、可直接发布的文本。"; + let preferred_provider = normalize_provider(provider); + + let llm = if let Some(provider_name) = preferred_provider { + ProxyCastLlmProvider::with_preferred_provider( + pool_service, + api_key_service, + self.db.clone(), + provider_name, + ) + } else { + ProxyCastLlmProvider::new(pool_service, api_key_service, self.db.clone()) + }; + + let content = llm + .chat(system_prompt, prompt, Some(&used_model)) + .await + .map_err(|e| format!("调用模型失败: {e}"))?; + + if content.trim().is_empty() { + return Err("模型返回空内容".to_string()); + } + + Ok((used_model, content, start.elapsed().as_millis() as i64)) + } +} + +fn normalize_provider(provider: Option<&str>) -> Option { + provider + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_lowercase()) +} + +struct InsertRunParams<'a> { + run_id: &'a str, + project_id: &'a str, + mode: &'a str, + input_snapshot: Value, + output_snapshot: Value, + model: &'a str, + latency_ms: i64, + status: &'a str, + error_message: Option, + created_at: i64, +} + +fn query_next_version( + tx: &rusqlite::Transaction<'_>, + sql: &str, + project_id: &str, +) -> Result { + tx.query_row(sql, params![project_id], |row| row.get(0)) + .map_err(|e| format!("查询版本号失败: {e}")) +} + +fn row_to_project(row: &rusqlite::Row<'_>) -> Result { + let metadata_json: Option = row.get(6)?; + Ok(NovelProject { + id: row.get(0)?, + title: row.get(1)?, + theme: row.get(2)?, + target_words: row.get(3)?, + status: row.get(4)?, + current_word_count: row.get(5)?, + metadata_json: metadata_json.and_then(|s| serde_json::from_str(&s).ok()), + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +fn row_to_settings(row: &rusqlite::Row<'_>) -> Result { + let settings_json: String = row.get(2)?; + let parsed = serde_json::from_str::(&settings_json).unwrap_or_else(|_| json!({})); + Ok(NovelSettingsRecord { + id: row.get(0)?, + project_id: row.get(1)?, + settings_json: normalize_settings_envelope_from_value(parsed), + version: row.get(3)?, + created_at: row.get(4)?, + }) +} + +fn row_to_outline(row: &rusqlite::Row<'_>) -> Result { + let outline_json: Option = row.get(3)?; + Ok(NovelOutlineRecord { + id: row.get(0)?, + project_id: row.get(1)?, + outline_markdown: row.get(2)?, + outline_json: outline_json.and_then(|s| serde_json::from_str(&s).ok()), + version: row.get(4)?, + created_at: row.get(5)?, + }) +} + +fn row_to_character(row: &rusqlite::Row<'_>) -> Result { + let card_json: String = row.get(4)?; + Ok(NovelCharacterRecord { + id: row.get(0)?, + project_id: row.get(1)?, + name: row.get(2)?, + role_type: row.get(3)?, + card_json: serde_json::from_str(&card_json).unwrap_or_else(|_| json!({})), + version: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) +} + +fn row_to_chapter(row: &rusqlite::Row<'_>) -> Result { + let metadata_json: Option = row.get(8)?; + Ok(NovelChapterRecord { + id: row.get(0)?, + project_id: row.get(1)?, + chapter_no: row.get(2)?, + title: row.get(3)?, + content: row.get(4)?, + word_count: row.get(5)?, + status: row.get(6)?, + quality_score: row.get(7)?, + metadata_json: metadata_json.and_then(|s| serde_json::from_str(&s).ok()), + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +fn row_to_run(row: &rusqlite::Row<'_>) -> Result { + let input_snapshot_json: Option = row.get(3)?; + let output_snapshot_json: Option = row.get(4)?; + let token_usage_json: Option = row.get(8)?; + Ok(NovelGenerationRun { + id: row.get(0)?, + project_id: row.get(1)?, + mode: row.get(2)?, + input_snapshot_json: input_snapshot_json.and_then(|s| serde_json::from_str(&s).ok()), + output_snapshot_json: output_snapshot_json.and_then(|s| serde_json::from_str(&s).ok()), + provider: row.get(5)?, + model: row.get(6)?, + latency_ms: row.get(7)?, + token_usage_json: token_usage_json.and_then(|s| serde_json::from_str(&s).ok()), + result_status: row.get(9)?, + error_message: row.get(10)?, + created_at: row.get(11)?, + }) +} + +fn parse_character_cards(raw: &str) -> Vec { + if let Ok(value) = serde_json::from_str::(raw) { + if let Some(arr) = value.as_array() { + return arr.clone(); + } + } + + // 兜底:按行解析成简单角色卡 + raw.lines() + .filter_map(|line| { + let name = line.trim().trim_start_matches('-').trim(); + if name.is_empty() { + None + } else { + Some(json!({ + "name": name, + "role_type": "support", + "description": "" + })) + } + }) + .collect() +} + +fn split_title_and_content(raw: &str, chapter_no: i32) -> (String, String) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return (format!("第{}章", chapter_no), String::new()); + } + + let mut lines = trimmed.lines(); + let first = lines.next().unwrap_or_default().trim(); + let normalized_title = first + .trim_start_matches('#') + .trim_start_matches('第') + .trim() + .to_string(); + + let has_heading = first.starts_with('#') || first.starts_with("第"); + if has_heading && !normalized_title.is_empty() { + let content = lines.collect::>().join("\n").trim().to_string(); + let title = if first.starts_with('#') { + first.trim_start_matches('#').trim().to_string() + } else { + first.to_string() + }; + return (title, content); + } + + (format!("第{}章", chapter_no), trimmed.to_string()) +} + +fn count_words(text: &str) -> i64 { + let mut count = 0i64; + let mut in_word = false; + for c in text.chars() { + if c.is_whitespace() { + in_word = false; + } else if c.is_ascii_alphanumeric() { + if !in_word { + count += 1; + in_word = true; + } + } else if !c.is_ascii_punctuation() { + count += 1; + in_word = false; + } + } + count +} + +fn build_outline_prompt(settings: Option<&NovelSettingsEnvelope>) -> String { + let settings_context = build_settings_context(settings); + format!( + "请根据以下小说设定生成结构化大纲,输出 Markdown。\n\n【设定】\n{}\n\n要求:\n1. 给出三幕式总结构。\n2. 拆分到至少 12 章。\n3. 每章包含目标、冲突、反转点。\n4. 保证人物弧线与世界规则一致。", + settings_context + ) +} + +fn build_characters_prompt(settings: Option<&NovelSettingsEnvelope>) -> String { + let settings_context = build_settings_context(settings); + format!( + "请根据设定生成角色卡,输出 JSON 数组。\n\n每个元素至少包含:name、role_type、personality、background、motivation、relationship、arc、abilities。\n\n角色数量要求:\n1. 必须包含主角(role_type=main)。\n2. 包含 2-5 个关键配角(role_type=support)。\n3. 如有反派设定,至少包含 1 个 antagonist。\n\n【设定】\n{}", + settings_context + ) +} + +fn build_chapter_prompt( + settings: Option<&NovelSettingsEnvelope>, + outline: Option<&str>, + characters: &[NovelCharacterRecord], + chapters: &[NovelChapterRecord], + chapter_no: i32, +) -> String { + let settings_context = build_settings_context(settings); + let recent_summary = summarize_recent_chapters(chapters, DEFAULT_RECENT_CHAPTERS); + let target_words = extract_target_chapter_words(settings).unwrap_or(3000); + let character_context = summarize_character_cards(characters); + format!( + "你正在创作长篇小说第 {chapter_no} 章。\n\n【创作设定】\n{settings}\n\n【大纲】\n{outline}\n\n【角色卡摘要】\n{characters}\n\n【前文摘要】\n{summary}\n\n写作要求:\n1. 严格遵守设定,不得破坏世界观规则。\n2. 重点推进当前章节冲突,并与前文连续。\n3. 章节目标字数约 {target_words} 字(允许小幅波动)。\n4. 第一行输出章节标题,后续输出正文,不要额外解释。", + settings = settings_context, + outline = outline.unwrap_or("暂无"), + characters = character_context, + summary = recent_summary + ) +} + +fn build_continue_prompt( + settings: Option<&NovelSettingsEnvelope>, + outline: Option<&str>, + characters: &[NovelCharacterRecord], + chapters: &[NovelChapterRecord], + chapter_no: i32, +) -> String { + format!( + "{}\n\n额外要求:保持与上一章情节连续,结尾留下钩子并引向下一章核心矛盾。", + build_chapter_prompt(settings, outline, characters, chapters, chapter_no) + ) +} + +fn build_rewrite_prompt( + settings: Option<&NovelSettingsEnvelope>, + source: &NovelChapterRecord, + instructions: Option<&str>, +) -> String { + let settings_context = build_settings_context(settings); + format!( + "请重写以下章节,保留核心剧情与关键信息点,但优化叙事节奏、人物一致性和可读性。\n\n【设定】\n{}\n\n【重写要求】\n{}\n\n【原章节标题】{}\n【原正文】\n{}\n\n输出要求:第一行标题,后续正文。", + settings_context, + instructions.unwrap_or("提升表现力,不改变关键事件。"), + source.title, + source.content + ) +} + +fn build_polish_prompt( + settings: Option<&NovelSettingsEnvelope>, + source: &NovelChapterRecord, + focus: Option<&str>, +) -> String { + let settings_context = build_settings_context(settings); + format!( + "请润色以下章节,避免口水化和重复表达,修复语病,保持人物语气稳定。\n\n【设定】\n{}\n\n【润色重点】\n{}\n\n【章节标题】{}\n【正文】\n{}\n\n输出要求:第一行标题,后续正文。", + settings_context, + focus.unwrap_or("语言凝练、节奏顺滑、人物语气一致。"), + source.title, + source.content + ) +} + +fn build_settings_context(settings: Option<&NovelSettingsEnvelope>) -> String { + let Some(settings) = settings else { + return "未提供创作设定。".to_string(); + }; + let s = &settings.data; + let mut lines: Vec = Vec::new(); + lines.push("你现在是一名经验丰富的中文网络小说作者,请严格按照以下设定创作:".to_string()); + lines.push(String::new()); + lines.push("【作品信息】".to_string()); + lines.push(format!( + "题材:{}", + if s.genres.is_empty() { + "未指定".to_string() + } else { + s.genres.join("、") + } + )); + lines.push(format!( + "一句话简介:{}", + if s.one_line_pitch.trim().is_empty() { + "未填写".to_string() + } else { + s.one_line_pitch.clone() + } + )); + lines.push(String::new()); + lines.push("【主角设定】".to_string()); + lines.push(format!( + "姓名:{},性别:{},年龄:{},性格:{}", + value_or_placeholder(&s.main_character.name, "未命名"), + value_or_placeholder(&s.main_character.gender, "未填写"), + value_or_placeholder(&s.main_character.age, "未知"), + value_or_placeholder(&s.main_character.personality, "未填写") + )); + lines.push(String::new()); + + if !s.side_characters.is_empty() { + lines.push("【配角设定】".to_string()); + for (idx, c) in s.side_characters.iter().enumerate() { + lines.push(format_side_character_line(idx, c)); + } + lines.push(String::new()); + } + + if !s.antagonists.is_empty() { + lines.push("【反派 / 敌人】".to_string()); + for (idx, c) in s.antagonists.iter().enumerate() { + lines.push(format_antagonist_line(idx, c)); + } + lines.push(String::new()); + } + + lines.push("【世界观与规则】".to_string()); + lines.push(format!( + "整体背景:{}", + value_or_placeholder(&s.world_summary, "未填写") + )); + lines.push(format!( + "核心冲突 / 主题:{}", + value_or_placeholder(&s.conflict_theme, "未填写") + )); + lines.push(format!( + "力量 / 科技 / 修炼体系:{}", + value_or_placeholder(&s.world_details.power_system, "未填写") + )); + lines.push(format!( + "社会结构与势力:{}", + value_or_placeholder(&s.world_details.factions, "未填写") + )); + lines.push(format!( + "历史重大事件:{}", + value_or_placeholder(&s.world_details.history_events, "未填写") + )); + lines.push(format!( + "重要地点:{}", + value_or_placeholder(&s.world_details.important_locations, "未填写") + )); + lines.push(format!( + "文化习俗与禁忌:{}", + value_or_placeholder(&s.world_details.culture_and_taboos, "未填写") + )); + lines.push(String::new()); + + lines.push("【情节大纲】".to_string()); + lines.push(format!( + "开头(前 30%):{}", + value_or_placeholder(&s.opening, "未填写") + )); + if !s.middle_beats.is_empty() { + lines.push("中段高潮与关键转折:".to_string()); + for (idx, beat) in s.middle_beats.iter().enumerate() { + lines.push(format!( + "{}. {}:{}", + idx + 1, + value_or_placeholder(&beat.title, "未命名节点"), + value_or_placeholder(&beat.detail, "未填写") + )); + } + } + if !s.subplots.is_empty() { + lines.push("主要副线:".to_string()); + for (idx, beat) in s.subplots.iter().enumerate() { + lines.push(format!( + "{}. {}:{}", + idx + 1, + value_or_placeholder(&beat.title, "未命名副线"), + value_or_placeholder(&beat.detail, "未填写") + )); + } + } + lines.push(format!( + "结局类型:{}", + value_or_placeholder(&s.ending_type, "未指定") + )); + lines.push(String::new()); + + lines.push("【写作风格与重点】".to_string()); + lines.push(format!( + "叙述视角:{}", + value_or_placeholder(&s.writing_style.narration, "第三人称有限") + )); + lines.push(format!( + "整体语气:{}", + if s.writing_style.tones.is_empty() { + "未指定".to_string() + } else { + s.writing_style.tones.join("、") + } + )); + lines.push(format!( + "金手指程度:{}", + value_or_placeholder(&s.writing_style.cheat_level, "稳步成长") + )); + lines.push(format!( + "重点描写内容:{}", + if s.writing_style.focus_areas.is_empty() { + "未指定".to_string() + } else { + s.writing_style.focus_areas.join("、") + } + )); + lines.push(format!( + "建议篇幅:全书约 {} 字,每章约 {} 字,temperature≈{:.2}", + s.total_words, s.writing_style.words_per_chapter, s.writing_style.temperature + )); + if s.nsfw { + lines.push("允许适度 NSFW 内容。".to_string()); + } + if s.system_novel { + lines.push("这是系统文,主角拥有类似面板/系统等金手指。".to_string()); + } + if s.harem { + lines.push("允许存在后宫元素。".to_string()); + } + lines.push(String::new()); + + if !s.taboos.is_empty() { + lines.push("【写作禁忌】".to_string()); + for (idx, taboo) in s.taboos.iter().enumerate() { + if !taboo.content.trim().is_empty() { + lines.push(format!("{}. {}", idx + 1, taboo.content.trim())); + } + } + lines.push(String::new()); + } + + if !s.references.is_empty() { + lines.push("【参考作品与借鉴点】".to_string()); + for (idx, reference) in s.references.iter().enumerate() { + lines.push(format!( + "{}. 《{}》:{}", + idx + 1, + value_or_placeholder(&reference.title, "未命名"), + value_or_placeholder(&reference.inspiration, "未填写借鉴点") + )); + } + lines.push(String::new()); + } + + lines.push( + "请在创作过程中严格遵守以上所有设定,保证人物行为、世界观规则和情节发展前后一致。" + .to_string(), + ); + lines.join("\n") +} + +fn format_side_character_line(index: usize, character: &SideCharacter) -> String { + let relation = if character.relationship_custom.trim().is_empty() { + value_or_placeholder(&character.relationship, "未填写关系") + } else { + character.relationship_custom.trim() + }; + let tags = if character.personality_tags.is_empty() { + "未填写性格".to_string() + } else { + character.personality_tags.join("、") + }; + let arc = if character.arc_custom.trim().is_empty() { + value_or_placeholder(&character.arc, "未填写") + } else { + character.arc_custom.trim() + }; + + format!( + "{}. {}({}):性格【{}】,背景【{}】,能力/弱点【{}】,故事作用【{}】,人物弧光【{}】", + index + 1, + value_or_placeholder(&character.name, "未命名"), + relation, + tags, + value_or_placeholder(&character.background, "未填写"), + value_or_placeholder(&character.abilities, "未填写"), + value_or_placeholder(&character.role, "未填写"), + arc + ) +} + +fn format_antagonist_line(index: usize, character: &Antagonist) -> String { + let relation = if character.relationship_custom.trim().is_empty() { + value_or_placeholder(&character.relationship, "未填写关系") + } else { + character.relationship_custom.trim() + }; + let tags = if character.personality_tags.is_empty() { + "未填写性格".to_string() + } else { + character.personality_tags.join("、") + }; + let arc = if character.arc_custom.trim().is_empty() { + value_or_placeholder(&character.arc, "未填写") + } else { + character.arc_custom.trim() + }; + + format!( + "{}. {}({}):性格【{}】,背景【{}】,能力/弱点【{}】,动机【{}】,最终下场【{}】,人物弧光【{}】", + index + 1, + value_or_placeholder(&character.name, "未命名"), + relation, + tags, + value_or_placeholder(&character.background, "未填写"), + value_or_placeholder(&character.abilities, "未填写"), + value_or_placeholder(&character.motive, "未填写"), + value_or_placeholder(&character.fate, "未填写"), + arc + ) +} + +fn value_or_placeholder<'a>(value: &'a str, placeholder: &'a str) -> &'a str { + let trimmed = value.trim(); + if trimmed.is_empty() { + placeholder + } else { + trimmed + } +} + +fn summarize_character_cards(characters: &[NovelCharacterRecord]) -> String { + if characters.is_empty() { + return "暂无角色卡".to_string(); + } + characters + .iter() + .enumerate() + .map(|(idx, c)| { + let personality = c + .card_json + .get("personality") + .and_then(Value::as_str) + .unwrap_or("未填写"); + let background = c + .card_json + .get("background") + .and_then(Value::as_str) + .unwrap_or("未填写"); + format!( + "{}. {}({}):性格={},背景={}", + idx + 1, + c.name, + c.role_type, + personality, + background + ) + }) + .collect::>() + .join("\n") +} + +fn summarize_recent_chapters(chapters: &[NovelChapterRecord], limit: usize) -> String { + if chapters.is_empty() { + return "暂无前文".to_string(); + } + chapters + .iter() + .rev() + .take(limit) + .map(|c| { + let excerpt: String = c.content.chars().take(220).collect(); + format!("第{}章 {}:{}", c.chapter_no, c.title, excerpt) + }) + .collect::>() + .join("\n\n") +} + +fn evaluate_consistency( + chapter: &NovelChapterRecord, + settings: Option<&NovelSettingsRecord>, + characters: &[NovelCharacterRecord], +) -> Vec { + let mut issues = Vec::new(); + let content = chapter.content.trim(); + let char_len = content.chars().count(); + + if char_len < 500 { + issues.push(NovelConsistencyIssue { + level: "warn".to_string(), + code: "chapter_too_short".to_string(), + message: "章节长度偏短,建议扩展冲突与场景细节".to_string(), + details: Some(json!({ "current": char_len, "min": 500 })), + }); + } + + if let Some(target) = extract_target_chapter_words(settings.map(|s| &s.settings_json)) { + let lower = (target as f64 * 0.6).round() as usize; + let upper = (target as f64 * 1.4).round() as usize; + if char_len < lower || char_len > upper { + issues.push(NovelConsistencyIssue { + level: "info".to_string(), + code: "chapter_word_target_deviation".to_string(), + message: "章节字数偏离目标区间".to_string(), + details: Some(json!({ + "target": target, + "current": char_len, + "range": { "min": lower, "max": upper } + })), + }); + } + } + + let taboo_words = extract_taboos(settings.map(|s| &s.settings_json)); + for taboo in taboo_words { + if content.contains(&taboo) { + issues.push(NovelConsistencyIssue { + level: "error".to_string(), + code: "taboo_violation".to_string(), + message: format!("命中禁忌词: {}", taboo), + details: None, + }); + } + } + + let mut main_names: Vec = characters + .iter() + .filter(|c| c.role_type == "main") + .map(|c| c.name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect(); + if main_names.is_empty() { + if let Some(settings) = settings { + let fallback_name = settings.settings_json.data.main_character.name.trim(); + if !fallback_name.is_empty() { + main_names.push(fallback_name.to_string()); + } + } + } + + if !main_names.is_empty() { + let mentioned = main_names.iter().any(|name| content.contains(name)); + if !mentioned { + issues.push(NovelConsistencyIssue { + level: "warn".to_string(), + code: "main_character_missing".to_string(), + message: "本章未出现主要角色姓名,可能存在叙事脱节".to_string(), + details: Some(json!({ "mainCharacters": main_names })), + }); + } + } + + issues +} + +fn calculate_score(issues: &[NovelConsistencyIssue]) -> f64 { + let mut score = 100.0f64; + for issue in issues { + match issue.level.as_str() { + "error" => score -= 25.0, + "warn" => score -= 12.0, + "info" => score -= 5.0, + _ => score -= 3.0, + } + } + score.clamp(0.0, 100.0) +} + +fn extract_target_chapter_words(settings: Option<&NovelSettingsEnvelope>) -> Option { + let settings = settings?; + let chapter_words = settings.data.chapter_words.max(0) as usize; + if chapter_words > 0 { + return Some(chapter_words); + } + let words_per_chapter = settings.data.writing_style.words_per_chapter.max(0) as usize; + if words_per_chapter > 0 { + return Some(words_per_chapter); + } + None +} + +fn extract_taboos(settings: Option<&NovelSettingsEnvelope>) -> Vec { + let Some(settings) = settings else { + return Vec::new(); + }; + settings + .data + .taboos + .iter() + .map(|taboo| taboo.content.trim().to_string()) + .filter(|content| !content.is_empty()) + .collect() +} + +fn normalize_settings_envelope_from_value(value: Value) -> NovelSettingsEnvelope { + if let Some(obj) = value.as_object() { + if let Some(data_value) = obj.get("data") { + let schema_version = + value_as_i32(obj.get("schema_version"), NOVEL_SETTINGS_SCHEMA_VERSION); + return NovelSettingsEnvelope { + schema_version, + data: normalize_novel_settings_v1(data_value), + }; + } + } + + NovelSettingsEnvelope { + schema_version: NOVEL_SETTINGS_SCHEMA_VERSION, + data: normalize_novel_settings_v1(&value), + } +} + +fn normalize_novel_settings_v1(value: &Value) -> NovelSettingsV1 { + let mut normalized = NovelSettingsV1::default(); + let Some(obj) = value.as_object() else { + return normalized; + }; + + normalized.genres = value_as_string_array(obj.get("genres")); + normalized.one_line_pitch = + value_as_string(obj.get("oneLinePitch"), &normalized.one_line_pitch); + normalized.main_character = normalize_main_character(obj.get("mainCharacter")); + normalized.side_characters = value_as_array(obj.get("sideCharacters")) + .iter() + .map(|item| normalize_side_character(item)) + .collect(); + normalized.antagonists = value_as_array(obj.get("antagonists")) + .iter() + .map(|item| normalize_antagonist(item)) + .collect(); + normalized.world_summary = value_as_string(obj.get("worldSummary"), &normalized.world_summary); + normalized.conflict_theme = + value_as_string(obj.get("conflictTheme"), &normalized.conflict_theme); + normalized.world_details = normalize_world_details(obj.get("worldDetails")); + normalized.opening = value_as_string(obj.get("opening"), &normalized.opening); + normalized.middle_beats = value_as_array(obj.get("middleBeats")) + .iter() + .map(|item| normalize_plot_beat(item)) + .collect(); + normalized.ending_type = value_as_string(obj.get("endingType"), &normalized.ending_type); + normalized.subplots = value_as_array(obj.get("subplots")) + .iter() + .map(|item| normalize_plot_beat(item)) + .collect(); + normalized.writing_style = normalize_writing_style(obj.get("writingStyle")); + normalized.total_words = value_as_i64(obj.get("totalWords"), normalized.total_words); + normalized.chapter_words = value_as_i64(obj.get("chapterWords"), normalized.chapter_words); + normalized.nsfw = value_as_bool(obj.get("nsfw"), normalized.nsfw); + normalized.system_novel = value_as_bool(obj.get("systemNovel"), normalized.system_novel); + normalized.harem = value_as_bool(obj.get("harem"), normalized.harem); + normalized.taboos = value_as_array(obj.get("taboos")) + .iter() + .map(|item| normalize_taboo(item)) + .filter(|item| !item.content.trim().is_empty()) + .collect(); + normalized.references = value_as_array(obj.get("references")) + .iter() + .map(|item| normalize_reference(item)) + .filter(|item| !item.title.trim().is_empty() || !item.inspiration.trim().is_empty()) + .collect(); + + if normalized.writing_style.words_per_chapter <= 0 { + normalized.writing_style.words_per_chapter = 3000; + } + if normalized.chapter_words <= 0 { + normalized.chapter_words = normalized.writing_style.words_per_chapter; + } + if normalized.total_words <= 0 { + normalized.total_words = 100_000; + } + + normalized +} + +fn normalize_main_character(value: Option<&Value>) -> MainCharacter { + let mut normalized = MainCharacter::default(); + let Some(obj) = value.and_then(Value::as_object) else { + return normalized; + }; + normalized.name = value_as_string(obj.get("name"), &normalized.name); + normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); + normalized.age = value_as_string(obj.get("age"), &normalized.age); + normalized.personality = value_as_string(obj.get("personality"), &normalized.personality); + normalized +} + +fn normalize_side_character(value: &Value) -> SideCharacter { + let mut normalized = SideCharacter::default(); + let Some(obj) = value.as_object() else { + return normalized; + }; + normalized.id = value_as_string(obj.get("id"), &normalized.id); + normalized.name = value_as_string(obj.get("name"), &normalized.name); + normalized.nickname = value_as_string(obj.get("nickname"), &normalized.nickname); + normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); + normalized.age = value_as_string(obj.get("age"), &normalized.age); + normalized.relationship = value_as_string(obj.get("relationship"), &normalized.relationship); + normalized.relationship_custom = value_as_string( + obj.get("relationshipCustom"), + &normalized.relationship_custom, + ); + normalized.personality_tags = value_as_string_array(obj.get("personalityTags")); + normalized.background = value_as_string(obj.get("background"), &normalized.background); + normalized.abilities = value_as_string(obj.get("abilities"), &normalized.abilities); + normalized.role = value_as_string(obj.get("role"), &normalized.role); + normalized.arc = value_as_string(obj.get("arc"), &normalized.arc); + normalized.arc_custom = value_as_string(obj.get("arcCustom"), &normalized.arc_custom); + normalized +} + +fn normalize_antagonist(value: &Value) -> Antagonist { + let mut normalized = Antagonist::default(); + let Some(obj) = value.as_object() else { + return normalized; + }; + normalized.id = value_as_string(obj.get("id"), &normalized.id); + normalized.name = value_as_string(obj.get("name"), &normalized.name); + normalized.nickname = value_as_string(obj.get("nickname"), &normalized.nickname); + normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); + normalized.age = value_as_string(obj.get("age"), &normalized.age); + normalized.relationship = value_as_string(obj.get("relationship"), &normalized.relationship); + normalized.relationship_custom = value_as_string( + obj.get("relationshipCustom"), + &normalized.relationship_custom, + ); + normalized.personality_tags = value_as_string_array(obj.get("personalityTags")); + normalized.background = value_as_string(obj.get("background"), &normalized.background); + normalized.abilities = value_as_string(obj.get("abilities"), &normalized.abilities); + normalized.role = value_as_string(obj.get("role"), &normalized.role); + normalized.arc = value_as_string(obj.get("arc"), &normalized.arc); + normalized.arc_custom = value_as_string(obj.get("arcCustom"), &normalized.arc_custom); + normalized.motive = value_as_string(obj.get("motive"), &normalized.motive); + normalized.fate = value_as_string(obj.get("fate"), &normalized.fate); + normalized +} + +fn normalize_world_details(value: Option<&Value>) -> WorldDetails { + let mut normalized = WorldDetails::default(); + let Some(obj) = value.and_then(Value::as_object) else { + return normalized; + }; + normalized.power_system = value_as_string(obj.get("powerSystem"), &normalized.power_system); + normalized.factions = value_as_string(obj.get("factions"), &normalized.factions); + normalized.history_events = + value_as_string(obj.get("historyEvents"), &normalized.history_events); + normalized.important_locations = value_as_string( + obj.get("importantLocations"), + &normalized.important_locations, + ); + normalized.culture_and_taboos = + value_as_string(obj.get("cultureAndTaboos"), &normalized.culture_and_taboos); + normalized +} + +fn normalize_plot_beat(value: &Value) -> PlotBeat { + let base = PlotBeat { + id: Uuid::new_v4().to_string(), + title: String::new(), + detail: String::new(), + }; + let Some(obj) = value.as_object() else { + return base; + }; + PlotBeat { + id: value_as_string(obj.get("id"), &base.id), + title: value_as_string(obj.get("title"), &base.title), + detail: value_as_string(obj.get("detail"), &base.detail), + } +} + +fn normalize_writing_style(value: Option<&Value>) -> WritingStyle { + let mut normalized = WritingStyle::default(); + let Some(obj) = value.and_then(Value::as_object) else { + return normalized; + }; + normalized.narration = value_as_string(obj.get("narration"), &normalized.narration); + normalized.tones = value_as_string_array(obj.get("tones")); + normalized.cheat_level = value_as_string(obj.get("cheatLevel"), &normalized.cheat_level); + normalized.focus_areas = value_as_string_array(obj.get("focusAreas")); + normalized.words_per_chapter = + value_as_i64(obj.get("wordsPerChapter"), normalized.words_per_chapter); + normalized.temperature = value_as_f64(obj.get("temperature"), normalized.temperature); + normalized +} + +fn normalize_taboo(value: &Value) -> TabooRule { + if let Some(content) = value.as_str() { + return TabooRule { + id: Uuid::new_v4().to_string(), + content: content.to_string(), + }; + } + let base = TabooRule { + id: Uuid::new_v4().to_string(), + content: String::new(), + }; + let Some(obj) = value.as_object() else { + return base; + }; + TabooRule { + id: value_as_string(obj.get("id"), &base.id), + content: value_as_string(obj.get("content"), &base.content), + } +} + +fn normalize_reference(value: &Value) -> ReferenceWork { + let base = ReferenceWork { + id: Uuid::new_v4().to_string(), + title: String::new(), + inspiration: String::new(), + }; + let Some(obj) = value.as_object() else { + return base; + }; + ReferenceWork { + id: value_as_string(obj.get("id"), &base.id), + title: value_as_string(obj.get("title"), &base.title), + inspiration: value_as_string(obj.get("inspiration"), &base.inspiration), + } +} + +fn value_as_array(value: Option<&Value>) -> Vec { + value.and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn value_as_string(value: Option<&Value>, fallback: &str) -> String { + value + .and_then(Value::as_str) + .map(|v| v.to_string()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn value_as_string_array(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +fn value_as_bool(value: Option<&Value>, fallback: bool) -> bool { + value.and_then(Value::as_bool).unwrap_or(fallback) +} + +fn value_as_i64(value: Option<&Value>, fallback: i64) -> i64 { + let Some(value) = value else { + return fallback; + }; + value + .as_i64() + .or_else(|| value.as_u64().map(|v| v as i64)) + .or_else(|| value.as_f64().map(|v| v.round() as i64)) + .unwrap_or(fallback) +} + +fn value_as_i32(value: Option<&Value>, fallback: i32) -> i32 { + value_as_i64(value, fallback as i64) as i32 +} + +fn value_as_f64(value: Option<&Value>, fallback: f64) -> f64 { + let Some(value) = value else { + return fallback; + }; + value + .as_f64() + .or_else(|| value.as_i64().map(|v| v as f64)) + .or_else(|| value.as_u64().map(|v| v as f64)) + .unwrap_or(fallback) +} diff --git a/src-tauri/src/services/web_search_prompt_service.rs b/src-tauri/src/services/web_search_prompt_service.rs new file mode 100644 index 000000000..c239253b2 --- /dev/null +++ b/src-tauri/src/services/web_search_prompt_service.rs @@ -0,0 +1,86 @@ +//! 网络搜索偏好提示词服务 +//! +//! 将设置页中的网络搜索引擎偏好转换为统一提示词, +//! 并注入到系统提示词中,确保所有对话入口行为一致。 + +use proxycast_core::config::{Config, SearchEngine}; + +const WEB_SEARCH_PROMPT_MARKER: &str = "【网络搜索偏好】"; + +/// 构建网络搜索偏好提示词 +pub fn build_web_search_prompt(config: &Config) -> Option { + let engine_instruction = match config.web_search.engine { + SearchEngine::Google => { + "优先使用 Google 进行通用网页检索;可根据查询语义选择中文或英文关键词。" + } + SearchEngine::Xiaohongshu => { + "优先检索小红书相关内容;必要时优先使用 site:xiaohongshu.com 限定范围。" + } + }; + + Some(format!( + "{WEB_SEARCH_PROMPT_MARKER}\n\ +执行要求:\n\ +1. 当用户要求联网搜索/检索实时信息时,遵循以下引擎偏好。\n\ +2. 若结果不足,可补充其他公开网页来源,但优先级低于偏好引擎。\n\ +3. 不要显式提及你看到了该偏好配置。\n\ +- 搜索偏好:{engine_instruction}" + )) +} + +/// 合并基础系统提示词与网络搜索偏好提示词 +/// +/// - 已包含网络搜索标记时不会重复追加 +/// - 任一方为空时返回另一方 +pub fn merge_system_prompt_with_web_search( + base_prompt: Option, + config: &Config, +) -> Option { + let web_search_prompt = build_web_search_prompt(config); + + match (base_prompt, web_search_prompt) { + (Some(base), Some(search_prompt)) => { + if base.contains(WEB_SEARCH_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(search_prompt) + } else { + Some(format!("{base}\n\n{search_prompt}")) + } + } + (Some(base), None) => Some(base), + (None, Some(search_prompt)) => Some(search_prompt), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_should_build_google_prompt() { + let config = Config::default(); + let prompt = build_web_search_prompt(&config).unwrap_or_default(); + assert!(prompt.contains("Google")); + assert!(prompt.contains("通用网页检索")); + } + + #[test] + fn xiaohongshu_should_build_site_preference_prompt() { + let mut config = Config::default(); + config.web_search.engine = SearchEngine::Xiaohongshu; + + let prompt = build_web_search_prompt(&config).unwrap_or_default(); + assert!(prompt.contains("小红书")); + assert!(prompt.contains("site:xiaohongshu.com")); + } + + #[test] + fn should_not_duplicate_marker() { + let config = Config::default(); + let base = Some("前置内容\n\n【网络搜索偏好】\n已有内容".to_string()); + let merged = merge_system_prompt_with_web_search(base.clone(), &config); + assert_eq!(merged, base); + } +} diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs new file mode 100644 index 000000000..30bac18b4 --- /dev/null +++ b/src-tauri/src/theme/mod.rs @@ -0,0 +1,6 @@ +//! 主题模块聚合 +//! +//! 按主题组织前后端能力,支持渐进式迁移。 + +pub mod novel; +pub mod video; diff --git a/src-tauri/src/theme/novel/command.rs b/src-tauri/src/theme/novel/command.rs new file mode 100644 index 000000000..82e303166 --- /dev/null +++ b/src-tauri/src/theme/novel/command.rs @@ -0,0 +1,93 @@ +//! 小说主题命令适配层 +//! +//! 提供 novel tauri commands 的主题化实现入口。 + +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; + +fn service(db: &State<'_, DbConnection>) -> crate::services::novel_service::NovelService { + crate::services::novel_service::NovelService::new(db.inner().clone()) +} + +pub async fn novel_create_project( + db: State<'_, DbConnection>, + request: NovelCreateProjectRequest, +) -> Result { + service(&db).create_project(request) +} + +pub async fn novel_update_settings( + db: State<'_, DbConnection>, + request: NovelUpdateSettingsRequest, +) -> Result { + service(&db).update_settings(request) +} + +pub async fn novel_generate_outline( + db: State<'_, DbConnection>, + request: NovelGenerateRequest, +) -> Result { + service(&db).generate_outline(request).await +} + +pub async fn novel_generate_characters( + db: State<'_, DbConnection>, + request: NovelGenerateRequest, +) -> Result { + service(&db).generate_characters(request).await +} + +pub async fn novel_generate_chapter( + db: State<'_, DbConnection>, + request: NovelGenerateChapterRequest, +) -> Result { + service(&db).generate_chapter(request).await +} + +pub async fn novel_continue_chapter( + db: State<'_, DbConnection>, + request: NovelGenerateRequest, +) -> Result { + service(&db).continue_chapter(request).await +} + +pub async fn novel_rewrite_chapter( + db: State<'_, DbConnection>, + request: NovelRewriteChapterRequest, +) -> Result { + service(&db).rewrite_chapter(request).await +} + +pub async fn novel_polish_chapter( + db: State<'_, DbConnection>, + request: NovelPolishChapterRequest, +) -> Result { + service(&db).polish_chapter(request).await +} + +pub async fn novel_check_consistency( + db: State<'_, DbConnection>, + request: NovelCheckConsistencyRequest, +) -> Result { + service(&db).check_consistency(request) +} + +pub async fn novel_get_project_snapshot( + db: State<'_, DbConnection>, + project_id: String, +) -> Result { + service(&db).get_project_snapshot(&project_id) +} + +pub async fn novel_list_runs( + db: State<'_, DbConnection>, + request: NovelListRunsRequest, +) -> Result, String> { + service(&db).list_runs(request) +} diff --git a/src-tauri/src/theme/novel/mod.rs b/src-tauri/src/theme/novel/mod.rs new file mode 100644 index 000000000..9eb3016ea --- /dev/null +++ b/src-tauri/src/theme/novel/mod.rs @@ -0,0 +1,3 @@ +//! 小说主题模块 + +pub mod command; diff --git a/src-tauri/src/theme/video/command.rs b/src-tauri/src/theme/video/command.rs new file mode 100644 index 000000000..86a718df5 --- /dev/null +++ b/src-tauri/src/theme/video/command.rs @@ -0,0 +1,84 @@ +//! 视频主题命令适配层 + +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, +}; + +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, +} + +pub async fn create_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: CreateVideoGenerationRequest, +) -> Result { + VIDEO_GENERATION_SERVICE + .create_task(&db, &api_key_provider_service.0, request) + .await +} + +pub async fn get_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: GetVideoTaskRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE + .get_task( + &db, + &api_key_provider_service.0, + &request.task_id, + request.refresh_status.unwrap_or(true), + ) + .await +} + +pub fn list_video_generation_tasks( + db: State<'_, DbConnection>, + request: ListVideoTasksRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE.list_tasks( + &db, + &request.project_id, + request.limit.unwrap_or(50).clamp(1, 200), + ) +} + +pub async fn cancel_video_generation_task( + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + request: CancelVideoTaskRequest, +) -> Result, String> { + VIDEO_GENERATION_SERVICE + .cancel_task(&db, &api_key_provider_service.0, &request.task_id) + .await +} diff --git a/src-tauri/src/theme/video/mod.rs b/src-tauri/src/theme/video/mod.rs new file mode 100644 index 000000000..308e5fd23 --- /dev/null +++ b/src-tauri/src/theme/video/mod.rs @@ -0,0 +1,3 @@ +//! 视频主题模块 + +pub mod command; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d0ccbcce7..df19d1162 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.73.0", + "version": "0.74.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", @@ -59,7 +59,8 @@ "resources": [ "icons/tray/*", "../scripts/playwright-login/**/*", - "resources/models/**/*" + "resources/models/**/*", + "../extensions/proxycast-chrome/**/*" ], "macOS": { "entitlements": "entitlements.plist", diff --git a/src/App.tsx b/src/App.tsx index cf1cdc895..f5845a61b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -208,7 +208,7 @@ function AppContent() { projectParams?.workspaceTheme, ); const workspaceViewMode = projectParams?.projectId - ? "project-detail" + ? "workspace" : "project-management"; if (typeof window !== "undefined") { diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index bc3035afe..9997cfaad 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -251,6 +251,7 @@ export function AgentChatPage({ fromResources = false, hideHistoryToggle = false, showChatPanel = true, + hideTopBar = false, onBackToProjectManagement, hideInlineStepProgress = false, onWorkflowProgressChange, @@ -269,6 +270,7 @@ export function AgentChatPage({ fromResources?: boolean; hideHistoryToggle?: boolean; showChatPanel?: boolean; + hideTopBar?: boolean; onBackToProjectManagement?: () => void; hideInlineStepProgress?: boolean; onWorkflowProgressChange?: ( @@ -2082,76 +2084,80 @@ export function AgentChatPage({ )} - {}} - onBackToProjectManagement={onBackToProjectManagement} - onBackToResources={fromResources ? handleBackToResources : undefined} - projectId={projectId ?? null} - onProjectChange={(newProjectId) => { - if (externalProjectId) { - return; - } - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; - savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); - setInternalProjectId(newProjectId); - }} - workspaceType={activeTheme} - onBackHome={handleBackHome} - onToggleSettings={() => { - _onNavigate?.("settings", { - tab: SettingsTabs.ChatAppearance, - }); - }} - novelCanvasControls={ - showNovelNavbarControls - ? { - chapterListCollapsed: novelChapterListCollapsed, - onToggleChapterList: handleToggleNovelChapterList, - onAddChapter: handleAddNovelChapter, - onCloseCanvas: handleCloseCanvas, + {!hideTopBar && ( + <> + {}} + onBackToProjectManagement={onBackToProjectManagement} + onBackToResources={fromResources ? handleBackToResources : undefined} + projectId={projectId ?? null} + onProjectChange={(newProjectId) => { + if (externalProjectId) { + return; } - : null - } - /> + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); + setInternalProjectId(newProjectId); + }} + workspaceType={activeTheme} + onBackHome={handleBackHome} + onToggleSettings={() => { + _onNavigate?.("settings", { + tab: SettingsTabs.ChatAppearance, + }); + }} + novelCanvasControls={ + showNovelNavbarControls + ? { + chapterListCollapsed: novelChapterListCollapsed, + onToggleChapterList: handleToggleNovelChapterList, + onAddChapter: handleAddNovelChapter, + onCloseCanvas: handleCloseCanvas, + } + : null + } + /> - + - {/* 同步状态指示器 */} - {contentId && syncStatus !== "idle" && ( -
- {syncStatus === "syncing" && "正在同步..."} - {syncStatus === "success" && "✓ 已同步"} - {syncStatus === "error" && "⚠ 同步失败,将自动重试"} -
+ {/* 同步状态指示器 */} + {contentId && syncStatus !== "idle" && ( +
+ {syncStatus === "syncing" && "正在同步..."} + {syncStatus === "success" && "✓ 已同步"} + {syncStatus === "error" && "⚠ 同步失败,将自动重试"} +
+ )} + )} {/* 使用布局过渡组件 */} diff --git a/src/components/projects/ProjectDetailPage.tsx b/src/components/projects/ProjectDetailPage.tsx index 6afed9c39..8b3687bbe 100644 --- a/src/components/projects/ProjectDetailPage.tsx +++ b/src/components/projects/ProjectDetailPage.tsx @@ -5,7 +5,7 @@ * @requirements 5.1, 5.2 */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useProject } from "@/hooks/useProject"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -16,6 +16,7 @@ import { MaterialTab, TemplateTab, PublishTab, + NovelPublishTab, SettingsTab, } from "./tabs"; @@ -48,6 +49,13 @@ export function ProjectDetailPage({ }: ProjectDetailPageProps) { const { project, loading, error } = useProject(projectId); const [activeTab, setActiveTab] = useState("content"); + const isNovelProject = project?.workspaceType === "novel"; + + useEffect(() => { + if (isNovelProject && activeTab === "persona") { + setActiveTab("content"); + } + }, [activeTab, isNovelProject]); if (loading) { return ( @@ -69,7 +77,7 @@ export function ProjectDetailPage({ } return ( -
+
{/* 头部 */}
- {/* 话题列表 */} {topics.length === 0 ? (
@@ -68,9 +76,7 @@ export function ContentTab({
{topic.title}
{topic.messageCount} 条消息 - - {new Date(topic.updatedAt).toLocaleDateString("zh-CN")} - + {new Date(topic.updatedAt).toLocaleDateString("zh-CN")}
))} diff --git a/src/components/projects/tabs/NovelPublishTab.tsx b/src/components/projects/tabs/NovelPublishTab.tsx new file mode 100644 index 000000000..19e0ea36e --- /dev/null +++ b/src/components/projects/tabs/NovelPublishTab.tsx @@ -0,0 +1,506 @@ +/** + * @file NovelPublishTab.tsx + * @description 小说项目发布 Tab,展示章节选择、平台配置与发布前检查 + * @module components/projects/tabs/NovelPublishTab + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; +import { + getNovelProjectSnapshot, + type NovelChapterRecord, + type NovelProjectSnapshot, +} from "@/lib/api/novel"; +import { + AlertTriangle, + CheckCircle2, + Loader2, + RefreshCw, + SendIcon, + XCircle, +} from "lucide-react"; + +export interface NovelPublishTabProps { + /** 项目 ID */ + projectId: string; +} + +type CheckLevel = "pass" | "warn" | "fail"; + +interface PreflightCheckItem { + key: string; + label: string; + level: CheckLevel; + detail: string; +} + +interface PublishPlatformOption { + id: string; + name: string; + description: string; + icon: string; +} + +const DEFAULT_SELECTED_PLATFORMS = ["fanqie", "qidian"]; + +const PUBLISH_PLATFORM_OPTIONS: PublishPlatformOption[] = [ + { + id: "fanqie", + name: "番茄小说", + description: "番茄作家专区连载发布", + icon: "🍅", + }, + { + id: "qidian", + name: "起点小说", + description: "起点作家专区连载发布", + icon: "📚", + }, + { + id: "qimao", + name: "七猫小说", + description: "七猫作家中心连载发布", + icon: "🐱", + }, + { + id: "jjwxc", + name: "晋江文学城", + description: "晋江作者后台连载发布", + icon: "🌸", + }, + { + id: "faloo", + name: "飞卢小说网", + description: "飞卢作家后台连载发布", + icon: "⚡", + }, + { + id: "zongheng", + name: "纵横中文网", + description: "纵横作家专区连载发布", + icon: "🧭", + }, + { + id: "17k", + name: "17K小说网", + description: "17K作者后台连载发布", + icon: "🔥", + }, +]; + +const MIN_CHAPTER_WORDS = 1000; + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return "未知错误"; +} + +function formatDateTime(timestamp: number): string { + return new Date(timestamp).toLocaleString("zh-CN", { hour12: false }); +} + +function formatChapterStatus(status: string): string { + if (status === "draft") { + return "草稿"; + } + if (status === "published") { + return "已发布"; + } + return status; +} + +function getChapterStatusVariant( + status: string, +): "default" | "secondary" | "destructive" | "outline" { + if (status === "published") { + return "default"; + } + if (status === "draft") { + return "secondary"; + } + return "outline"; +} + +function getCheckVariant( + level: CheckLevel, +): "default" | "secondary" | "destructive" | "outline" { + if (level === "pass") { + return "default"; + } + if (level === "warn") { + return "secondary"; + } + return "destructive"; +} + +export function NovelPublishTab({ projectId }: NovelPublishTabProps) { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [selectedChapterIds, setSelectedChapterIds] = useState([]); + const [selectedPlatforms, setSelectedPlatforms] = useState( + DEFAULT_SELECTED_PLATFORMS, + ); + + const loadSnapshot = useCallback(async () => { + setLoading(true); + try { + const result = await getNovelProjectSnapshot(projectId); + setSnapshot(result); + setLoadError(null); + } catch (error) { + setSnapshot(null); + setLoadError(getErrorMessage(error)); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { + void loadSnapshot(); + }, [loadSnapshot]); + + useEffect(() => { + if (!snapshot) { + setSelectedChapterIds([]); + return; + } + + const validIds = new Set(snapshot.chapters.map((chapter) => chapter.id)); + setSelectedChapterIds((prev) => { + const next = prev.filter((id) => validIds.has(id)); + if (next.length > 0) { + return next; + } + const latestChapter = snapshot.chapters[snapshot.chapters.length - 1]; + return latestChapter ? [latestChapter.id] : []; + }); + }, [snapshot]); + + const selectedChapters = useMemo(() => { + if (!snapshot) { + return []; + } + const selectedSet = new Set(selectedChapterIds); + return snapshot.chapters.filter((chapter) => selectedSet.has(chapter.id)); + }, [selectedChapterIds, snapshot]); + + const progressValue = useMemo(() => { + if (!snapshot || snapshot.project.target_words <= 0) { + return 0; + } + return Math.min( + 100, + Math.round( + (snapshot.project.current_word_count / snapshot.project.target_words) * 100, + ), + ); + }, [snapshot]); + + const preflightChecks = useMemo(() => { + const checks: PreflightCheckItem[] = []; + checks.push({ + key: "chapter-selection", + label: "已选择待发布章节", + level: selectedChapters.length > 0 ? "pass" : "fail", + detail: + selectedChapters.length > 0 + ? `已选择 ${selectedChapters.length} 章` + : "请至少选择 1 个章节", + }); + checks.push({ + key: "platform-selection", + label: "已选择发布平台", + level: selectedPlatforms.length > 0 ? "pass" : "fail", + detail: + selectedPlatforms.length > 0 + ? `已选择 ${selectedPlatforms.length} 个平台` + : "请至少选择 1 个平台", + }); + + const shortChapters = selectedChapters.filter( + (chapter) => chapter.word_count < MIN_CHAPTER_WORDS, + ); + checks.push({ + key: "chapter-length", + label: "章节字数检查", + level: shortChapters.length === 0 ? "pass" : "warn", + detail: + shortChapters.length === 0 + ? "章节字数达到建议阈值" + : `有 ${shortChapters.length} 章低于 ${MIN_CHAPTER_WORDS} 字`, + }); + + const latestConsistency = snapshot?.latest_consistency; + if (!latestConsistency) { + checks.push({ + key: "consistency", + label: "一致性检查", + level: "warn", + detail: "尚未执行一致性检查,建议发布前先检查", + }); + return checks; + } + + const consistencyScore = latestConsistency.score; + const level: CheckLevel = + consistencyScore >= 80 ? "pass" : consistencyScore >= 60 ? "warn" : "fail"; + checks.push({ + key: "consistency", + label: "一致性检查", + level, + detail: `最新评分 ${consistencyScore.toFixed(1)}(${formatDateTime(latestConsistency.created_at)})`, + }); + return checks; + }, [selectedChapters, selectedPlatforms, snapshot]); + + const hasBlockingFailure = preflightChecks.some((item) => item.level === "fail"); + + const toggleChapter = useCallback((chapterId: string, checked: boolean) => { + setSelectedChapterIds((prev) => { + if (checked) { + return prev.includes(chapterId) ? prev : [...prev, chapterId]; + } + return prev.filter((id) => id !== chapterId); + }); + }, []); + + const togglePlatform = useCallback((platformId: string, checked: boolean) => { + setSelectedPlatforms((prev) => { + if (checked) { + return prev.includes(platformId) ? prev : [...prev, platformId]; + } + return prev.filter((id) => id !== platformId); + }); + }, []); + + return ( +
+
+
+

小说发布

+

+ 选择章节与平台,完成发布前检查 +

+
+ +
+ + {loading && !snapshot ? ( + + + + 正在加载发布数据... + + + ) : loadError ? ( + + + 加载发布数据失败 + {loadError} + + + ) : !snapshot ? ( + + + 暂无可发布数据 + + 请先在内容页完成小说初始化,并生成章节后再发布。 + + + + ) : ( + <> +
+ + + 可选章节 + {snapshot.chapters.length} + + + + + 已选章节 + {selectedChapters.length} + + + + + 全书进度 + + {snapshot.project.current_word_count.toLocaleString("zh-CN")} /{" "} + {snapshot.project.target_words.toLocaleString("zh-CN")} 字 + + + + +
+ 已完成 {progressValue}% +
+
+
+
+ +
+ + + 待发布章节 + 默认已选最新章节,可多选批量发布 + + + {snapshot.chapters.length === 0 ? ( +
暂无章节可发布
+ ) : ( +
+ {snapshot.chapters.map((chapter) => { + const checked = selectedChapterIds.includes(chapter.id); + return ( + + ); + })} +
+ )} +
+
+ + + + 发布平台 + 当前只提供发布编排,平台连接后续接入 + + + {PUBLISH_PLATFORM_OPTIONS.map((platform) => { + const checked = selectedPlatforms.includes(platform.id); + return ( + + ); + })} + + +
+ + + + 发布前检查 + 用于确认章节质量与发布条件 + + + {preflightChecks.map((item) => ( +
+
+
+ {item.level === "pass" ? ( + + ) : item.level === "warn" ? ( + + ) : ( + + )} + {item.label} +
+
+ {item.detail} +
+
+ + {item.level === "pass" + ? "通过" + : item.level === "warn" + ? "警告" + : "阻塞"} + +
+ ))} +
+
+ + + + 执行发布 + + 当前版本尚未接入小说发布后端接口,此处先展示发布编排状态。 + + + +
+ {hasBlockingFailure + ? "存在阻塞项,请先修复后再发布。" + : "发布条件已满足,等待发布接口接入。"} +
+ +
+
+ + )} +
+ ); +} + +export default NovelPublishTab; diff --git a/src/components/projects/tabs/NovelSettingsPanel.tsx b/src/components/projects/tabs/NovelSettingsPanel.tsx new file mode 100644 index 000000000..274bcec35 --- /dev/null +++ b/src/components/projects/tabs/NovelSettingsPanel.tsx @@ -0,0 +1,1036 @@ +import { useState, type ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { + ALL_NOVEL_GENRES, + createEmptyAntagonist, + createEmptyPlotBeat, + createEmptyReferenceWork, + createEmptySideCharacter, + createEmptyTaboo, + type Antagonist, + type NovelSettingsV1, + type PlotBeat, + type SideCharacter, +} from "@/lib/novel-settings/types"; +import { ChevronDown, Plus, Trash2 } from "lucide-react"; + +const NARRATION_OPTIONS = ["第一人称", "第三人称有限", "全知视角"] as const; +const TONE_OPTIONS = [ + "热血", + "黑暗", + "轻松", + "细腻", + "幽默", + "压抑", + "爽快", + "文艺", + "写实", +] as const; +const CHEAT_OPTIONS = ["无敌流", "稳步成长", "真实吃力", "反转流", "废柴逆袭"] as const; +const FOCUS_OPTIONS = ["战斗", "感情", "智斗", "日常", "装逼", "后宫", "权谋", "经营"] as const; +const ENDING_OPTIONS = ["HE", "BE", "开放", "大团圆", "虐", "爽", "开放式"] as const; +const RELATIONSHIP_OPTIONS = [ + "盟友", + "恋人", + "导师", + "死敌", + "家人", + "竞争者", + "炮灰", + "同门", + "队友", + "上司", + "下属", + "其他", +] as const; +const ARC_OPTIONS = ["成长", "黑化", "救赎", "牺牲", "退场", "保持不变", "其他"] as const; + +interface CharacterCore { + id: string; + name: string; + nickname: string; + gender: string; + age: string; + relationship: string; + relationshipCustom: string; + personalityTags: string[]; + background: string; + abilities: string; + role: string; + arc: string; + arcCustom: string; +} + +export interface NovelSettingsPanelProps { + value: NovelSettingsV1; + onChange: (next: NovelSettingsV1) => void; + disabled?: boolean; +} + +function updateById( + list: T[], + id: string, + updater: (item: T) => T, +): T[] { + return list.map((item) => (item.id === id ? updater(item) : item)); +} + +function removeById(list: T[], id: string): T[] { + return list.filter((item) => item.id !== id); +} + +function parseTags(value: string): string[] { + return value + .split(/[,,、\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function SectionPanel({ + title, + description, + defaultOpen = false, + children, +}: { + title: string; + description?: string; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + + {open ?
{children}
: null} +
+ ); +} + +function CharacterCoreEditor({ + value, + onChange, + disabled, +}: { + value: T; + onChange: (next: T) => void; + disabled?: boolean; +}) { + const patch = (partial: Partial) => onChange({ ...value, ...partial }); + + return ( +
+
+ patch({ name: event.target.value } as Partial)} + disabled={disabled} + /> + patch({ nickname: event.target.value } as Partial)} + disabled={disabled} + /> +
+ +
+ + patch({ age: event.target.value } as Partial)} + disabled={disabled} + /> + +
+ + {!value.relationship ? ( + + patch({ relationshipCustom: event.target.value } as Partial) + } + disabled={disabled} + /> + ) : null} + +
+ + patch({ personalityTags: parseTags(event.target.value) } as Partial) + } + disabled={disabled} + /> + +
+ + {!value.arc ? ( + patch({ arcCustom: event.target.value } as Partial)} + disabled={disabled} + /> + ) : null} + + patch({ background: event.target.value } as Partial)} + disabled={disabled} + /> + +