From 6f381998e97aa4179ed2b2a2d5c02056d3458be1 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Fri, 13 Mar 2026 12:46:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.8=20-=20Codex=E5=8F=8CAgent?= =?UTF-8?q?=E3=80=81=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E3=80=81=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E7=B3=BB=E7=BB=9F=E3=80=81=E4=BC=9A=E8=AF=9D=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex双Agent接入:共享后端内核,前台隔离会话/设置/导入 - 图片上传:Claude (stream-json) 和 Codex (--image) 均支持拖拽/粘贴/选择上传 - 主题系统:CoolVibe Light 视觉方案,主题入口移至二级页 - 会话加载优化:加载遮罩、热会话缓存、切后台内容不丢失 - 移动端增强:侧栏手势、运行状态标签、按钮比例修复 - 后端重构:agent-runtime.js / codex-rollouts.js 模块拆分 - 回归脚本:npm run regression 隔离式测试 --- .env.example | 3 + .gitignore | 2 + CHANGELOG.md | 36 + README.en.md | 31 +- README.md | 33 +- lib/agent-runtime.js | 390 +++++++ lib/codex-rollouts.js | 205 ++++ package-lock.json | 4 +- package.json | 5 +- public/app.js | 2364 +++++++++++++++++++++++++++++++++++----- public/index.html | 36 +- public/style.css | 1072 +++++++++++++++++- scripts/mock-claude.js | 52 + scripts/mock-codex.js | 62 ++ scripts/regression.js | 403 +++++++ server.js | 1260 +++++++++++++++++---- 16 files changed, 5450 insertions(+), 508 deletions(-) create mode 100644 lib/agent-runtime.js create mode 100644 lib/codex-rollouts.js create mode 100755 scripts/mock-claude.js create mode 100755 scripts/mock-codex.js create mode 100644 scripts/regression.js diff --git a/.env.example b/.env.example index b14d49b..4b3b50c 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,8 @@ PORT=8002 # Claude CLI 路径(默认在 PATH 中查找 claude) CLAUDE_PATH=claude +# Codex CLI 路径(默认在 PATH 中查找 codex) +CODEX_PATH=codex + # PushPlus Token(可选,首次启动会自动迁移到 config/notify.json) PUSHPLUS_TOKEN= diff --git a/.gitignore b/.gitignore index 3d0a95b..7cdbe04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ node_modules/ sessions/ logs/ +attachments/ .env config/notify.json config/auth.json config/model.json +config/codex.json CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f6ef8..ad6e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # 更新记录 +- **v1.2.8** + - **Codex 双 Agent 接入** + - 新建会话时可选择 Claude 或 Codex,复用 cc-web 现有的多会话、后台进程、断线续挂和文件 I/O 核心逻辑 + - 侧边栏按 Agent 隔离会话列表、最近会话记忆与默认新建行为 + - Agent 切换入口收进顶部栏标签下拉 + - Claude / Codex 各有独立设置入口,Codex 设置简化为 `local/custom + API Profile` + - Codex 会话通过 `codex exec --json` / `codex exec resume --json` 对接,stdin 文件喂入 prompt,持久化 thread id 用于续接 + - `/model`、`/cost`、`/help` 对 Codex 会话做了适配 + - **Codex 本地历史导入** + - 扫描 `~/.codex/sessions/` 下的 rollout `.jsonl`,解析用户消息、助手输出、函数调用与 token 使用量后导入到 cc-web + - 删除 Codex 会话时同步清理对应 rollout 文件与本地线程元数据 + - **图片上传** + - Claude 和 Codex 会话均支持图片消息:拖拽、粘贴或点击附件按钮上传 + - 客户端自动压缩(WebP),服务端附件缓存(7 天 TTL) + - Claude 通过 `--input-format stream-json` 传入 base64 图片,Codex 通过 `--image` 参数传入 + - 历史消息仅保留图片文字标签,不渲染原图 + - 单条消息最多 4 张图片 + - **会话加载体验优化** + - 回退并收敛长会话加载策略:最近消息先到,更老消息分批补充 + - 新增会话加载遮罩与加载期不可操作状态 + - 热会话缓存(最近 4 个,strong/weak 两级命中策略) + - 修复切后台再切回时运行中内容短暂消失的问题(`preserveStreaming` 机制) + - **主题系统** + - 引入完整的主题系统与 CoolVibe Light 视觉方案 + - 主题入口从设置页顶部改为「外观 → 界面主题」二级页 + - 加载遮罩适配 washi / coolvibe / editorial 三套主题变量 + - **移动端交互增强** + - 对话区任意位置右滑唤起侧栏、侧栏打开后左滑关闭 + - 运行中状态覆盖移动端 cwd 标签显示 + - 修复附件按钮、新会话分裂按钮的移动端比例失调 + - **其他改进** + - 后端重构:spawn spec 与事件解析抽离到 `lib/agent-runtime.js`,Codex rollout 解析在 `lib/codex-rollouts.js` + - 新增隔离式回归脚本 `npm run regression`,使用 mock CLI 在临时目录中校验主路径 + - 设置页说明卡移除,Codex Web Search 解释文案移除 + - 删除确认弹窗按 Agent 动态提示 Claude / Codex 的本地删除影响 + - **v1.2.7** - 新增导入本地 CLI 会话:扫描 `~/.claude/projects/` 下的 `.jsonl`,解析后导入到 cc-web,可续接历史对话。 - 新增新建会话指定工作目录:创建会话时弹窗设置 cwd,spawn 子进程时使用该目录,header 显示当前工作路径。 diff --git a/README.en.md b/README.en.md index 219a3c7..509072e 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ # CC-Web -A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI, so you can run and monitor workflows directly from a browser. +A lightweight browser interface for Claude Code and Codex, designed to keep each agent close to its native CLI workflow while sharing the same web shell. ![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white) ![License](https://img.shields.io/badge/License-MIT-blue) @@ -15,21 +15,27 @@ A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs ## Features -- **Lightweight runtime(1.6MB)**: low backend overhead, browser-based control panel. +- **Lightweight runtime**: low backend overhead, browser-based control panel. +- **Dual-agent sessions**: create Claude or Codex sessions on the same backend core. +- **Agent-isolated views**: switching Claude / Codex only shows that agent's sessions, recent state, settings, and import entry points. +- **Agent-specific settings**: Claude keeps template-based model config; Codex has its own path, default model, mode, and search settings. - **Multi-session management**: create, switch, rename, and delete sessions; deleting a session also removes the local Claude history record. +- **Local history import**: import Claude history from `~/.claude/projects/` and Codex rollout history from `~/.codex/sessions/`. - **Session resume**: context continuity via `--resume`; you can also reattach via SSH + `tmux attach -t claude` when needed. - **Background task support**: Claude processes continue after browser disconnect and notify you on completion. - **Multi-channel notifications**: PushPlus / Telegram / ServerChan / Feishu bot / QQ (Qmsg), configurable in Web UI. - **Process persistence**: detached subprocess + PID files; running tasks survive service restarts. - **Multi-API switching**: configure multiple API profiles and switch between them instantly from the UI. +- **Password-based auth**: initial password generation, forced first-login reset, and password change in Web UI. ## Requirements - **Node.js** >= 18 -- **Claude Code CLI** installed and configured (`claude` command available) +- **Claude Code CLI** and/or **Codex CLI** installed and configured ```bash npm install -g @anthropic-ai/claude-code +npm install -g @openai/codex ``` ## Quick Start @@ -66,6 +72,7 @@ After startup, open `http://localhost:8002` and sign in with your password. | `CC_WEB_PASSWORD` | No | Auto-generated | Web login password (migrated into `config/auth.json` on first start) | | `PORT` | No | `8002` | Service port | | `CLAUDE_PATH` | No | `claude` | Executable path to Claude CLI | +| `CODEX_PATH` | No | `codex` | Executable path to Codex CLI | | `PUSHPLUS_TOKEN` | No | - | PushPlus token (migrated into notification config on first start) | ### Notification Configuration @@ -107,6 +114,8 @@ cc-web/ │ └── auth.json # Auth config (generated at runtime) ├── sessions/ # Chat history JSON files (generated at runtime) ├── logs/ # Process lifecycle logs (generated at runtime) +├── lib/ # Agent runtime + Codex rollout parsing helpers +├── scripts/ # Regression tooling + mock CLIs ├── .env.example # Environment variable template ├── start.bat # Windows startup script ├── .gitignore @@ -119,10 +128,10 @@ cc-web/ ### Process Model ```text -Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude CLI (detached) +Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude / Codex CLI (detached) ``` -- Each user message spawns a `claude -p --output-format stream-json` subprocess. +- Each user message spawns either a Claude or Codex subprocess depending on the session agent. - Subprocesses use `detached: true` + `proc.unref()` and run independently from Node.js lifecycle. - stdin/stdout/stderr are bridged via files in `sessions/{id}-run/`. - PID is persisted to disk and recovered after service restart (`recoverProcesses()`). @@ -216,7 +225,7 @@ server { ### Windows Deployment -Use this mode when running CC-Web on a personal PC and controlling Claude Code from mobile. +Use this mode when running CC-Web on a personal PC and controlling Claude / Codex from mobile. Start with `start.bat`, or run manually: @@ -235,6 +244,14 @@ node server.js ## Release Notes +- **v1.2.8** + - **Dual-agent (Codex)**: create Claude or Codex sessions on the same backend; agent-isolated sidebar, settings, and import + - **Image upload**: drag, paste, or attach images in both Claude and Codex sessions; client-side WebP compression, 7-day server cache, up to 4 images per message + - **Session loading**: loading overlay, hot session cache (4 slots, strong/weak hit), fix for streaming content disappearing on tab switch + - **Theme system**: full theme engine with CoolVibe Light, washi, and editorial variants; theme picker moved to sub-page + - **Mobile UX**: swipe-to-open/close sidebar, running-state badge replaces cwd label, button sizing fixes + - **Backend refactor**: spawn spec + event parsing extracted to `lib/agent-runtime.js`; isolated regression script `npm run regression` + - **v1.2.2** - Aligned context compression with Claude Code native behavior: `/compact` is now actually sent to CLI instead of doing a local pseudo-reset. - Added automatic overflow recovery: when `Request too large (max 20MB)` occurs, CC-Web runs `/compact` and replays the failed prompt automatically. @@ -250,4 +267,4 @@ node server.js ## Notes -- This project currently targets Claude Code only. +- Claude support is still the more mature path, while Codex now supports isolated sessions, resume, import, background execution, and local cleanup. diff --git a/README.md b/README.md index dac5bb5..54927d6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CC-Web -Claude Code 轻量级 Web 远程工具 — 在浏览器中与 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 交互。 +Claude Code / Codex 轻量级 Web 远程工具 — 在浏览器中与本机 CLI Agent 交互。 ![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white) ![License](https://img.shields.io/badge/License-MIT-blue) @@ -24,20 +24,27 @@ https://github.com/ZgDaniel/cc-web 给我装! ## 功能特性 -- **超轻量(1.6MB)** — 后端性能占用少,前端通过 web 访问 +- **超轻量** — 后端性能占用少,前端通过 web 访问 +- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex,沿用相同的 Web 会话与后台任务模型 +- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰 +- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式 - **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录 - **会话续接** — 基于 `--resume` 实现跨消息上下文保持,也可通过 SSH 使用 `tmux attach -t claude` 命令加入会话 +- **本地历史导入** — Claude 可导入 `~/.claude/projects/` 会话;Codex 可导入 `~/.codex/sessions/` rollout 历史 - **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知 - **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQ(Qmsg),Web UI 内可视化配置 - **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务 - **多 API 切换** — 可配置多个 API 方案,UI 中一键切换,即时生效 +- **密码认证** — 自动生成初始密码、首次登录强制改密、Web UI 修改密码 +- **隔离式回归脚本** — `npm run regression` 在临时目录中使用 mock Claude / Codex CLI 校验主路径,不污染真实数据 ## 前提条件 - **Node.js** >= 18 -- **Claude Code CLI** 已安装并配置(`claude` 命令可用) +- **Claude Code CLI** 或 **Codex CLI** 已安装并配置 ```bash npm install -g @anthropic-ai/claude-code + npm install -g @openai/codex ``` ## 快速开始 @@ -75,6 +82,10 @@ copy .env.example .env & REM 可选 | `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json`) | | `PORT` | 否 | `8002` | 服务监听端口 | | `CLAUDE_PATH` | 否 | `claude` | Claude CLI 可执行文件路径 | +| `CODEX_PATH` | 否 | `codex` | Codex CLI 可执行文件路径 | +| `CC_WEB_CONFIG_DIR` | 否 | `./config` | 配置目录覆写(主要供隔离测试使用) | +| `CC_WEB_SESSIONS_DIR` | 否 | `./sessions` | 会话目录覆写(主要供隔离测试使用) | +| `CC_WEB_LOGS_DIR` | 否 | `./logs` | 日志目录覆写(主要供隔离测试使用) | | `PUSHPLUS_TOKEN` | 否 | - | PushPlus Token(首次启动自动迁移到通知配置) | ### 通知配置 @@ -106,16 +117,24 @@ copy .env.example .env & REM 可选 ``` cc-web/ ├── server.js # Node.js 后端(HTTP + WebSocket + 进程管理 + 通知) +├── lib/ +│ ├── agent-runtime.js # Claude / Codex 运行时适配层 +│ └── codex-rollouts.js # Codex rollout 历史解析 ├── public/ │ ├── index.html # 页面结构 │ ├── app.js # 前端逻辑(WebSocket 通信、UI 交互) │ ├── style.css # 样式(和风暖色调主题) │ └── sw.js # Service Worker(移动端推送通知) ├── config/ +│ ├── codex.json # Codex 独立配置(运行时生成) │ ├── notify.json # 通知渠道配置(运行时生成) │ └── auth.json # 密码配置(运行时生成) ├── sessions/ # 对话历史 JSON 文件(运行时生成) ├── logs/ # 进程生命周期日志(运行时生成) +├── scripts/ +│ ├── regression.js # 隔离式回归脚本 +│ ├── mock-claude.js # 回归用 mock Claude CLI +│ └── mock-codex.js # 回归用 mock Codex CLI ├── .env.example # 环境变量模板 ├── start.bat # Windows 一键启动脚本 ├── .gitignore @@ -128,14 +147,15 @@ cc-web/ ### 进程模型 ``` -浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude CLI (detached) +浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude / Codex CLI (detached) ``` -- 每条用户消息 spawn 一个 `claude -p --output-format stream-json` 子进程 +- 每条用户消息会根据当前会话 Agent,spawn Claude 或 Codex 子进程 - 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期 - stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe - PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()`) - 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端 +- Claude / Codex 的 spawn spec 与事件解析分别由 `lib/agent-runtime.js` 管理 ### 后台任务流程 @@ -247,4 +267,5 @@ node server.js ## 补充说明 -- 暂时只支持 Claude Code,后续再 vibe codex +- 当前已支持 Claude Code 与 Codex;Claude 侧能力更完整,Codex 侧以会话续接、后台执行和命令流展示为主 +- 每次大改动后建议先执行 `npm run regression` diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js new file mode 100644 index 0000000..ef2f1db --- /dev/null +++ b/lib/agent-runtime.js @@ -0,0 +1,390 @@ +function createAgentRuntime(deps) { + const { + processEnv, + CLAUDE_PATH, + CODEX_PATH, + MODEL_MAP, + loadModelConfig, + applyCustomTemplateToSettings, + loadCodexConfig, + prepareCodexCustomRuntime, + wsSend, + truncateObj, + sanitizeToolInput, + loadSession, + saveSession, + setRuntimeSessionId, + getRuntimeSessionId, + } = deps; + + function buildClaudeSpawnSpec(session, options = {}) { + const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0; + const args = ['-p', '--output-format', 'stream-json', '--verbose']; + if (hasAttachments) args.push('--input-format', 'stream-json'); + const permMode = session.permissionMode || 'yolo'; + switch (permMode) { + case 'yolo': + args.push('--dangerously-skip-permissions'); + break; + case 'plan': + args.push('--permission-mode', 'plan'); + break; + case 'default': + break; + } + if (session.claudeSessionId) { + args.push('--resume', session.claudeSessionId); + } + if (session.model) { + const validModels = new Set(Object.values(MODEL_MAP)); + if (validModels.has(session.model)) { + args.push('--model', session.model); + } + } + + const env = { ...processEnv }; + delete env.CLAUDECODE; + delete env.CLAUDE_CODE; + delete env.CC_WEB_PASSWORD; + for (const k of Object.keys(env)) { + if (k.startsWith('ANTHROPIC_')) delete env[k]; + } + + const modelCfg = loadModelConfig(); + if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) { + const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate); + if (tpl) applyCustomTemplateToSettings(tpl); + } + + return { + command: CLAUDE_PATH, + args, + env, + cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(), + parser: 'claude', + mode: permMode, + resume: !!session.claudeSessionId, + }; + } + + function buildCodexSpawnSpec(session, options = {}) { + const codexConfig = loadCodexConfig(); + const runtimeConfig = prepareCodexCustomRuntime(codexConfig); + if (runtimeConfig?.error) { + return { error: runtimeConfig.error }; + } + const runtimeId = getRuntimeSessionId(session); + const args = ['exec']; + if (runtimeId) args.push('resume'); + args.push('--json', '--skip-git-repo-check'); + + const permMode = session.permissionMode || 'yolo'; + switch (permMode) { + case 'yolo': + args.push('--dangerously-bypass-approvals-and-sandbox'); + break; + case 'plan': + args.push('-s', 'read-only'); + break; + case 'default': + default: + args.push('--full-auto'); + break; + } + + const effectiveModel = session.model; + if (effectiveModel) args.push('--model', effectiveModel); + if (Array.isArray(options.attachments)) { + for (const attachment of options.attachments) { + if (attachment?.path) args.push('--image', attachment.path); + } + } + if (runtimeId) { + args.push(runtimeId, '-'); + } else { + if (session.cwd) args.push('-C', session.cwd); + args.push('-'); + } + + const env = { ...processEnv }; + delete env.CC_WEB_PASSWORD; + delete env.CLAUDECODE; + delete env.CLAUDE_CODE; + if (runtimeConfig?.mode === 'custom') { + env.CODEX_HOME = runtimeConfig.homeDir; + env.OPENAI_API_KEY = runtimeConfig.apiKey; + delete env.OPENAI_BASE_URL; + } + + return { + command: CODEX_PATH, + args, + env, + cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(), + parser: 'codex', + mode: permMode, + resume: !!runtimeId, + }; + } + + function codexToolName(item) { + switch (item?.type) { + case 'command_execution': + return 'CommandExecution'; + case 'mcp_tool_call': + return 'McpToolCall'; + case 'file_change': + return 'FileChange'; + case 'reasoning': + return 'Reasoning'; + default: + return item?.type || 'CodexItem'; + } + } + + function codexToolInput(item) { + if (!item) return null; + if (item.type === 'command_execution') return { command: item.command || '' }; + return truncateObj(item, 500); + } + + function codexToolMeta(item) { + if (!item) return null; + switch (item.type) { + case 'command_execution': + return { + kind: 'command_execution', + title: 'Shell Command', + subtitle: item.command || '', + exitCode: typeof item.exit_code === 'number' ? item.exit_code : null, + status: item.status || null, + }; + case 'mcp_tool_call': + return { + kind: 'mcp_tool_call', + title: 'MCP Tool', + subtitle: item.tool_name || item.name || item.server_name || '', + status: item.status || null, + }; + case 'file_change': + return { + kind: 'file_change', + title: 'File Change', + subtitle: item.path || item.file_path || '', + status: item.status || null, + }; + case 'reasoning': + return { + kind: 'reasoning', + title: 'Reasoning', + subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '', + status: item.status || null, + }; + default: + return { + kind: item.type || 'codex_item', + title: codexToolName(item), + subtitle: '', + status: item.status || null, + }; + } + } + + function codexToolResult(item) { + if (!item) return ''; + if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output; + if (typeof item.text === 'string' && item.text) return item.text; + return JSON.stringify(truncateObj(item, 1200)); + } + + function ensureCodexToolCall(entry, item) { + let tc = entry.toolCalls.find((t) => t.id === item.id); + if (tc) { + tc.name = codexToolName(item); + tc.kind = item.type || tc.kind || null; + tc.meta = codexToolMeta(item) || tc.meta || null; + if (tc.input == null) tc.input = codexToolInput(item); + return tc; + } + tc = { + name: codexToolName(item), + id: item.id, + kind: item.type || null, + meta: codexToolMeta(item), + input: codexToolInput(item), + done: false, + }; + entry.toolCalls.push(tc); + wsSend(entry.ws, { + type: 'tool_start', + name: tc.name, + toolUseId: item.id, + input: tc.input, + kind: tc.kind, + meta: tc.meta, + }); + return tc; + } + + function processClaudeEvent(entry, event, sessionId) { + if (!event || !event.type) return; + + switch (event.type) { + case 'system': + if (event.session_id) { + const session = loadSession(sessionId); + if (session) { + session.claudeSessionId = event.session_id; + saveSession(session); + } + } + break; + + case 'assistant': { + const content = event.message?.content; + if (!Array.isArray(content)) break; + + for (const block of content) { + if (block.type === 'text' && block.text) { + entry.fullText += block.text; + wsSend(entry.ws, { type: 'text_delta', text: block.text }); + } else if (block.type === 'tool_use') { + const toolInput = sanitizeToolInput(block.name, block.input); + const tc = { name: block.name, id: block.id, input: toolInput, done: false }; + entry.toolCalls.push(tc); + wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input }); + } else if (block.type === 'tool_result') { + const resultText = typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? block.content.map((c) => c.text || '').join('\n') + : JSON.stringify(block.content); + const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id); + if (tc) { + tc.done = true; + tc.result = resultText.slice(0, 2000); + } + wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) }); + } + } + + if (event.session_id) { + const session = loadSession(sessionId); + if (session && !session.claudeSessionId) { + session.claudeSessionId = event.session_id; + saveSession(session); + } + } + break; + } + + case 'result': { + const session = loadSession(sessionId); + if (session) { + if (event.session_id) session.claudeSessionId = event.session_id; + if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd; + saveSession(session); + } + entry.lastCost = event.total_cost_usd || null; + if (entry.ws && event.total_cost_usd !== undefined) { + wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 }); + } + break; + } + } + } + + function processCodexEvent(entry, event, sessionId) { + if (!event || !event.type) return; + + switch (event.type) { + case 'thread.started': { + if (!event.thread_id) break; + const session = loadSession(sessionId); + if (session) { + setRuntimeSessionId(session, event.thread_id); + saveSession(session); + } + break; + } + + case 'item.started': { + const item = event.item; + if (!item || !item.id || item.type === 'agent_message') break; + ensureCodexToolCall(entry, item); + break; + } + + case 'item.completed': { + const item = event.item; + if (!item || !item.id) break; + if (item.type === 'agent_message') { + if (item.text) { + entry.fullText += item.text; + wsSend(entry.ws, { type: 'text_delta', text: item.text }); + } + break; + } + const tc = ensureCodexToolCall(entry, item); + const resultText = codexToolResult(item).slice(0, 2000); + tc.done = true; + tc.result = resultText; + wsSend(entry.ws, { + type: 'tool_end', + toolUseId: item.id, + result: resultText, + kind: tc.kind, + meta: tc.meta, + }); + break; + } + + case 'turn.completed': { + const usage = event.usage || null; + entry.lastUsage = usage; + const session = loadSession(sessionId); + if (session && usage) { + session.totalUsage = { + inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0), + cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0), + outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0), + }; + saveSession(session); + wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage }); + } + break; + } + + case 'turn.failed': { + const message = event.error?.message || 'Codex 任务失败'; + entry.lastError = message; + break; + } + + case 'error': + if (event.message) { + if (/^Reconnecting\.\.\./.test(event.message)) { + wsSend(entry.ws, { type: 'system_message', message: event.message }); + } else { + entry.lastError = event.message; + } + } + break; + } + } + + function processRuntimeEvent(entry, event, sessionId) { + if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId); + else processClaudeEvent(entry, event, sessionId); + } + + return { + buildClaudeSpawnSpec, + buildCodexSpawnSpec, + processClaudeEvent, + processCodexEvent, + processRuntimeEvent, + }; +} + +module.exports = { createAgentRuntime }; diff --git a/lib/codex-rollouts.js b/lib/codex-rollouts.js new file mode 100644 index 0000000..a280eae --- /dev/null +++ b/lib/codex-rollouts.js @@ -0,0 +1,205 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +function createCodexRolloutStore(deps) { + const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps; + + function extractCodexMessageText(content) { + if (!Array.isArray(content)) return ''; + return content + .filter((item) => item && (item.type === 'input_text' || item.type === 'output_text')) + .map((item) => item.text || '') + .join(''); + } + + function appendAssistantContent(turn, text) { + if (!turn || !text || !text.trim()) return; + turn.content = turn.content ? `${turn.content}\n\n${text}` : text; + } + + function parseCodexRolloutLines(lines) { + const messages = []; + const pendingToolCalls = new Map(); + const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null }; + const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }; + let currentAssistant = null; + let sawRealUserMessage = false; + const fallbackUserMessages = []; + + function ensureAssistant(ts) { + if (!currentAssistant) { + currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null }; + } else if (!currentAssistant.timestamp && ts) { + currentAssistant.timestamp = ts; + } + return currentAssistant; + } + + function flushAssistant() { + if (!currentAssistant) return; + if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) { + messages.push(currentAssistant); + } + currentAssistant = null; + pendingToolCalls.clear(); + } + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let entry; + try { entry = JSON.parse(trimmed); } catch { continue; } + const ts = entry.timestamp || null; + if (ts) meta.updatedAt = ts; + + if (entry.type === 'session_meta') { + meta.threadId = entry.payload?.id || meta.threadId; + meta.cwd = entry.payload?.cwd || meta.cwd; + meta.cliVersion = entry.payload?.cli_version || meta.cliVersion; + meta.source = entry.payload?.source || meta.source; + continue; + } + + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') { + const total = entry.payload?.info?.total_token_usage || null; + const usage = entry.payload?.info?.last_token_usage || null; + if (total) { + totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0); + totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0); + totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0); + } else if (usage) { + totalUsage.inputTokens += usage.input_tokens || 0; + totalUsage.cachedInputTokens += usage.cached_input_tokens || 0; + totalUsage.outputTokens += usage.output_tokens || 0; + } + continue; + } + + if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { + const text = String(entry.payload?.message || '').trim(); + if (text) { + sawRealUserMessage = true; + flushAssistant(); + if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' '); + messages.push({ role: 'user', content: text, timestamp: ts }); + } + continue; + } + + if (entry.type !== 'response_item') continue; + + const payload = entry.payload || {}; + switch (payload.type) { + case 'message': { + if (payload.role === 'assistant') { + const text = extractCodexMessageText(payload.content); + if (text.trim()) { + if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) { + flushAssistant(); + } + appendAssistantContent(ensureAssistant(ts), text); + } + } else if (payload.role === 'user' && !sawRealUserMessage) { + const text = extractCodexMessageText(payload.content); + if (text.trim()) { + fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts }); + } + } + break; + } + case 'function_call': { + const assistant = ensureAssistant(ts); + const toolUseId = payload.call_id || payload.id || crypto.randomUUID(); + const tc = { + name: payload.name || 'FunctionCall', + id: toolUseId, + input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''), + done: false, + }; + assistant.toolCalls.push(tc); + pendingToolCalls.set(toolUseId, tc); + break; + } + case 'function_call_output': { + const assistant = ensureAssistant(ts); + const toolUseId = payload.call_id || crypto.randomUUID(); + let tc = pendingToolCalls.get(toolUseId); + if (!tc) { + tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false }; + assistant.toolCalls.push(tc); + pendingToolCalls.set(toolUseId, tc); + } + tc.done = true; + tc.result = (typeof payload.output === 'string' + ? payload.output + : JSON.stringify(payload.output || '')).slice(0, 2000); + break; + } + default: + break; + } + } + + flushAssistant(); + if (!sawRealUserMessage && fallbackUserMessages.length > 0) { + const fallback = fallbackUserMessages[0]; + if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' '); + return { meta, messages: fallbackUserMessages.concat(messages), totalUsage }; + } + return { meta, messages, totalUsage }; + } + + function walkFiles(dir, files = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) walkFiles(fullPath, files); + else if (entry.isFile()) files.push(fullPath); + } + return files; + } + + function getCodexRolloutFiles() { + if (!fs.existsSync(codexSessionsDir)) return []; + return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse(); + } + + function getImportedCodexThreadIds() { + const imported = new Set(); + try { + for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) { + try { + const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8'))); + if (session.codexThreadId) imported.add(session.codexThreadId); + } catch {} + } + } catch {} + return imported; + } + + function parseCodexRolloutFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = parseCodexRolloutLines(content.split('\n')); + parsed.filePath = filePath; + return parsed; + } catch { + return null; + } + } + + return { + parseCodexRolloutLines, + getCodexRolloutFiles, + getImportedCodexThreadIds, + parseCodexRolloutFile, + }; +} + +module.exports = { createCodexRolloutStore }; diff --git a/package-lock.json b/package-lock.json index fa20d9b..2593773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-web", - "version": "1.0.0", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-web", - "version": "1.0.0", + "version": "1.2.8", "dependencies": { "ws": "^8.18.0" } diff --git a/package.json b/package.json index 54cab3e..276baf9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "cc-web", - "version": "1.1.0", + "version": "1.2.8", "private": true, "scripts": { - "start": "node server.js" + "start": "node server.js", + "regression": "node scripts/regression.js" }, "dependencies": { "ws": "^8.18.0" diff --git a/public/app.js b/public/app.js index 73286b0..d0465cb 100644 --- a/public/app.js +++ b/public/app.js @@ -20,23 +20,65 @@ yolo: 'YOLO', }; + const AGENT_LABELS = { + claude: 'Claude', + codex: 'Codex', + }; + + const DEFAULT_AGENT = 'claude'; + const SESSION_CACHE_LIMIT = 4; + const SESSION_CACHE_MAX_WEIGHT = 1_500_000; + const SIDEBAR_SWIPE_TRIGGER = 72; + const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42; + const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,适合复杂任务' }, { value: 'sonnet', label: 'Sonnet', desc: '平衡性能与速度' }, { value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' }, ]; + const DEFAULT_CODEX_MODEL_OPTIONS = [ + { value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' }, + { value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' }, + { value: 'o3', label: 'o3', desc: '偏强推理路径' }, + { value: 'o4-mini', label: 'o4-mini', desc: '轻量快速响应' }, + ]; + const MODE_PICKER_OPTIONS = [ { value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' }, { value: 'plan', label: 'Plan', desc: '执行前需确认计划' }, { value: 'default', label: '默认', desc: '标准权限审批' }, ]; + const THEME_OPTIONS = [ + { + value: 'washi', + label: 'Washi Warm', + desc: '暖纸色与朱砂点缀,保留当前熟悉的 CC-Web 气质。', + swatches: ['#faf6f0', '#f2ebe2', '#c0553a', '#5d8a54'], + }, + { + value: 'coolvibe', + label: 'CoolVibe Light', + desc: '保留 CoolVibe 的青色科技感,但改成更干净的浅色工作台。', + swatches: ['#f7fbfc', '#eef7f9', '#0891b2', '#ffffff'], + }, + { + value: 'editorial', + label: 'Editorial Sand', + desc: '更明亮的留白和更克制的棕色强调,像编辑台一样安静。', + swatches: ['#f6f1e8', '#efe8dc', '#8b5e3c', '#2f4b45'], + }, + ]; + // --- State --- let ws = null; let authToken = localStorage.getItem('cc-web-token'); let currentSessionId = null; let sessions = []; + let sessionCache = new Map(); let isGenerating = false; let reconnectAttempts = 0; let reconnectTimer = null; @@ -46,11 +88,21 @@ let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录) let hasGrouped = false; // 本次输出是否已触发过折叠 let cmdMenuIndex = -1; - let currentMode = localStorage.getItem('cc-web-mode') || 'yolo'; + let currentMode = 'yolo'; let currentModel = 'opus'; + let currentAgent = AGENT_LABELS[localStorage.getItem('cc-web-agent')] ? localStorage.getItem('cc-web-agent') : DEFAULT_AGENT; + let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi'); + let codexConfigCache = null; + let loadedHistorySessionId = null; + let activeSessionLoad = null; + let sidebarSwipe = null; + let pendingAttachments = []; + let uploadingAttachments = []; let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; + let currentSessionRunning = false; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; + let pendingInitialSessionLoad = false; // --- DOM --- const $ = (sel) => document.querySelector(sel); @@ -60,19 +112,30 @@ const loginError = $('#login-error'); const rememberPw = $('#remember-pw'); const app = $('#app'); + const sessionLoadingOverlay = $('#session-loading-overlay'); + const sessionLoadingLabel = $('#session-loading-label'); const sidebar = $('#sidebar'); const sidebarOverlay = $('#sidebar-overlay'); const menuBtn = $('#menu-btn'); + const chatMain = document.querySelector('.chat-main'); + const newChatSplit = sidebar.querySelector('.new-chat-split'); const newChatBtn = $('#new-chat-btn'); const newChatArrow = $('#new-chat-arrow'); const newChatDropdown = $('#new-chat-dropdown'); const importSessionBtn = $('#import-session-btn'); const sessionList = $('#session-list'); const chatTitle = $('#chat-title'); + const chatAgentBtn = $('#chat-agent-btn'); + const chatAgentMenu = $('#chat-agent-menu'); + const chatRuntimeState = $('#chat-runtime-state'); const chatCwd = $('#chat-cwd'); const costDisplay = $('#cost-display'); + const attachmentTray = $('#attachment-tray'); + const imageUploadInput = $('#image-upload-input'); + const attachBtn = $('#attach-btn'); const messagesDiv = $('#messages'); const msgInput = $('#msg-input'); + const inputWrapper = msgInput.closest('.input-wrapper'); const sendBtn = $('#send-btn'); const abortBtn = $('#abort-btn'); const cmdMenu = $('#cmd-menu'); @@ -86,6 +149,809 @@ window.addEventListener('resize', setVH); window.addEventListener('orientationchange', () => setTimeout(setVH, 100)); + function buildWelcomeMarkup(agent) { + const label = AGENT_LABELS[agent] || AGENT_LABELS.claude; + return `

欢迎使用 CC-Web

开始与 ${label} 对话

`; + } + + function normalizeAgent(agent) { + return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT; + } + + function normalizeTheme(theme) { + return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi'; + } + + function getThemeOption(theme) { + return THEME_OPTIONS.find((item) => item.value === normalizeTheme(theme)) || THEME_OPTIONS[0]; + } + + function refreshThemeSummaries() { + const label = getThemeOption(currentTheme).label; + document.querySelectorAll('[data-theme-summary]').forEach((node) => { + node.textContent = label; + }); + } + + function applyTheme(theme) { + currentTheme = normalizeTheme(theme); + document.documentElement.dataset.theme = currentTheme; + localStorage.setItem('cc-web-theme', currentTheme); + refreshThemeSummaries(); + } + + function buildThemePickerHtml(options = {}) { + const { showSectionTitle = true } = options; + return ` + ${showSectionTitle ? '
界面主题
' : ''} +
+ ${THEME_OPTIONS.map((theme) => ` + + `).join('')} +
+ `; + } + + function mountThemePicker(panel) { + panel.querySelectorAll('[data-theme-value]').forEach((button) => { + button.addEventListener('click', () => { + applyTheme(button.dataset.themeValue); + panel.querySelectorAll('[data-theme-value]').forEach((item) => { + item.classList.toggle('active', item.dataset.themeValue === currentTheme); + }); + }); + }); + } + + function buildThemeEntryHtml() { + return ` +
外观
+ + `; + } + + function openThemeSubpage() { + const overlay = document.createElement('div'); + overlay.className = 'settings-overlay settings-subpage-overlay'; + overlay.style.zIndex = '10001'; + + const panel = document.createElement('div'); + panel.className = 'settings-panel settings-subpage-panel'; + panel.innerHTML = ` +
+ +
+
Appearance
+

界面主题

+
+ +
+ ${buildThemePickerHtml({ showSectionTitle: false })} + `; + + overlay.appendChild(panel); + document.body.appendChild(overlay); + mountThemePicker(panel); + refreshThemeSummaries(); + + const closeSubpage = () => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + + panel.querySelector('.settings-back').addEventListener('click', closeSubpage); + panel.querySelector('.settings-close').addEventListener('click', closeSubpage); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeSubpage(); + }); + } + + function getAgentSessionStorageKey(agent) { + return `cc-web-session-${normalizeAgent(agent)}`; + } + + function getAgentModeStorageKey(agent) { + return `cc-web-mode-${normalizeAgent(agent)}`; + } + + function getLastSessionForAgent(agent) { + return localStorage.getItem(getAgentSessionStorageKey(agent)); + } + + function setLastSessionForAgent(agent, sessionId) { + localStorage.setItem(getAgentSessionStorageKey(agent), sessionId); + localStorage.setItem('cc-web-session', sessionId); + } + + function getSessionMeta(sessionId) { + return sessions.find((s) => s.id === sessionId) || null; + } + + function deepClone(value) { + if (value === null || value === undefined) return value; + return JSON.parse(JSON.stringify(value)); + } + + function cloneMessages(messages) { + return Array.isArray(messages) ? deepClone(messages) : []; + } + + function estimateSessionMessageWeight(message) { + const content = typeof message?.content === 'string' ? message.content.length : JSON.stringify(message?.content || '').length; + const toolCalls = Array.isArray(message?.toolCalls) ? JSON.stringify(message.toolCalls).length : 0; + return content + toolCalls + 64; + } + + function estimateSessionSnapshotWeight(snapshot) { + const base = JSON.stringify({ + title: snapshot.title || '', + mode: snapshot.mode || '', + model: snapshot.model || '', + agent: snapshot.agent || '', + cwd: snapshot.cwd || '', + updated: snapshot.updated || '', + }).length; + return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0); + } + + function normalizeSessionSnapshot(payload, options = {}) { + return { + sessionId: payload.sessionId, + messages: cloneMessages(payload.messages || []), + title: payload.title || '新会话', + mode: payload.mode || 'yolo', + model: payload.model || '', + agent: normalizeAgent(payload.agent), + hasUnread: !!payload.hasUnread, + cwd: payload.cwd || null, + totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, + totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null, + updated: payload.updated || null, + isRunning: !!payload.isRunning, + historyPending: !!payload.historyPending, + complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, + }; + } + + function touchSessionCache(sessionId) { + const entry = sessionCache.get(sessionId); + if (entry) entry.lastUsed = Date.now(); + } + + function invalidateSessionCache(sessionId) { + if (!sessionId) return; + sessionCache.delete(sessionId); + } + + function pruneSessionCache() { + let totalWeight = 0; + for (const entry of sessionCache.values()) totalWeight += entry.weight || 0; + while (sessionCache.size > SESSION_CACHE_LIMIT || totalWeight > SESSION_CACHE_MAX_WEIGHT) { + let oldestId = null; + let oldestTs = Infinity; + for (const [sessionId, entry] of sessionCache) { + if ((entry.lastUsed || 0) < oldestTs) { + oldestTs = entry.lastUsed || 0; + oldestId = sessionId; + } + } + if (!oldestId) break; + totalWeight -= sessionCache.get(oldestId)?.weight || 0; + sessionCache.delete(oldestId); + } + } + + function cacheSessionSnapshot(snapshot) { + if (!snapshot?.sessionId || !snapshot.complete) return; + const cachedSnapshot = deepClone(snapshot); + const weight = estimateSessionSnapshotWeight(cachedSnapshot); + if (weight > SESSION_CACHE_MAX_WEIGHT) { + invalidateSessionCache(cachedSnapshot.sessionId); + return; + } + const meta = getSessionMeta(cachedSnapshot.sessionId); + sessionCache.set(cachedSnapshot.sessionId, { + snapshot: cachedSnapshot, + version: cachedSnapshot.updated || null, + meta: meta ? deepClone(meta) : null, + weight, + lastUsed: Date.now(), + }); + pruneSessionCache(); + } + + function updateCachedSession(sessionId, updater) { + const entry = sessionCache.get(sessionId); + if (!entry) return; + const nextSnapshot = deepClone(entry.snapshot); + updater(nextSnapshot); + entry.snapshot = nextSnapshot; + entry.weight = estimateSessionSnapshotWeight(nextSnapshot); + entry.lastUsed = Date.now(); + if (nextSnapshot.updated) entry.version = nextSnapshot.updated; + pruneSessionCache(); + } + + function reconcileSessionCacheWithSessions() { + const knownIds = new Set(sessions.map((session) => session.id)); + for (const [sessionId, entry] of sessionCache) { + if (!knownIds.has(sessionId)) { + sessionCache.delete(sessionId); + continue; + } + const meta = getSessionMeta(sessionId); + entry.meta = meta ? deepClone(meta) : null; + } + } + + function getSessionCacheDisposition(sessionId) { + const entry = sessionCache.get(sessionId); + const meta = getSessionMeta(sessionId); + if (!entry?.snapshot?.complete || !meta) return 'miss'; + if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning) { + return 'strong'; + } + return 'weak'; + } + + function buildCachedSessionSnapshot(sessionId) { + const entry = sessionCache.get(sessionId); + if (!entry?.snapshot) return null; + const snapshot = deepClone(entry.snapshot); + const meta = getSessionMeta(sessionId) || entry.meta; + if (meta) { + snapshot.title = meta.title || snapshot.title; + snapshot.agent = normalizeAgent(meta.agent || snapshot.agent); + snapshot.hasUnread = !!meta.hasUnread; + snapshot.updated = meta.updated || snapshot.updated; + snapshot.isRunning = !!meta.isRunning; + } + return snapshot; + } + + function formatFileSize(bytes) { + const size = Number(bytes) || 0; + if (size < 1024) return `${size}B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`; + return `${(size / (1024 * 1024)).toFixed(1)}MB`; + } + + function syncAttachmentActions() { + const uploading = uploadingAttachments.length > 0; + if (attachBtn) attachBtn.disabled = uploading; + } + + function replaceFileExtension(filename, ext) { + const base = String(filename || 'image').replace(/\.[^/.]+$/, ''); + return `${base}${ext}`; + } + + function loadImageFromFile(file) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('读取图片失败')); + }; + img.src = url; + }); + } + + async function compressImageFile(file) { + if (!file || !/^image\/(png|jpeg|webp)$/i.test(file.type || '')) return file; + const img = await loadImageFromFile(file); + const maxDimension = 2000; + const maxOriginalBytes = 2 * 1024 * 1024; + const largestSide = Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height); + if (file.size <= maxOriginalBytes && largestSide <= maxDimension) { + return file; + } + + const scale = Math.min(1, maxDimension / largestSide); + const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale)); + const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return file; + ctx.drawImage(img, 0, 0, width, height); + + const targetType = 'image/webp'; + const qualities = [0.9, 0.84, 0.78, 0.72]; + let bestBlob = null; + for (const quality of qualities) { + const blob = await new Promise((resolve) => canvas.toBlob(resolve, targetType, quality)); + if (!blob) continue; + if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob; + if (blob.size <= Math.max(maxOriginalBytes, file.size * 0.72)) break; + } + if (!bestBlob || bestBlob.size >= file.size) return file; + return new File([bestBlob], replaceFileExtension(file.name || 'image', '.webp'), { + type: bestBlob.type, + lastModified: Date.now(), + }); + } + + async function deleteUploadedAttachment(id) { + if (!id) return; + try { + await ensureAuthenticatedWs(); + await fetch(`/api/attachments/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + } catch {} + } + + function ensureAuthenticatedWs() { + return new Promise((resolve, reject) => { + if (ws && ws.readyState === 1 && authToken) { + resolve(authToken); + return; + } + const savedPassword = localStorage.getItem('cc-web-pw'); + if (!savedPassword) { + reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。')); + return; + } + const timeout = setTimeout(() => { + reject(new Error('登录状态恢复超时,请刷新页面后重试。')); + }, 8000); + + const cleanup = () => { + clearTimeout(timeout); + document.removeEventListener('cc-web-auth-restored', onRestored); + document.removeEventListener('cc-web-auth-failed', onFailed); + }; + const onRestored = () => { + cleanup(); + resolve(authToken); + }; + const onFailed = () => { + cleanup(); + reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。')); + }; + document.addEventListener('cc-web-auth-restored', onRestored); + document.addEventListener('cc-web-auth-failed', onFailed); + + if (!ws || ws.readyState > 1) { + connect(); + } else if (ws.readyState === 1) { + send({ type: 'auth', password: savedPassword }); + } + }); + } + + function renderAttachmentLabels(attachments, options = {}) { + if (!Array.isArray(attachments) || attachments.length === 0) return ''; + const labels = attachments.map((attachment) => { + const stateSuffix = attachment.storageState === 'expired' ? '(已过期)' : ''; + const name = escapeHtml(attachment.filename || 'image'); + return `图片: ${name}${stateSuffix}`; + }).join(''); + return `
${labels}
`; + } + + function renderPendingAttachments() { + if (!attachmentTray) return; + if (!pendingAttachments.length && !uploadingAttachments.length) { + attachmentTray.hidden = true; + attachmentTray.innerHTML = ''; + syncAttachmentActions(); + return; + } + attachmentTray.hidden = false; + const uploadingHtml = uploadingAttachments.map((attachment) => ` +
+
+ ${escapeHtml(attachment.filename || 'image')} + 上传中 · ${formatFileSize(attachment.size)} +
+
+ `).join(''); + const readyHtml = pendingAttachments.map((attachment, index) => ` +
+
+ ${escapeHtml(attachment.filename || 'image')} + ${formatFileSize(attachment.size)} · 将随下一条消息发送 +
+ +
+ `).join(''); + const noteHtml = [ + uploadingAttachments.length > 0 + ? '
图片上传中,此时发送不会包含尚未完成的图片。
' + : '', + ].join(''); + attachmentTray.innerHTML = `${uploadingHtml}${readyHtml}${noteHtml}`; + attachmentTray.querySelectorAll('.attachment-chip-remove').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const index = Number(btn.dataset.index); + const [removed] = pendingAttachments.splice(index, 1); + renderPendingAttachments(); + deleteUploadedAttachment(removed?.id); + }); + }); + syncAttachmentActions(); + } + + async function uploadImageFile(file) { + await ensureAuthenticatedWs(); + const headers = { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': file.type || 'application/octet-stream', + 'X-Filename': encodeURIComponent(file.name || 'image'), + }; + const response = await fetch('/api/attachments', { + method: 'POST', + headers, + body: file, + }); + const rawText = await response.text(); + let data = null; + try { + data = rawText ? JSON.parse(rawText) : null; + } catch { + data = null; + } + if (response.status === 401) { + throw new Error('登录状态已失效,请刷新页面后重新登录再上传图片。'); + } + if (response.status === 413) { + throw new Error('图片大小超过当前上传限制,请压缩到 10MB 以内后重试。'); + } + if (!response.ok || !data?.ok) { + throw new Error(data?.message || `上传失败 (${response.status})`); + } + return data.attachment; + } + + async function handleSelectedImageFiles(fileList) { + const files = Array.from(fileList || []).filter((file) => file && /^image\//.test(file.type || '')); + if (!files.length) return; + if (pendingAttachments.length + files.length > 4) { + appendError('单条消息最多附带 4 张图片。'); + return; + } + const batch = files.map((file, index) => ({ + id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, + filename: file.name || 'image', + size: file.size || 0, + })); + uploadingAttachments.push(...batch); + renderPendingAttachments(); + try { + const results = await Promise.allSettled(files.map(async (file) => { + const optimized = await compressImageFile(file); + return uploadImageFile(optimized); + })); + const errors = []; + for (const result of results) { + if (result.status === 'fulfilled') { + pendingAttachments.push(result.value); + } else { + errors.push(result.reason?.message || '图片上传失败'); + } + } + if (errors.length > 0) { + appendError(errors[0]); + } + } catch (err) { + appendError(err.message || '图片上传失败'); + } finally { + uploadingAttachments = uploadingAttachments.filter((item) => !batch.some((entry) => entry.id === item.id)); + renderPendingAttachments(); + if (imageUploadInput) imageUploadInput.value = ''; + } + } + + function getVisibleSessions() { + return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent); + } + + function shouldOverlayRuntimeBadge() { + return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches; + } + + function updateCwdBadge() { + if (!chatCwd) return; + if (currentCwd) { + const parts = currentCwd.replace(/\/+$/, '').split('/'); + const short = parts.slice(-2).join('/') || currentCwd; + chatCwd.textContent = '~/' + short; + chatCwd.title = currentCwd; + } else { + chatCwd.textContent = ''; + chatCwd.title = ''; + } + chatCwd.hidden = !currentCwd || (currentSessionRunning && shouldOverlayRuntimeBadge()); + } + + function setCurrentSessionRunningState(isRunning) { + const running = !!isRunning; + currentSessionRunning = running; + if (chatRuntimeState) { + chatRuntimeState.hidden = !running; + chatRuntimeState.textContent = running ? '运行中' : ''; + } + updateCwdBadge(); + } + + function updateAgentScopedUI() { + if (chatAgentBtn) { + chatAgentBtn.textContent = AGENT_LABELS[currentAgent]; + chatAgentBtn.setAttribute('aria-expanded', chatAgentMenu && !chatAgentMenu.hidden ? 'true' : 'false'); + } + if (chatAgentMenu) { + chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => { + const active = btn.dataset.agent === currentAgent; + btn.classList.toggle('active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } + if (importSessionBtn) { + importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话'; + } + } + + function setCurrentAgent(agent) { + currentAgent = normalizeAgent(agent); + localStorage.setItem('cc-web-agent', currentAgent); + currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo'; + modeSelect.value = currentMode; + updateAgentScopedUI(); + } + + function closeAgentMenu() { + if (!chatAgentMenu) return; + chatAgentMenu.hidden = true; + if (chatAgentBtn) chatAgentBtn.setAttribute('aria-expanded', 'false'); + } + + function toggleAgentMenu() { + if (!chatAgentMenu || !chatAgentBtn) return; + const willOpen = chatAgentMenu.hidden; + chatAgentMenu.hidden = !willOpen; + chatAgentBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); + } + + function resetChatView(agent) { + setCurrentAgent(agent); + currentSessionId = null; + loadedHistorySessionId = null; + clearSessionLoading(); + setCurrentSessionRunningState(false); + currentCwd = null; + currentModel = currentAgent === 'claude' ? 'opus' : ''; + isGenerating = false; + pendingText = ''; + pendingAttachments = []; + uploadingAttachments = []; + activeToolCalls.clear(); + sendBtn.hidden = false; + abortBtn.hidden = true; + chatTitle.textContent = '新会话'; + updateCwdBadge(); + messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); + setStatsDisplay(null); + renderPendingAttachments(); + highlightActiveSession(); + } + + function applySessionSnapshot(snapshot, options = {}) { + if (!snapshot) return; + const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning); + if (isGenerating && !preserveStreaming) { + isGenerating = false; + sendBtn.hidden = false; + abortBtn.hidden = true; + pendingText = ''; + activeToolCalls.clear(); + } + currentSessionId = snapshot.sessionId; + loadedHistorySessionId = snapshot.sessionId; + setLastSessionForAgent(snapshot.agent, currentSessionId); + chatTitle.textContent = snapshot.title || '新会话'; + setCurrentAgent(snapshot.agent); + setCurrentSessionRunningState(snapshot.isRunning); + setStatsDisplay(snapshot); + currentCwd = snapshot.cwd || null; + updateCwdBadge(); + if (snapshot.mode && MODE_LABELS[snapshot.mode]) { + currentMode = snapshot.mode; + modeSelect.value = currentMode; + localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); + } + currentModel = snapshot.model || ''; + if (!preserveStreaming) { + renderMessages(snapshot.messages || [], { immediate: !!options.immediate }); + } + highlightActiveSession(); + renderSessionList(); + if (!options.skipCloseSidebar) closeSidebar(); + if (snapshot.hasUnread && !options.suppressUnreadToast) { + showToast('后台任务已完成', snapshot.sessionId); + } + } + + function syncViewForAgent(agent, options = {}) { + const targetAgent = normalizeAgent(agent); + const { preserveCurrent = true, loadLast = true } = options; + setCurrentAgent(targetAgent); + renderSessionList(); + + const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null; + if (preserveCurrent && currentMeta && normalizeAgent(currentMeta.agent) === targetAgent) { + highlightActiveSession(); + return; + } + + if (currentSessionId && (!currentMeta || normalizeAgent(currentMeta.agent) !== targetAgent)) { + send({ type: 'detach_view' }); + } + + resetChatView(targetAgent); + + if (!loadLast) return; + const lastSessionId = getLastSessionForAgent(targetAgent); + const lastMeta = lastSessionId ? getSessionMeta(lastSessionId) : null; + if (lastMeta && normalizeAgent(lastMeta.agent) === targetAgent) { + openSession(lastSessionId); + } + } + + function getSessionLoadLabel(sessionId) { + const meta = sessionId ? getSessionMeta(sessionId) : null; + const title = meta?.title ? `“${meta.title}”` : '所选会话'; + return `正在载入 ${title} 的完整消息记录…`; + } + + function setSessionLoading(sessionId, options = {}) { + const loading = !!sessionId; + const blocking = options.blocking !== false; + activeSessionLoad = loading ? { sessionId, blocking, snapshot: null } : null; + const showOverlay = !!(loading && blocking); + document.body.classList.toggle('session-loading-active', showOverlay); + sessionLoadingOverlay.hidden = !showOverlay; + sessionLoadingOverlay.setAttribute('aria-hidden', showOverlay ? 'false' : 'true'); + sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…'; + msgInput.disabled = showOverlay; + modeSelect.disabled = showOverlay; + sendBtn.disabled = showOverlay; + abortBtn.disabled = showOverlay; + if (showOverlay && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + + function clearSessionLoading(sessionId) { + if (sessionId && activeSessionLoad && activeSessionLoad.sessionId !== sessionId) return; + setSessionLoading(null, { blocking: false }); + } + + function isBlockingSessionLoad(sessionId) { + return !!(activeSessionLoad && + activeSessionLoad.blocking && + (!sessionId || activeSessionLoad.sessionId === sessionId)); + } + + function finishSessionSwitch(sessionId) { + if (isBlockingSessionLoad(sessionId)) { + scrollToBottom(); + requestAnimationFrame(() => clearSessionLoading(sessionId)); + return; + } + clearSessionLoading(sessionId); + } + + function finalizeLoadedSession(sessionId) { + if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) { + activeSessionLoad.snapshot.complete = true; + cacheSessionSnapshot(activeSessionLoad.snapshot); + } + finishSessionSwitch(sessionId); + } + + function beginSessionSwitch(sessionId, options = {}) { + if (!sessionId) return; + const blocking = options.blocking !== false; + const force = options.force === true; + if (!force && activeSessionLoad?.sessionId === sessionId) return; + if (!force && sessionId === currentSessionId && !activeSessionLoad) return; + renderEpoch++; + loadedHistorySessionId = null; + setSessionLoading(sessionId, { blocking, label: options.label }); + send({ type: 'load_session', sessionId }); + } + + function showCachedSession(sessionId) { + const snapshot = buildCachedSessionSnapshot(sessionId); + if (!snapshot) return false; + if (currentSessionId && currentSessionId !== sessionId) { + send({ type: 'detach_view' }); + } + clearSessionLoading(); + touchSessionCache(sessionId); + applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true }); + return true; + } + + function openSession(sessionId, options = {}) { + if (!sessionId) return; + if (options.forceSync) { + beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label }); + return; + } + if (!options.force && sessionId === currentSessionId && !activeSessionLoad) return; + + const disposition = getSessionCacheDisposition(sessionId); + if (disposition === 'strong') { + showCachedSession(sessionId); + return; + } + if (disposition === 'weak' && showCachedSession(sessionId)) { + beginSessionSwitch(sessionId, { blocking: false, force: true, label: options.label }); + return; + } + beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: options.force === true, label: options.label }); + } + + function setStatsDisplay(msg) { + if (currentAgent === 'codex' && msg && msg.totalUsage) { + const usage = msg.totalUsage; + if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) { + const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : ''; + costDisplay.textContent = `in ${usage.inputTokens} · out ${usage.outputTokens}${cacheText}`; + return; + } + } + if (msg && typeof msg.totalCost === 'number' && msg.totalCost > 0) { + costDisplay.textContent = `$${msg.totalCost.toFixed(4)}`; + return; + } + costDisplay.textContent = ''; + } + + function getCodexModelOptions() { + const seen = new Set(); + const options = []; + + function addOption(value, label, desc) { + const v = (value || '').trim(); + if (!v || seen.has(v)) return; + seen.add(v); + options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' }); + } + + DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addOption(opt.value, opt.label, opt.desc)); + addOption(currentModel, currentModel, '当前会话模型'); + sessions + .filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId) + .forEach((s) => addOption(s.model, s.model, '当前会话已保存模型')); + + return options; + } + // --- marked config --- const PREVIEW_LANGS = new Set(['html', 'svg']); const _previewCodeMap = new Map(); @@ -167,7 +1033,10 @@ handleServerMessage(msg); }; - ws.onclose = () => scheduleReconnect(); + ws.onclose = () => { + clearSessionLoading(); + scheduleReconnect(); + }; ws.onerror = () => {}; } @@ -192,21 +1061,20 @@ if (msg.success) { authToken = msg.token; localStorage.setItem('cc-web-token', msg.token); + document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; app.hidden = false; + send({ type: 'get_codex_config' }); // Check if must change password if (msg.mustChangePassword) { showForceChangePassword(); } else { - // Auto-load last viewed session - const lastSession = localStorage.getItem('cc-web-session'); - if (lastSession) { - send({ type: 'load_session', sessionId: lastSession }); - } + pendingInitialSessionLoad = true; } } else { authToken = null; localStorage.removeItem('cc-web-token'); + document.dispatchEvent(new CustomEvent('cc-web-auth-failed')); loginOverlay.hidden = false; app.hidden = true; loginError.hidden = false; @@ -215,56 +1083,62 @@ case 'session_list': sessions = msg.sessions || []; + reconcileSessionCacheWithSessions(); renderSessionList(); + if (currentSessionId) { + setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); + } + if (pendingInitialSessionLoad) { + pendingInitialSessionLoad = false; + syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true }); + } else if (currentSessionId && !getSessionMeta(currentSessionId)) { + resetChatView(currentAgent); + } break; case 'session_info': - // Reset generating state (will be re-set by resume_generating if process is active) - if (isGenerating) { - isGenerating = false; - sendBtn.hidden = false; - abortBtn.hidden = true; - pendingText = ''; - activeToolCalls.clear(); + const snapshot = normalizeSessionSnapshot(msg); + if (activeSessionLoad?.sessionId === msg.sessionId) { + activeSessionLoad.snapshot = snapshot; } - currentSessionId = msg.sessionId; - localStorage.setItem('cc-web-session', currentSessionId); - chatTitle.textContent = msg.title || '新会话'; - // 显示 cwd - currentCwd = msg.cwd || null; - if (currentCwd) { - const parts = currentCwd.replace(/\/+$/, '').split('/'); - const short = parts.slice(-2).join('/') || currentCwd; - chatCwd.textContent = '~/' + short; - chatCwd.title = currentCwd; - chatCwd.hidden = false; - } else { - chatCwd.hidden = true; - chatCwd.textContent = ''; + applySessionSnapshot(snapshot, { + immediate: isBlockingSessionLoad(msg.sessionId), + suppressUnreadToast: false, + preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning, + }); + if (!msg.historyPending) { + if (activeSessionLoad?.sessionId === msg.sessionId) { + finalizeLoadedSession(msg.sessionId); + } else { + cacheSessionSnapshot(snapshot); + finishSessionSwitch(msg.sessionId); + } } - // 同步 session 的 mode(如有) - if (msg.mode && MODE_LABELS[msg.mode]) { - currentMode = msg.mode; - modeSelect.value = currentMode; - localStorage.setItem('cc-web-mode', currentMode); - } - // 同步 session 的 model(如有) - if (msg.model) { - currentModel = msg.model; - } - renderMessages(msg.messages || []); - highlightActiveSession(); - closeSidebar(); - // Show notification for sessions completed in background - if (msg.hasUnread) { - showToast('后台任务已完成', msg.sessionId); + break; + + case 'session_history_chunk': + if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) { + const blocking = isBlockingSessionLoad(msg.sessionId); + if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) { + activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages); + } + prependHistoryMessages(msg.messages || [], { + preserveScroll: !blocking, + skipScrollbar: blocking, + }); + if (!msg.remaining) { + finalizeLoadedSession(msg.sessionId); + } } break; case 'session_renamed': + sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session); + updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; }); if (msg.sessionId === currentSessionId) { chatTitle.textContent = msg.title; } + renderSessionList(); break; case 'text_delta': @@ -275,19 +1149,35 @@ case 'tool_start': if (!isGenerating) startGenerating(); - activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, done: false }); - appendToolCall(msg.toolUseId, msg.name, msg.input, false); + activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false }); + appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null); break; case 'tool_end': if (activeToolCalls.has(msg.toolUseId)) { activeToolCalls.get(msg.toolUseId).done = true; + if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind; + if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta; + activeToolCalls.get(msg.toolUseId).result = msg.result; } updateToolCall(msg.toolUseId, msg.result); break; case 'cost': costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`; + if (currentSessionId) { + updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; }); + } + break; + + case 'usage': + if (msg.totalUsage) { + const cacheText = msg.totalUsage.cachedInputTokens ? ` · cache ${msg.totalUsage.cachedInputTokens}` : ''; + costDisplay.textContent = `in ${msg.totalUsage.inputTokens} · out ${msg.totalUsage.outputTokens}${cacheText}`; + if (currentSessionId) { + updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalUsage = deepClone(msg.totalUsage); }); + } + } break; case 'done': @@ -302,25 +1192,49 @@ if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; modeSelect.value = currentMode; - localStorage.setItem('cc-web-mode', currentMode); + localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); + if (currentSessionId) { + updateCachedSession(currentSessionId, (snapshot) => { snapshot.mode = msg.mode; }); + } } break; case 'model_changed': if (msg.model) { currentModel = msg.model; + if (currentSessionId) { + updateCachedSession(currentSessionId, (snapshot) => { snapshot.model = msg.model; }); + } } break; case 'resume_generating': // Server has an active process for this session — resume streaming - startGenerating(); + setCurrentSessionRunningState(true); + if (!isGenerating || !document.getElementById('streaming-msg')) { + startGenerating(); + } else { + sendBtn.hidden = true; + abortBtn.hidden = false; + toolGroupCount = 0; + hasGrouped = false; + activeToolCalls.clear(); + const toolsDiv = document.querySelector('#streaming-msg .msg-tools'); + if (toolsDiv) toolsDiv.innerHTML = ''; + } pendingText = msg.text || ''; - if (pendingText) flushRender(); + flushRender(); if (msg.toolCalls && msg.toolCalls.length > 0) { for (const tc of msg.toolCalls) { - activeToolCalls.set(tc.id, { name: tc.name, done: tc.done }); - appendToolCall(tc.id, tc.name, tc.input, tc.done); + activeToolCalls.set(tc.id, { + name: tc.name, + input: tc.input, + result: tc.result, + kind: tc.kind || null, + meta: tc.meta || null, + done: tc.done, + }); + appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null); if (tc.done && tc.result) { updateToolCall(tc.id, tc.result); } @@ -330,6 +1244,10 @@ case 'error': appendError(msg.message); + clearSessionLoading(); + if (!isGenerating && currentSessionId) { + setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); + } if (isGenerating) finishGenerating(); break; @@ -345,6 +1263,11 @@ if (typeof _onModelConfig === 'function') _onModelConfig(msg.config); break; + case 'codex_config': + codexConfigCache = msg.config || null; + if (typeof _onCodexConfig === 'function') _onCodexConfig(msg.config); + break; + case 'fetch_models_result': if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg); break; @@ -355,7 +1278,7 @@ showBrowserNotification(msg.title); if (msg.sessionId === currentSessionId) { // Reload current session to show completed response - send({ type: 'load_session', sessionId: msg.sessionId }); + openSession(msg.sessionId, { forceSync: true, blocking: false }); } else { send({ type: 'list_sessions' }); } @@ -369,6 +1292,10 @@ if (typeof _onNativeSessions === 'function') _onNativeSessions(msg.groups || []); break; + case 'codex_sessions': + if (typeof _onCodexSessions === 'function') _onCodexSessions(msg.sessions || []); + break; + case 'cwd_suggestions': if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg.paths || []); break; @@ -382,6 +1309,7 @@ // --- Generating State --- function startGenerating() { isGenerating = true; + setCurrentSessionRunningState(true); pendingText = ''; activeToolCalls.clear(); toolGroupCount = 0; @@ -413,6 +1341,7 @@ isGenerating = false; sendBtn.hidden = false; abortBtn.hidden = true; + setCurrentSessionRunningState(false); msgInput.focus(); if (pendingText) flushRender(); @@ -482,7 +1411,7 @@ catch { return escapeHtml(text); } } - function createMsgElement(role, content) { + function createMsgElement(role, content, attachments = []) { const div = document.createElement('div'); div.className = `msg ${role}`; @@ -496,16 +1425,27 @@ const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; - avatar.textContent = role === 'user' ? 'U' : 'C'; + avatar.textContent = role === 'user' ? 'U' : (currentAgent === 'codex' ? 'O' : 'C'); const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; if (role === 'user') { - bubble.style.whiteSpace = 'pre-wrap'; - bubble.textContent = content; + if (content) { + const textNode = document.createElement('div'); + textNode.className = 'msg-text'; + textNode.style.whiteSpace = 'pre-wrap'; + textNode.textContent = content; + bubble.appendChild(textNode); + } + if (attachments.length > 0) { + bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); + } } else { bubble.innerHTML = content ? renderMarkdown(content) : ''; + if (attachments.length > 0) { + bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); + } } div.appendChild(avatar); @@ -515,22 +1455,100 @@ let renderEpoch = 0; + function toolKind(tool) { + return tool?.kind || tool?.meta?.kind || ''; + } + + function toolTitle(tool) { + if (tool?.meta?.title) return tool.meta.title; + return tool?.name || 'Tool'; + } + + function toolSubtitle(tool) { + if (tool?.meta?.subtitle) return tool.meta.subtitle; + if (toolKind(tool) === 'command_execution') { + return tool?.input?.command || ''; + } + return ''; + } + + function stringifyToolValue(value) { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + function toolStateLabel(tool, done) { + if (!done) return 'Running'; + if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') { + return `Exit ${tool.meta.exitCode}`; + } + return 'Done'; + } + + function toolStateClass(tool, done) { + if (!done) return 'running'; + if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number' && tool.meta.exitCode !== 0) { + return 'error'; + } + return 'done'; + } + + function applyToolSummary(summary, tool, done) { + summary.innerHTML = ''; + const icon = document.createElement('span'); + icon.className = `tool-call-icon ${done ? 'done' : 'running'}`; + + const main = document.createElement('span'); + main.className = 'tool-call-summary-main'; + const label = document.createElement('span'); + label.className = 'tool-call-label'; + label.textContent = toolTitle(tool); + main.appendChild(label); + + const subtitleText = toolSubtitle(tool); + if (subtitleText) { + const subtitle = document.createElement('span'); + subtitle.className = 'tool-call-subtitle'; + subtitle.textContent = subtitleText; + main.appendChild(subtitle); + } + + const state = document.createElement('span'); + state.className = `tool-call-state ${toolStateClass(tool, done)}`; + state.textContent = toolStateLabel(tool, done); + + summary.appendChild(icon); + summary.appendChild(main); + summary.appendChild(state); + } + + function buildStructuredToolSection(labelText, bodyText) { + const section = document.createElement('div'); + section.className = 'tool-call-section'; + const label = document.createElement('div'); + label.className = 'tool-call-section-label'; + label.textContent = labelText; + const pre = document.createElement('pre'); + pre.className = 'tool-call-code'; + pre.textContent = bodyText; + section.appendChild(label); + section.appendChild(pre); + return section; + } + function buildMsgElement(m) { - const el = createMsgElement(m.role, m.content); + const el = createMsgElement(m.role, m.content, m.attachments || []); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); const FOLD_AT = 5; let grouped = false; for (const tc of m.toolCalls) { - const details = document.createElement('details'); - details.className = 'tool-call'; - details.dataset.toolName = tc.name || ''; - if (tc.name === 'AskUserQuestion') details.open = true; - const summary = document.createElement('summary'); - summary.innerHTML = ` ${escapeHtml(tc.name)}`; - details.appendChild(summary); - const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input); - details.appendChild(buildToolContentElement(tc.name, displayInput)); + const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call')); @@ -570,12 +1588,19 @@ return el; } - function renderMessages(messages) { + function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; messagesDiv.innerHTML = ''; if (messages.length === 0) { - messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; + messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); + return; + } + if (options.immediate) { + const frag = document.createDocumentFragment(); + messages.forEach((message) => frag.appendChild(buildMsgElement(message))); + messagesDiv.appendChild(frag); + scrollToBottom(); return; } // Batch render: last 10 first, then next 20, then the rest @@ -618,6 +1643,26 @@ } } + function prependHistoryMessages(messages, options = {}) { + if (!Array.isArray(messages) || messages.length === 0) return; + const preserveScroll = options.preserveScroll !== false; + const skipScrollbar = options.skipScrollbar === true; + const welcome = messagesDiv.querySelector('.welcome-msg'); + if (welcome) welcome.remove(); + const frag = document.createDocumentFragment(); + messages.forEach((m) => frag.appendChild(buildMsgElement(m))); + if (!preserveScroll) { + messagesDiv.insertBefore(frag, messagesDiv.firstChild); + if (!skipScrollbar) updateScrollbar(); + return; + } + const prevHeight = messagesDiv.scrollHeight; + const prevScrollTop = messagesDiv.scrollTop; + messagesDiv.insertBefore(frag, messagesDiv.firstChild); + messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); + if (!skipScrollbar) updateScrollbar(); + } + function normalizeAskUserInput(input) { if (input === null || input === undefined) return null; if (typeof input === 'string') { @@ -767,20 +1812,87 @@ } function buildToolContentElement(name, input) { - if (name === 'AskUserQuestion') { - const questions = extractAskUserQuestions(input); + const tool = typeof name === 'object' && name !== null ? name : { name, input }; + const effectiveName = tool.name || name; + const effectiveInput = tool.input !== undefined ? tool.input : input; + const effectiveResult = tool.result; + const kind = toolKind(tool); + if (effectiveName === 'AskUserQuestion') { + const questions = extractAskUserQuestions(effectiveInput); if (questions.length > 0) { return createAskUserQuestionView(questions); } } - const inputStr = typeof input === 'string' ? input : (input ? JSON.stringify(input, null, 2) : ''); + + if (kind === 'command_execution') { + const wrapper = document.createElement('div'); + wrapper.className = 'tool-call-content command'; + const stack = document.createElement('div'); + stack.className = 'tool-call-structured'; + const commandText = effectiveInput?.command || tool?.meta?.subtitle || ''; + if (commandText) stack.appendChild(buildStructuredToolSection('Command', commandText)); + if (effectiveResult) { + stack.appendChild(buildStructuredToolSection('Output', stringifyToolValue(effectiveResult))); + } else if (!tool.done) { + const empty = document.createElement('div'); + empty.className = 'tool-call-empty'; + empty.textContent = '等待命令输出…'; + stack.appendChild(empty); + } + wrapper.appendChild(stack); + return wrapper; + } + + if (kind === 'reasoning') { + const content = document.createElement('div'); + content.className = 'tool-call-content reasoning'; + const text = stringifyToolValue(effectiveResult || effectiveInput); + content.innerHTML = text ? renderMarkdown(text) : '
暂无推理内容
'; + return content; + } + + if (kind === 'file_change' || kind === 'mcp_tool_call') { + const wrapper = document.createElement('div'); + wrapper.className = `tool-call-content ${kind === 'file_change' ? 'file-change' : ''}`.trim(); + const stack = document.createElement('div'); + stack.className = 'tool-call-structured'; + if (tool?.meta?.subtitle) { + stack.appendChild(buildStructuredToolSection(kind === 'file_change' ? 'Target' : 'Tool', tool.meta.subtitle)); + } + const payloadText = stringifyToolValue(effectiveResult || effectiveInput); + if (payloadText) { + stack.appendChild(buildStructuredToolSection('Payload', payloadText)); + } + wrapper.appendChild(stack); + return wrapper; + } + + const inputStr = stringifyToolValue(effectiveResult || effectiveInput); const content = document.createElement('div'); content.className = 'tool-call-content'; content.textContent = inputStr; return content; } - function appendToolCall(toolUseId, name, input, done) { + function createToolCallElement(toolUseId, tool, done) { + const details = document.createElement('details'); + details.className = 'tool-call'; + details.id = `tool-${toolUseId}`; + details.dataset.toolName = tool.name || ''; + if (toolKind(tool)) { + details.dataset.toolKind = toolKind(tool); + details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`); + } + if (tool.name === 'AskUserQuestion' || (!done && toolKind(tool) === 'command_execution')) details.open = true; + + const summary = document.createElement('summary'); + applyToolSummary(summary, tool, done); + details.appendChild(summary); + details.appendChild(buildToolContentElement({ ...tool, done })); + return details; + } + + function appendToolCall(toolUseId, name, input, done, kind = null, meta = null) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); @@ -788,16 +1900,9 @@ let toolsDiv = bubble.querySelector('.msg-tools'); if (!toolsDiv) { toolsDiv = bubble; } - const details = document.createElement('details'); - details.className = 'tool-call'; - details.id = `tool-${toolUseId}`; - details.dataset.toolName = name || ''; - if (name === 'AskUserQuestion') details.open = true; + const tool = { id: toolUseId, name, input, kind, meta, done }; - const summary = document.createElement('summary'); - summary.innerHTML = ` ${escapeHtml(name)}`; - details.appendChild(summary); - details.appendChild(buildToolContentElement(name, input)); + const details = createToolCallElement(toolUseId, tool, done); // 折叠策略:只维护唯一一个 .tool-group 父节点 // 散落的 .tool-call 直接子节点达到5个时,将它们全部移入父节点;之后继续散落,再达5个再移入 @@ -836,18 +1941,31 @@ function updateToolCall(toolUseId, result) { const el = document.getElementById(`tool-${toolUseId}`); if (!el) return; - const icon = el.querySelector('.tool-call-icon'); - if (icon) { icon.classList.remove('running'); icon.classList.add('done'); } - if (result) { - if (el.dataset.toolName === 'AskUserQuestion') { - return; - } - const content = el.querySelector('.tool-call-content'); - if (content) content.textContent = result; - } + const tool = activeToolCalls.get(toolUseId) || { + id: toolUseId, + name: el.dataset.toolName || '', + kind: el.dataset.toolKind || null, + done: true, + }; + tool.done = true; + if (result !== undefined) tool.result = result; + const summary = el.querySelector('summary'); + if (summary) applyToolSummary(summary, tool, true); + if (tool.name === 'AskUserQuestion') return; + const nextContent = buildToolContentElement(tool); + const content = el.querySelector('.tool-call-content'); + if (content) content.replaceWith(nextContent); } - function showDeleteConfirm(onConfirm) { + function getDeleteConfirmMessage(agent) { + const normalized = normalizeAgent(agent); + if (normalized === 'codex') { + return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?'; + } + return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?'; + } + + function showDeleteConfirm(agent, onConfirm) { const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.style.zIndex = '10002'; @@ -855,7 +1973,7 @@ const box = document.createElement('div'); box.className = 'settings-panel'; box.innerHTML = ` -
删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?
+
${escapeHtml(getDeleteConfirmMessage(agent))}
@@ -972,12 +2090,24 @@ function renderSessionList() { sessionList.innerHTML = ''; - for (const s of sessions) { + const visibleSessions = getVisibleSessions(); + if (visibleSessions.length === 0) { + const empty = document.createElement('div'); + empty.className = 'session-list-empty'; + empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`; + sessionList.appendChild(empty); + return; + } + + for (const s of visibleSessions) { const item = document.createElement('div'); item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`; item.dataset.id = s.id; item.innerHTML = ` - ${escapeHtml(s.title || 'Untitled')} +
+ ${escapeHtml(s.title || 'Untitled')} + ${s.isRunning ? '运行中' : ''} +
${s.hasUnread ? '' : ''} ${timeAgo(s.updated)}
@@ -991,18 +2121,19 @@ if (target.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { + if (getLastSessionForAgent(currentAgent) === s.id) { + localStorage.removeItem(getAgentSessionStorageKey(currentAgent)); + } + invalidateSessionCache(s.id); send({ type: 'delete_session', sessionId: s.id }); if (s.id === currentSessionId) { - currentSessionId = null; - messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; - chatTitle.textContent = '新会话'; - costDisplay.textContent = ''; + resetChatView(currentAgent); } }; if (skipDeleteConfirm) { doDelete(); } else { - showDeleteConfirm(doDelete); + showDeleteConfirm(s.agent, doDelete); } return; } @@ -1011,7 +2142,7 @@ startEditSessionTitle(item, s); return; } - send({ type: 'load_session', sessionId: s.id }); + openSession(s.id); }); sessionList.appendChild(item); @@ -1072,6 +2203,10 @@ chatTitle.style.outline = '1px solid var(--accent)'; chatTitle.style.borderRadius = '6px'; chatTitle.style.padding = '2px 8px'; + chatTitle.style.minWidth = '96px'; + chatTitle.style.whiteSpace = 'normal'; + chatTitle.style.overflow = 'visible'; + chatTitle.style.textOverflow = 'clip'; chatTitle.focus(); // Select all text const range = document.createRange(); @@ -1086,6 +2221,10 @@ chatTitle.style.outline = ''; chatTitle.style.borderRadius = ''; chatTitle.style.padding = ''; + chatTitle.style.minWidth = ''; + chatTitle.style.whiteSpace = ''; + chatTitle.style.overflow = ''; + chatTitle.style.textOverflow = ''; const newTitle = chatTitle.textContent.trim() || originalText; chatTitle.textContent = newTitle; if (save && newTitle !== originalText && currentSessionId) { @@ -1110,6 +2249,84 @@ sidebarOverlay.hidden = true; } + function canOpenSidebarBySwipe(target) { + if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false; + if (sidebar.classList.contains('open')) return false; + if (sessionLoadingOverlay && !sessionLoadingOverlay.hidden) return false; + if (!chatMain || !target || !chatMain.contains(target)) return false; + if (!app.hidden && target && target.closest('input, textarea, select, button, .modal-panel, .settings-panel, .option-picker, .cmd-menu')) { + return false; + } + return true; + } + + function canCloseSidebarBySwipe(target) { + if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false; + if (!sidebar.classList.contains('open')) return false; + if (!target) return false; + return sidebar.contains(target) || target === sidebarOverlay; + } + + function handleSidebarSwipeStart(e) { + if (!e.touches || e.touches.length !== 1) return; + const touch = e.touches[0]; + if (canCloseSidebarBySwipe(e.target)) { + sidebarSwipe = { + startX: touch.clientX, + startY: touch.clientY, + active: true, + mode: 'close', + }; + return; + } + if (!canOpenSidebarBySwipe(e.target)) { + sidebarSwipe = null; + return; + } + sidebarSwipe = { + startX: touch.clientX, + startY: touch.clientY, + active: true, + mode: 'open', + }; + } + + function handleSidebarSwipeMove(e) { + if (!sidebarSwipe?.active || !e.touches || e.touches.length !== 1) return; + const touch = e.touches[0]; + const deltaX = touch.clientX - sidebarSwipe.startX; + const deltaY = touch.clientY - sidebarSwipe.startY; + if (Math.abs(deltaY) > SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT && Math.abs(deltaY) > Math.abs(deltaX)) { + sidebarSwipe = null; + return; + } + const horizontalIntent = sidebarSwipe.mode === 'open' ? deltaX > 12 : deltaX < -12; + if (horizontalIntent && Math.abs(deltaY) < SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT) { + e.preventDefault(); + } + } + + function handleSidebarSwipeEnd(e) { + if (!sidebarSwipe?.active) return; + const touch = e.changedTouches && e.changedTouches[0]; + const endX = touch ? touch.clientX : sidebarSwipe.startX; + const endY = touch ? touch.clientY : sidebarSwipe.startY; + const deltaX = endX - sidebarSwipe.startX; + const deltaY = endY - sidebarSwipe.startY; + const shouldOpen = sidebarSwipe.mode === 'open' && + deltaX >= SIDEBAR_SWIPE_TRIGGER && + Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT; + const shouldClose = sidebarSwipe.mode === 'close' && + deltaX <= -SIDEBAR_SWIPE_TRIGGER && + Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT; + sidebarSwipe = null; + if (shouldOpen) { + openSidebar(); + } else if (shouldClose) { + closeSidebar(); + } + } + // --- Slash Command Menu --- function showCmdMenu(filter) { const filtered = SLASH_COMMANDS.filter(c => @@ -1247,8 +2464,15 @@ } function showModelPicker() { + if (currentAgent === 'codex') { + const options = getCodexModelOptions(); + showOptionPicker('选择 Codex 模型', options, currentModel || '', (value) => { + send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); + }); + return; + } showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => { - send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode }); + send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); }); } @@ -1256,7 +2480,7 @@ showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => { currentMode = value; modeSelect.value = currentMode; - localStorage.setItem('cc-web-mode', currentMode); + localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } @@ -1266,12 +2490,16 @@ // --- Send Message --- function sendMessage() { const text = msgInput.value.trim(); - if (!text || isGenerating) return; + if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) return; hideCmdMenu(); hideOptionPicker(); // Slash commands: don't show as user bubble if (text.startsWith('/')) { + if (pendingAttachments.length > 0) { + appendError('命令消息暂不支持附带图片,请先移除图片或发送普通消息。'); + return; + } // /model without argument → show interactive picker if (text === '/model' || text === '/model ') { showModelPicker(); @@ -1286,7 +2514,7 @@ autoResize(); return; } - send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); msgInput.value = ''; autoResize(); return; @@ -1295,11 +2523,14 @@ // Regular message const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); - messagesDiv.appendChild(createMsgElement('user', text)); + const attachments = pendingAttachments.map((attachment) => ({ ...attachment })); + messagesDiv.appendChild(createMsgElement('user', text, attachments)); scrollToBottom(); - send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); + send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); msgInput.value = ''; + pendingAttachments = []; + renderPendingAttachments(); autoResize(); startGenerating(); } @@ -1337,6 +2568,26 @@ }); sidebarOverlay.addEventListener('click', closeSidebar); + document.addEventListener('touchstart', handleSidebarSwipeStart, { passive: true }); + document.addEventListener('touchmove', handleSidebarSwipeMove, { passive: false }); + document.addEventListener('touchend', handleSidebarSwipeEnd, { passive: true }); + document.addEventListener('touchcancel', () => { sidebarSwipe = null; }, { passive: true }); + + if (chatAgentBtn && chatAgentMenu) { + chatAgentBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleAgentMenu(); + }); + chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + closeAgentMenu(); + const targetAgent = normalizeAgent(btn.dataset.agent); + if (targetAgent === currentAgent) return; + syncViewForAgent(targetAgent, { preserveCurrent: false, loadLast: true }); + }); + }); + } // Split new-chat button newChatBtn.addEventListener('click', () => showNewSessionModal()); @@ -1346,7 +2597,11 @@ }); importSessionBtn.addEventListener('click', () => { newChatDropdown.hidden = true; - showImportSessionModal(); + if (currentAgent === 'codex') { + showImportCodexSessionModal(); + } else { + showImportSessionModal(); + } }); document.addEventListener('click', (e) => { if (!newChatDropdown.hidden && @@ -1354,15 +2609,41 @@ e.target !== newChatArrow) { newChatDropdown.hidden = true; } + if (chatAgentMenu && !chatAgentMenu.hidden && + !chatAgentMenu.contains(e.target) && + e.target !== chatAgentBtn) { + closeAgentMenu(); + } }); sendBtn.addEventListener('click', sendMessage); abortBtn.addEventListener('click', () => send({ type: 'abort' })); + if (attachBtn && imageUploadInput) { + attachBtn.addEventListener('click', () => imageUploadInput.click()); + imageUploadInput.addEventListener('change', () => { + handleSelectedImageFiles(imageUploadInput.files); + }); + } + if (inputWrapper) { + inputWrapper.addEventListener('dragover', (e) => { + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + inputWrapper.classList.add('drag-active'); + }); + inputWrapper.addEventListener('dragleave', (e) => { + if (e.target === inputWrapper) inputWrapper.classList.remove('drag-active'); + }); + inputWrapper.addEventListener('drop', (e) => { + e.preventDefault(); + inputWrapper.classList.remove('drag-active'); + handleSelectedImageFiles(e.dataTransfer?.files); + }); + } // Mode selector modeSelect.value = currentMode; modeSelect.addEventListener('change', () => { currentMode = modeSelect.value; - localStorage.setItem('cc-web-mode', currentMode); + localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } @@ -1406,6 +2687,18 @@ } }); + msgInput.addEventListener('paste', (e) => { + const items = Array.from(e.clipboardData?.items || []); + const files = items + .filter((item) => item.kind === 'file' && /^image\//.test(item.type || '')) + .map((item) => item.getAsFile()) + .filter(Boolean); + if (files.length > 0) { + e.preventDefault(); + handleSelectedImageFiles(files); + } + }); + // Close cmd menu on outside click document.addEventListener('click', (e) => { if (!cmdMenu.contains(e.target) && e.target !== msgInput) { @@ -1421,7 +2714,7 @@ if (sessionId) { toast.style.cursor = 'pointer'; toast.addEventListener('click', () => { - send({ type: 'load_session', sessionId }); + openSession(sessionId); toast.remove(); }); } @@ -1457,7 +2750,9 @@ let _onNotifyConfig = null; let _onNotifyTestResult = null; let _onModelConfig = null; + let _onCodexConfig = null; let _onFetchModelsResult = null; + let _onCodexSessions = null; const settingsBtn = $('#settings-btn'); @@ -1470,10 +2765,191 @@ { value: 'qqbot', label: 'QQ(Qmsg)' }, ]; - function showSettingsPanel() { - // Request current configs + function buildNotifyFieldsHtml(config, provider) { + if (provider === 'pushplus') { + return ` +
+ + +
+ `; + } + if (provider === 'telegram') { + return ` +
+ + +
+
+ + +
+ `; + } + if (provider === 'serverchan') { + return ` +
+ + +
+ `; + } + if (provider === 'feishu') { + return ` +
+ + +
+ `; + } + if (provider === 'qqbot') { + return ` +
+ + +
+ `; + } + return ''; + } + + function buildAgentContextCard(agent, title, copy) { + const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude; + return ` +
+
${escapeHtml(label)} Space
+
${escapeHtml(title)}
+
${escapeHtml(copy)}
+
+ `; + } + + function renderNotifyFields(fieldsDiv, config, provider) { + fieldsDiv.innerHTML = buildNotifyFieldsHtml(config, provider); + } + + function collectNotifyConfigFromPanel(panel, currentConfig, provider) { + const pp = panel.querySelector('#notify-pushplus-token'); + const tgBot = panel.querySelector('#notify-tg-bottoken'); + const tgChat = panel.querySelector('#notify-tg-chatid'); + const sc = panel.querySelector('#notify-sc-sendkey'); + const feishuWh = panel.querySelector('#notify-feishu-webhook'); + const qmsgKey = panel.querySelector('#notify-qmsg-key'); + return { + provider, + pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') }, + telegram: { + botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''), + chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || ''), + }, + serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') }, + feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') }, + qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') }, + }; + } + + function openPasswordModal() { + const pwOverlay = document.createElement('div'); + pwOverlay.className = 'settings-overlay'; + pwOverlay.style.zIndex = '10001'; + const pwModal = document.createElement('div'); + pwModal.className = 'settings-panel'; + pwModal.style.maxWidth = '400px'; + pwModal.innerHTML = ` +
+

修改密码

+ +
+
+ + +
+
+ + +
至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
+
+
+ + +
+
+ +
+
+ `; + pwOverlay.appendChild(pwModal); + document.body.appendChild(pwOverlay); + + const currentPwIn = pwModal.querySelector('#pw-modal-current'); + const newPwIn = pwModal.querySelector('#pw-modal-new'); + const confirmPwIn = pwModal.querySelector('#pw-modal-confirm'); + const hint = pwModal.querySelector('#pw-modal-hint'); + const submitBtn = pwModal.querySelector('#pw-modal-submit'); + const status = pwModal.querySelector('#pw-modal-status'); + + function checkPw() { + const newPw = newPwIn.value; + const confirmPw = confirmPwIn.value; + const currentPw = currentPwIn.value; + if (!newPw) { + hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; + hint.className = 'password-hint'; + submitBtn.disabled = true; + return; + } + const result = clientValidatePassword(newPw); + if (!result.valid) { + hint.textContent = result.message; + hint.className = 'password-hint error'; + submitBtn.disabled = true; + return; + } + hint.textContent = '密码强度符合要求'; + hint.className = 'password-hint success'; + submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw; + } + + currentPwIn.addEventListener('input', checkPw); + newPwIn.addEventListener('input', checkPw); + confirmPwIn.addEventListener('input', checkPw); + + const closePwModal = () => { document.body.removeChild(pwOverlay); }; + pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal); + pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); }); + + submitBtn.addEventListener('click', () => { + const currentPw = currentPwIn.value; + const newPw = newPwIn.value; + const confirmPw = confirmPwIn.value; + if (newPw !== confirmPw) { + status.textContent = '两次密码不一致'; + status.className = 'settings-status error'; + return; + } + submitBtn.disabled = true; + status.textContent = '正在修改...'; + status.className = 'settings-status'; + _onPasswordChanged = (result) => { + if (result.success) { + status.textContent = result.message || '密码修改成功'; + status.className = 'settings-status success'; + setTimeout(closePwModal, 1200); + } else { + status.textContent = result.message || '修改失败'; + status.className = 'settings-status error'; + submitBtn.disabled = false; + } + }; + send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw }); + }); + + currentPwIn.focus(); + } + + function showCodexSettingsPanel() { send({ type: 'get_notify_config' }); - send({ type: 'get_model_config' }); + send({ type: 'get_codex_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; @@ -1481,26 +2957,29 @@ const panel = document.createElement('div'); panel.className = 'settings-panel'; - panel.innerHTML = `

- ⚙ 设置 + ⚙ Codex 设置

-
模型配置
+
Codex 运行配置
- + +
-
-