From 2c1c64ff30380b86abd0fc5469c4df88ae4d0aeb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 8 Mar 2026 10:45:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20CC-Web=20v1.0=20=E2=80=94=20Claude=20Co?= =?UTF-8?q?de=20Web=20Chat=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能特性: - WebSocket 流式对话、工具调用折叠、Markdown 渲染 - 多会话管理与续接、模型/权限模式切换 - 后台任务持久化(detached 进程 + PID 恢复) - 多渠道通知(PushPlus/Telegram/Server酱/飞书/QQ) - 密码管理(自动生成初始密码、首次改密、Web UI 改密) - 移动端适配、PWA 通知、斜杠指令 Co-Authored-By: Claude Opus 4.6 --- .env.example | 11 + .gitignore | 6 + README.md | 228 ++++++++ package-lock.json | 36 ++ package.json | 11 + public/app.js | 1374 +++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 92 +++ public/style.css | 1039 ++++++++++++++++++++++++++++++++++ public/sw.js | 28 + server.js | 1214 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 4039 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 public/sw.js create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b14d49b --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Web 登录密码(可选,不设则首次启动自动生成随机密码) +# CC_WEB_PASSWORD=your-password-here + +# 服务监听端口(默认 8002) +PORT=8002 + +# Claude CLI 路径(默认在 PATH 中查找 claude) +CLAUDE_PATH=claude + +# PushPlus Token(可选,首次启动会自动迁移到 config/notify.json) +PUSHPLUS_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67aac87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +sessions/ +logs/ +.env +config/notify.json +config/auth.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..162b30b --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# CC-Web + +Claude Code 轻量级 Web 聊天界面 — 在浏览器中与 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 交互。 + +![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) + +## 功能特性 + +- **实时对话** — WebSocket 流式传输,逐字显示 Claude 回复 +- **工具调用折叠** — 自动折叠/展开 Claude 的工具调用过程,不干扰阅读 +- **Markdown 渲染** — 完整的 Markdown + 代码高亮(highlight.js) +- **多会话管理** — 创建、切换、重命名、删除会话,自动保存历史 +- **会话续接** — 基于 `--resume` 实现跨消息上下文保持 +- **模型切换** — Opus / Sonnet / Haiku 随时切换 +- **权限模式** — YOLO(全自动)/ Plan(确认后执行)/ Default(标准审批) +- **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知 +- **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQ(Qmsg),Web UI 内可视化配置 +- **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务 +- **移动端适配** — 响应式布局,支持 PWA 通知 +- **密码认证** — 自动生成初始密码、首次登录强制改密、Web UI 修改密码 +- **斜杠指令** — `/clear` `/model` `/mode` `/cost` `/compact` `/help` + +## 前提条件 + +- **Node.js** >= 18 +- **Claude Code CLI** 已安装并配置(`claude` 命令可用) + ```bash + npm install -g @anthropic-ai/claude-code + ``` + +## 快速开始 + +```bash +# 克隆项目 +git clone https://github.com/your-username/cc-web.git +cd cc-web + +# 安装依赖 +npm install + +# 创建配置文件(可选,不设密码则首次启动自动生成) +cp .env.example .env + +# 启动 +npm start +``` + +启动后访问 `http://localhost:8002`,输入密码即可使用。 + +## 配置 + +### 环境变量 (.env) + +| 变量 | 必填 | 默认值 | 说明 | +|------|:---:|--------|------| +| `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json`) | +| `PORT` | 否 | `8002` | 服务监听端口 | +| `CLAUDE_PATH` | 否 | `claude` | Claude CLI 可执行文件路径 | +| `PUSHPLUS_TOKEN` | 否 | - | PushPlus Token(首次启动自动迁移到通知配置) | + +### 通知配置 + +点击侧边栏底部的 **⚙ 设置按钮**,在 Web UI 中可视化配置推送通知: + +| 通知方式 | 所需配置 | 获取方式 | +|---------|---------|---------| +| **PushPlus**(微信推送) | Token | [pushplus.plus](https://www.pushplus.plus/) 注册获取 | +| **Telegram** | Bot Token + Chat ID | [@BotFather](https://t.me/BotFather) 创建机器人 | +| **Server酱** | SendKey | [sct.ftqq.com](https://sct.ftqq.com/) 注册获取 | +| **飞书机器人** | Webhook URL | 飞书群 → 设置 → 群机器人 → 添加自定义机器人 | +| **QQ(Qmsg)** | Qmsg Key | [qmsg.zendee.cn](https://qmsg.zendee.cn/) 登录后获取,需添加接收 QQ 号 | + +配置保存在 `config/notify.json`,Token 在 UI 中脱敏显示(仅显示前4后4位)。 + +### 密码管理 + +密码存储在 `config/auth.json`,支持自动生成与 Web UI 修改: + +- **首次启动**(无 `.env` 密码、无 `auth.json`):自动生成 12 位随机密码,打印到控制台,首次登录强制修改 +- **从 `.env` 迁移**:如已在 `.env` 设置 `CC_WEB_PASSWORD`,启动时自动迁移到 `auth.json`,无需改密 +- **Web UI 修改**:设置面板 → 修改密码(需输入当前密码) +- **密码要求**:≥ 8 位,包含大写/小写/数字/特殊字符中的至少 2 种 +- **改密后**:所有已登录会话失效,需重新认证 + +## 项目结构 + +``` +cc-web/ +├── server.js # Node.js 后端(HTTP + WebSocket + 进程管理 + 通知) +├── public/ +│ ├── index.html # 页面结构 +│ ├── app.js # 前端逻辑(WebSocket 通信、UI 交互) +│ ├── style.css # 样式(和风暖色调主题) +│ └── sw.js # Service Worker(移动端推送通知) +├── config/ +│ ├── notify.json # 通知渠道配置(运行时生成) +│ └── auth.json # 密码配置(运行时生成) +├── sessions/ # 对话历史 JSON 文件(运行时生成) +├── logs/ # 进程生命周期日志(运行时生成) +├── .env.example # 环境变量模板 +├── .gitignore +├── package.json +└── README.md +``` + +## 架构设计 + +### 进程模型 + +``` +浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude CLI (detached) +``` + +- 每条用户消息 spawn 一个 `claude -p --output-format stream-json` 子进程 +- 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期 +- stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe +- PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()`) +- 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端 + +### 后台任务流程 + +1. 用户发送消息 → spawn Claude 进程 +2. 用户关闭浏览器 → 进程继续运行(detached) +3. 进程完成 → PID 监控检测到退出 +4. 发送推送通知(PushPlus/Telegram/...) +5. 用户重新打开 → 自动同步完成的回复 + +### 进程日志 + +日志文件 `logs/process.log`(JSONL 格式,自动轮转 2MB),记录完整的进程生命周期: + +| 事件 | 说明 | +|------|------| +| `process_spawn` | 进程创建(PID、模式、模型) | +| `process_complete` | 进程完成(退出码、耗时、费用) | +| `ws_connect` / `ws_disconnect` | 客户端连接/断开 | +| `ws_resume_attach` | 客户端重连并挂载到运行中的进程 | +| `recovery_alive` / `recovery_dead` | 服务重启时恢复进程 | +| `heartbeat` | 每 60 秒活跃进程状态快照 | + +查看日志: +```bash +tail -f logs/process.log | jq . +``` + +## 生产部署 + +### systemd 服务 + +创建 `/etc/systemd/system/cc-web.service`: + +```ini +[Unit] +Description=CC-Web - Claude Code Web Chat UI +After=network.target + +[Service] +Type=simple +User=your-user +WorkingDirectory=/path/to/cc-web +ExecStart=/usr/bin/node server.js +Restart=on-failure +RestartSec=5 +# 重要:只杀 Node.js 进程,不杀 Claude 子进程 +KillMode=process + +[Install] +WantedBy=multi-user.target +``` + +> **`KillMode=process` 非常重要**:确保 systemd 重启服务时只杀 Node.js 进程,Claude 子进程继续运行,服务恢复后自动重新挂载。 + +```bash +sudo systemctl enable cc-web +sudo systemctl start cc-web +``` + +### Nginx 反向代理 + +```nginx +server { + listen 443 ssl; + server_name your-domain.com; + + ssl_certificate /path/to/fullchain.pem; + ssl_certificate_key /path/to/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8002; + proxy_http_version 1.1; + + # WebSocket 支持 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + # 长连接超时(Claude 任务可能运行较久) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +## 斜杠指令 + +在输入框中输入 `/` 可查看所有指令: + +| 指令 | 说明 | +|------|------| +| `/clear` | 清除当前会话(含 Claude 上下文) | +| `/model [名称]` | 查看/切换模型(opus, sonnet, haiku) | +| `/mode [模式]` | 查看/切换权限模式(yolo, plan, default) | +| `/cost` | 查看当前会话累计费用 | +| `/compact` | 压缩上下文(重置 Claude 会话但保留聊天记录) | +| `/help` | 显示帮助 | + +## 技术栈 + +- **后端**:Node.js + [ws](https://github.com/websockets/ws)(唯一依赖) +- **前端**:原生 HTML/CSS/JS,无构建步骤 +- **CDN**:[marked.js](https://marked.js.org/)(Markdown)+ [highlight.js](https://highlightjs.org/)(代码高亮) +- **CLI**:[Claude Code](https://docs.anthropic.com/en/docs/claude-code) + +## 许可证 + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fa20d9b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "cc-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cc-web", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a140e00 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "cc-web", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..5fc003c --- /dev/null +++ b/public/app.js @@ -0,0 +1,1374 @@ +// === CC-Web Frontend === +(function () { + 'use strict'; + + const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; + const RENDER_DEBOUNCE = 100; + + const SLASH_COMMANDS = [ + { cmd: '/clear', desc: '清除当前会话' }, + { cmd: '/model', desc: '查看/切换模型' }, + { cmd: '/mode', desc: '查看/切换权限模式' }, + { cmd: '/cost', desc: '查看会话费用' }, + { cmd: '/compact', desc: '压缩上下文' }, + { cmd: '/help', desc: '显示帮助' }, + ]; + + const MODE_LABELS = { + default: '默认', + plan: 'Plan', + yolo: 'YOLO', + }; + + const MODEL_OPTIONS = [ + { value: 'opus', label: 'Opus', desc: '最强大,适合复杂任务' }, + { value: 'sonnet', label: 'Sonnet', desc: '平衡性能与速度' }, + { value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' }, + ]; + + const MODE_PICKER_OPTIONS = [ + { value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' }, + { value: 'plan', label: 'Plan', desc: '执行前需确认计划' }, + { value: 'default', label: '默认', desc: '标准权限审批' }, + ]; + + // --- State --- + let ws = null; + let authToken = localStorage.getItem('cc-web-token'); + let currentSessionId = null; + let sessions = []; + let isGenerating = false; + let reconnectAttempts = 0; + let reconnectTimer = null; + let pendingText = ''; + let renderTimer = null; + let activeToolCalls = new Map(); + let cmdMenuIndex = -1; + let currentMode = localStorage.getItem('cc-web-mode') || 'yolo'; + let currentModel = 'opus'; + let loginPasswordValue = ''; // store login password for force-change flow + + // --- DOM --- + const $ = (sel) => document.querySelector(sel); + const loginOverlay = $('#login-overlay'); + const loginForm = $('#login-form'); + const loginPassword = $('#login-password'); + const loginError = $('#login-error'); + const rememberPw = $('#remember-pw'); + const app = $('#app'); + const sidebar = $('#sidebar'); + const sidebarOverlay = $('#sidebar-overlay'); + const menuBtn = $('#menu-btn'); + const newChatBtn = $('#new-chat-btn'); + const sessionList = $('#session-list'); + const chatTitle = $('#chat-title'); + const costDisplay = $('#cost-display'); + const messagesDiv = $('#messages'); + const msgInput = $('#msg-input'); + const sendBtn = $('#send-btn'); + const abortBtn = $('#abort-btn'); + const cmdMenu = $('#cmd-menu'); + const modeSelect = $('#mode-select'); + + // --- Viewport height fix for mobile browsers --- + function setVH() { + document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); + } + setVH(); + window.addEventListener('resize', setVH); + window.addEventListener('orientationchange', () => setTimeout(setVH, 100)); + + // --- marked config --- + const renderer = new marked.Renderer(); + renderer.code = function (code, language) { + const lang = language || 'plaintext'; + let highlighted; + try { + if (hljs.getLanguage(lang)) { + highlighted = hljs.highlight(code, { language: lang }).value; + } else { + highlighted = hljs.highlightAuto(code).value; + } + } catch { + highlighted = escapeHtml(code); + } + return `
+
+ ${escapeHtml(lang)} + +
+
${highlighted}
+
`; + }; + marked.setOptions({ renderer, breaks: true, gfm: true }); + + window.ccCopyCode = function (btn) { + const code = btn.closest('.code-block-wrapper').querySelector('code').textContent; + navigator.clipboard.writeText(code).then(() => { + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = 'Copy', 1500); + }); + }; + + // --- WebSocket --- + function connect() { + if (ws && ws.readyState <= 1) return; + ws = new WebSocket(WS_URL); + + ws.onopen = () => { + reconnectAttempts = 0; + if (authToken) send({ type: 'auth', token: authToken }); + }; + + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + handleServerMessage(msg); + }; + + ws.onclose = () => scheduleReconnect(); + ws.onerror = () => {}; + } + + function send(data) { + if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); + } + + function scheduleReconnect() { + if (reconnectTimer) return; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + reconnectAttempts++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); + } + + // --- Server Message Handler --- + function handleServerMessage(msg) { + switch (msg.type) { + case 'auth_result': + if (msg.success) { + authToken = msg.token; + localStorage.setItem('cc-web-token', msg.token); + loginOverlay.hidden = true; + app.hidden = false; + // 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 }); + } + } + } else { + authToken = null; + localStorage.removeItem('cc-web-token'); + loginOverlay.hidden = false; + app.hidden = true; + loginError.hidden = false; + } + break; + + case 'session_list': + sessions = msg.sessions || []; + renderSessionList(); + 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(); + } + currentSessionId = msg.sessionId; + localStorage.setItem('cc-web-session', currentSessionId); + chatTitle.textContent = msg.title || '新会话'; + // 同步 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_renamed': + if (msg.sessionId === currentSessionId) { + chatTitle.textContent = msg.title; + } + break; + + case 'text_delta': + if (!isGenerating) startGenerating(); + pendingText += msg.text; + scheduleRender(); + break; + + 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); + break; + + case 'tool_end': + if (activeToolCalls.has(msg.toolUseId)) { + activeToolCalls.get(msg.toolUseId).done = true; + } + updateToolCall(msg.toolUseId, msg.result); + break; + + case 'cost': + costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`; + break; + + case 'done': + finishGenerating(msg.sessionId); + break; + + case 'system_message': + appendSystemMessage(msg.message); + break; + + case 'mode_changed': + if (msg.mode && MODE_LABELS[msg.mode]) { + currentMode = msg.mode; + modeSelect.value = currentMode; + localStorage.setItem('cc-web-mode', currentMode); + } + break; + + case 'model_changed': + if (msg.model) { + currentModel = msg.model; + } + break; + + case 'resume_generating': + // Server has an active process for this session — resume streaming + startGenerating(); + pendingText = msg.text || ''; + if (pendingText) 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); + if (tc.done && tc.result) { + updateToolCall(tc.id, tc.result); + } + } + } + break; + + case 'error': + appendError(msg.message); + if (isGenerating) finishGenerating(); + break; + + case 'notify_config': + if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config); + break; + + case 'notify_test_result': + if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg); + break; + + case 'background_done': + // A background task completed (browser was disconnected or viewing another session) + showToast(`「${msg.title}」任务完成`, msg.sessionId); + showBrowserNotification(msg.title); + if (msg.sessionId === currentSessionId) { + // Reload current session to show completed response + send({ type: 'load_session', sessionId: msg.sessionId }); + } else { + send({ type: 'list_sessions' }); + } + break; + + case 'password_changed': + handlePasswordChanged(msg); + break; + } + } + + // --- Generating State --- + function startGenerating() { + isGenerating = true; + pendingText = ''; + activeToolCalls.clear(); + sendBtn.hidden = true; + abortBtn.hidden = false; + // 不禁用输入框,允许用户继续输入(但无法发送) + + const welcome = messagesDiv.querySelector('.welcome-msg'); + if (welcome) welcome.remove(); + + const msgEl = createMsgElement('assistant', ''); + msgEl.id = 'streaming-msg'; + messagesDiv.appendChild(msgEl); + scrollToBottom(); + } + + function finishGenerating(sessionId) { + isGenerating = false; + sendBtn.hidden = false; + abortBtn.hidden = true; + msgInput.focus(); + + if (pendingText) flushRender(); + + const typing = document.querySelector('.typing-indicator'); + if (typing) typing.remove(); + + const streamEl = document.getElementById('streaming-msg'); + if (streamEl) streamEl.removeAttribute('id'); + + if (sessionId) currentSessionId = sessionId; + pendingText = ''; + activeToolCalls.clear(); + } + + // --- Rendering --- + function scheduleRender() { + if (renderTimer) return; + renderTimer = setTimeout(() => { + renderTimer = null; + flushRender(); + }, RENDER_DEBOUNCE); + } + + function flushRender() { + const streamEl = document.getElementById('streaming-msg'); + if (!streamEl) return; + const bubble = streamEl.querySelector('.msg-bubble'); + if (!bubble) return; + bubble.innerHTML = renderMarkdown(pendingText); + scrollToBottom(); + } + + function renderMarkdown(text) { + if (!text) return '
'; + try { return marked.parse(text); } + catch { return escapeHtml(text); } + } + + function createMsgElement(role, content) { + const div = document.createElement('div'); + div.className = `msg ${role}`; + + if (role === 'system') { + const bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + bubble.textContent = content; + div.appendChild(bubble); + return div; + } + + const avatar = document.createElement('div'); + avatar.className = 'msg-avatar'; + avatar.textContent = role === 'user' ? 'U' : 'C'; + + const bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + + if (role === 'user') { + bubble.textContent = content; + } else { + bubble.innerHTML = content ? renderMarkdown(content) : '
'; + } + + div.appendChild(avatar); + div.appendChild(bubble); + return div; + } + + function renderMessages(messages) { + messagesDiv.innerHTML = ''; + if (messages.length === 0) { + messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; + return; + } + for (const m of messages) { + const el = createMsgElement(m.role, m.content); + if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { + const bubble = el.querySelector('.msg-bubble'); + for (const tc of m.toolCalls) { + const details = document.createElement('details'); + details.className = 'tool-call'; + const contentText = tc.result || (typeof tc.input === 'string' ? tc.input : (tc.input ? JSON.stringify(tc.input, null, 2) : '')); + details.innerHTML = ` ${escapeHtml(tc.name)} +
${escapeHtml(contentText)}
`; + bubble.insertBefore(details, bubble.firstChild); + } + } + messagesDiv.appendChild(el); + } + scrollToBottom(); + } + + function appendToolCall(toolUseId, name, input, done) { + const streamEl = document.getElementById('streaming-msg'); + if (!streamEl) return; + const bubble = streamEl.querySelector('.msg-bubble'); + if (!bubble) return; + + const details = document.createElement('details'); + details.className = 'tool-call'; + details.id = `tool-${toolUseId}`; + const inputStr = typeof input === 'string' ? input : (input ? JSON.stringify(input, null, 2) : ''); + details.innerHTML = ` + ${escapeHtml(name)} +
${escapeHtml(inputStr)}
+ `; + bubble.appendChild(details); + scrollToBottom(); + } + + 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) { + const content = el.querySelector('.tool-call-content'); + if (content) content.textContent = result; + } + } + + function appendSystemMessage(message) { + const welcome = messagesDiv.querySelector('.welcome-msg'); + if (welcome) welcome.remove(); + messagesDiv.appendChild(createMsgElement('system', message)); + scrollToBottom(); + } + + function appendError(message) { + const div = document.createElement('div'); + div.className = 'msg system'; + div.innerHTML = `
⚠ ${escapeHtml(message)}
`; + messagesDiv.appendChild(div); + scrollToBottom(); + } + + function scrollToBottom() { + requestAnimationFrame(() => { + messagesDiv.scrollTop = messagesDiv.scrollHeight; + }); + } + + // --- Session List --- + function renderSessionList() { + sessionList.innerHTML = ''; + for (const s of sessions) { + const item = document.createElement('div'); + item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`; + item.dataset.id = s.id; + item.innerHTML = ` + ${escapeHtml(s.title || 'Untitled')} + ${s.hasUnread ? '' : ''} + ${timeAgo(s.updated)} +
+ + +
+ `; + + item.addEventListener('click', (e) => { + const target = e.target; + if (target.classList.contains('delete')) { + e.stopPropagation(); + if (confirm('删除此会话?')) { + send({ type: 'delete_session', sessionId: s.id }); + if (s.id === currentSessionId) { + currentSessionId = null; + messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; + chatTitle.textContent = '新会话'; + costDisplay.textContent = ''; + } + } + return; + } + if (target.classList.contains('edit')) { + e.stopPropagation(); + startEditSessionTitle(item, s); + return; + } + send({ type: 'load_session', sessionId: s.id }); + }); + + sessionList.appendChild(item); + } + } + + function startEditSessionTitle(itemEl, session) { + const titleEl = itemEl.querySelector('.session-item-title'); + const currentTitle = session.title || ''; + const input = document.createElement('input'); + input.className = 'session-item-edit-input'; + input.value = currentTitle; + input.maxLength = 100; + + titleEl.replaceWith(input); + input.focus(); + input.select(); + + // Hide actions during edit + const actions = itemEl.querySelector('.session-item-actions'); + const time = itemEl.querySelector('.session-item-time'); + if (actions) actions.style.display = 'none'; + if (time) time.style.display = 'none'; + + function save() { + const newTitle = input.value.trim() || currentTitle; + if (newTitle !== currentTitle) { + send({ type: 'rename_session', sessionId: session.id, title: newTitle }); + } + // Restore + const span = document.createElement('span'); + span.className = 'session-item-title'; + span.textContent = newTitle; + input.replaceWith(span); + if (actions) actions.style.display = ''; + if (time) time.style.display = ''; + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { input.value = currentTitle; input.blur(); } + }); + } + + function highlightActiveSession() { + document.querySelectorAll('.session-item').forEach((el) => { + el.classList.toggle('active', el.dataset.id === currentSessionId); + }); + } + + // --- Header title editing (contenteditable) --- + chatTitle.addEventListener('click', () => { + if (!currentSessionId || chatTitle.contentEditable === 'true') return; + const originalText = chatTitle.textContent; + chatTitle.contentEditable = 'true'; + chatTitle.style.background = '#fff'; + chatTitle.style.outline = '1px solid var(--accent)'; + chatTitle.style.borderRadius = '6px'; + chatTitle.style.padding = '2px 8px'; + chatTitle.focus(); + // Select all text + const range = document.createRange(); + range.selectNodeContents(chatTitle); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + + function finish(save) { + chatTitle.contentEditable = 'false'; + chatTitle.style.background = ''; + chatTitle.style.outline = ''; + chatTitle.style.borderRadius = ''; + chatTitle.style.padding = ''; + const newTitle = chatTitle.textContent.trim() || originalText; + chatTitle.textContent = newTitle; + if (save && newTitle !== originalText && currentSessionId) { + send({ type: 'rename_session', sessionId: currentSessionId, title: newTitle }); + } + } + + chatTitle.addEventListener('blur', () => finish(true), { once: true }); + chatTitle.addEventListener('keydown', function handler(e) { + if (e.key === 'Enter') { e.preventDefault(); chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } + if (e.key === 'Escape') { chatTitle.textContent = originalText; chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } + }); + }); + + // --- Sidebar --- + function openSidebar() { + sidebar.classList.add('open'); + sidebarOverlay.hidden = false; + } + function closeSidebar() { + sidebar.classList.remove('open'); + sidebarOverlay.hidden = true; + } + + // --- Slash Command Menu --- + function showCmdMenu(filter) { + const filtered = SLASH_COMMANDS.filter(c => + c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1)) + ); + // Exact match first (fixes /mode vs /model ambiguity) + filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0)); + if (filtered.length === 0) { + hideCmdMenu(); + return; + } + cmdMenuIndex = 0; + cmdMenu.innerHTML = filtered.map((c, i) => + `
+ ${c.cmd} + ${c.desc} +
` + ).join(''); + cmdMenu.hidden = false; + + // Click handlers + cmdMenu.querySelectorAll('.cmd-item').forEach(el => { + el.addEventListener('click', () => { + const cmd = el.dataset.cmd; + if (cmd === '/model') { + hideCmdMenu(); + msgInput.value = ''; + showModelPicker(); + return; + } + if (cmd === '/mode') { + hideCmdMenu(); + msgInput.value = ''; + showModePicker(); + return; + } + msgInput.value = cmd + ' '; + hideCmdMenu(); + msgInput.focus(); + }); + }); + } + + function hideCmdMenu() { + cmdMenu.hidden = true; + cmdMenuIndex = -1; + } + + function navigateCmdMenu(direction) { + const items = cmdMenu.querySelectorAll('.cmd-item'); + if (items.length === 0) return; + items[cmdMenuIndex]?.classList.remove('active'); + cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length; + items[cmdMenuIndex]?.classList.add('active'); + } + + function selectCmdMenuItem() { + const items = cmdMenu.querySelectorAll('.cmd-item'); + if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) { + const cmd = items[cmdMenuIndex].dataset.cmd; + if (cmd === '/model') { + hideCmdMenu(); + msgInput.value = ''; + showModelPicker(); + return; + } + if (cmd === '/mode') { + hideCmdMenu(); + msgInput.value = ''; + showModePicker(); + return; + } + msgInput.value = cmd + ' '; + hideCmdMenu(); + msgInput.focus(); + } + } + + // --- Option Picker (generic) --- + function showOptionPicker(title, options, currentValue, onSelect) { + hideOptionPicker(); + + const picker = document.createElement('div'); + picker.className = 'option-picker'; + picker.id = 'option-picker'; + + picker.innerHTML = ` +
${escapeHtml(title)}
+ ${options.map(opt => ` +
+
+
${escapeHtml(opt.label)}
+
${escapeHtml(opt.desc)}
+
+ ${opt.value === currentValue ? '' : ''} +
+ `).join('')} + `; + + const chatMain = document.querySelector('.chat-main'); + chatMain.appendChild(picker); + + picker.querySelectorAll('.option-picker-item').forEach(el => { + el.addEventListener('click', () => { + onSelect(el.dataset.value); + hideOptionPicker(); + }); + }); + + // Close on outside click (delayed to avoid immediate close) + setTimeout(() => { + document.addEventListener('click', _pickerOutsideClick); + }, 0); + document.addEventListener('keydown', _pickerEscape); + } + + function hideOptionPicker() { + const picker = document.getElementById('option-picker'); + if (picker) picker.remove(); + document.removeEventListener('click', _pickerOutsideClick); + document.removeEventListener('keydown', _pickerEscape); + } + + function _pickerOutsideClick(e) { + const picker = document.getElementById('option-picker'); + if (picker && !picker.contains(e.target)) { + hideOptionPicker(); + } + } + + function _pickerEscape(e) { + if (e.key === 'Escape') { + hideOptionPicker(); + } + } + + function showModelPicker() { + showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => { + send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode }); + }); + } + + function showModePicker() { + showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => { + currentMode = value; + modeSelect.value = currentMode; + localStorage.setItem('cc-web-mode', currentMode); + if (currentSessionId) { + send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); + } + }); + } + + // --- Send Message --- + function sendMessage() { + const text = msgInput.value.trim(); + if (!text || isGenerating) return; + hideCmdMenu(); + hideOptionPicker(); + + // Slash commands: don't show as user bubble + if (text.startsWith('/')) { + // /model without argument → show interactive picker + if (text === '/model' || text === '/model ') { + showModelPicker(); + msgInput.value = ''; + autoResize(); + return; + } + // /mode without argument → show interactive picker + if (text === '/mode' || text === '/mode ') { + showModePicker(); + msgInput.value = ''; + autoResize(); + return; + } + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); + msgInput.value = ''; + autoResize(); + return; + } + + // Regular message + const welcome = messagesDiv.querySelector('.welcome-msg'); + if (welcome) welcome.remove(); + messagesDiv.appendChild(createMsgElement('user', text)); + scrollToBottom(); + + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); + msgInput.value = ''; + autoResize(); + startGenerating(); + } + + function autoResize() { + msgInput.style.height = 'auto'; + const max = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--input-max-height')) || 200; + msgInput.style.height = Math.min(msgInput.scrollHeight, max) + 'px'; + } + + // --- Event Listeners --- + loginForm.addEventListener('submit', (e) => { + e.preventDefault(); + const pw = loginPassword.value; + if (!pw) return; + loginError.hidden = true; + loginPasswordValue = pw; + // Remember password + if (rememberPw.checked) { + localStorage.setItem('cc-web-pw', pw); + } else { + localStorage.removeItem('cc-web-pw'); + } + send({ type: 'auth', password: pw }); + // Request notification permission on first user interaction + requestNotificationPermission(); + }); + + menuBtn.addEventListener('click', () => { + sidebar.classList.contains('open') ? closeSidebar() : openSidebar(); + }); + + sidebarOverlay.addEventListener('click', closeSidebar); + newChatBtn.addEventListener('click', () => send({ type: 'new_session' })); + sendBtn.addEventListener('click', sendMessage); + abortBtn.addEventListener('click', () => send({ type: 'abort' })); + + // Mode selector + modeSelect.value = currentMode; + modeSelect.addEventListener('change', () => { + currentMode = modeSelect.value; + localStorage.setItem('cc-web-mode', currentMode); + if (currentSessionId) { + send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); + } + }); + + msgInput.addEventListener('input', () => { + autoResize(); + const val = msgInput.value; + // Show slash command menu + if (val.startsWith('/') && !val.includes('\n')) { + showCmdMenu(val); + } else { + hideCmdMenu(); + } + }); + + msgInput.addEventListener('keydown', (e) => { + // Command menu navigation + if (!cmdMenu.hidden) { + if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; } + if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; } + if (e.key === 'Escape') { hideCmdMenu(); return; } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (!cmdMenu.hidden) { + // If menu is open and user presses Enter, select the item + selectCmdMenuItem(); + } else { + sendMessage(); + } + } + }); + + // Close cmd menu on outside click + document.addEventListener('click', (e) => { + if (!cmdMenu.contains(e.target) && e.target !== msgInput) { + hideCmdMenu(); + } + }); + + // --- Toast Notification --- + function showToast(text, sessionId) { + const toast = document.createElement('div'); + toast.className = 'toast-notification'; + toast.textContent = text; + if (sessionId) { + toast.style.cursor = 'pointer'; + toast.addEventListener('click', () => { + send({ type: 'load_session', sessionId }); + toast.remove(); + }); + } + document.body.appendChild(toast); + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 5000); + } + + // --- Browser Notification (via Service Worker for mobile) --- + function showBrowserNotification(title) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then((reg) => { + reg.showNotification('CC-Web', { + body: `「${title}」任务完成`, + tag: 'cc-web-task', + renotify: true, + }); + }).catch(() => {}); + } + } + + function requestNotificationPermission() { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + // --- Settings Panel --- + let _onNotifyConfig = null; + let _onNotifyTestResult = null; + + const settingsBtn = $('#settings-btn'); + + const PROVIDER_OPTIONS = [ + { value: 'off', label: '关闭' }, + { value: 'pushplus', label: 'PushPlus' }, + { value: 'telegram', label: 'Telegram' }, + { value: 'serverchan', label: 'Server酱' }, + { value: 'feishu', label: '飞书机器人' }, + { value: 'qqbot', label: 'QQ(Qmsg)' }, + ]; + + function showSettingsPanel() { + // Request current config + send({ type: 'get_notify_config' }); + + const overlay = document.createElement('div'); + overlay.className = 'settings-overlay'; + overlay.id = 'settings-overlay'; + + const panel = document.createElement('div'); + panel.className = 'settings-panel'; + + panel.innerHTML = ` +

+ ⚙ 设置 + +

+
通知设置
+
+ + +
+
+
+ + +
+
+ +
+ +
修改密码
+
+ + +
+
+ + +
至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
+
+
+ + +
+
+ +
+
+ `; + + overlay.appendChild(panel); + document.body.appendChild(overlay); + + const providerSelect = panel.querySelector('#notify-provider'); + const fieldsDiv = panel.querySelector('#notify-fields'); + const statusDiv = panel.querySelector('#notify-status'); + const closeBtn = panel.querySelector('.settings-close'); + const testBtn = panel.querySelector('#notify-test-btn'); + const saveBtn = panel.querySelector('#notify-save-btn'); + + let currentConfig = null; + + function renderFields(provider) { + fieldsDiv.innerHTML = ''; + if (provider === 'pushplus') { + fieldsDiv.innerHTML = ` +
+ + +
+ `; + } else if (provider === 'telegram') { + fieldsDiv.innerHTML = ` +
+ + +
+
+ + +
+ `; + } else if (provider === 'serverchan') { + fieldsDiv.innerHTML = ` +
+ + +
+ `; + } else if (provider === 'feishu') { + fieldsDiv.innerHTML = ` +
+ + +
+ `; + } else if (provider === 'qqbot') { + fieldsDiv.innerHTML = ` +
+ + +
+ `; + } + } + + providerSelect.addEventListener('change', () => renderFields(providerSelect.value)); + + function collectConfig() { + const provider = providerSelect.value; + const config = { 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'); + config.pushplus = { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') }; + config.telegram = { botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''), chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || '') }; + config.serverchan = { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') }; + config.feishu = { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') }; + config.qqbot = { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') }; + return config; + } + + function showStatus(msg, type) { + statusDiv.textContent = msg; + statusDiv.className = 'settings-status ' + type; + } + + _onNotifyConfig = (config) => { + currentConfig = config; + providerSelect.value = config.provider || 'off'; + renderFields(config.provider || 'off'); + }; + + _onNotifyTestResult = (msg) => { + showStatus(msg.message, msg.success ? 'success' : 'error'); + }; + + closeBtn.addEventListener('click', hideSettingsPanel); + overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); }); + + testBtn.addEventListener('click', () => { + // Save first then test + const config = collectConfig(); + send({ type: 'save_notify_config', config }); + showStatus('正在发送测试消息...', ''); + send({ type: 'test_notify' }); + }); + + saveBtn.addEventListener('click', () => { + const config = collectConfig(); + send({ type: 'save_notify_config', config }); + showStatus('已保存', 'success'); + }); + + // Password change in settings + const settingsCurrentPw = panel.querySelector('#settings-current-pw'); + const settingsNewPw = panel.querySelector('#settings-new-pw'); + const settingsConfirmPw = panel.querySelector('#settings-confirm-pw'); + const pwHint = panel.querySelector('#settings-pw-hint'); + const pwChangeBtn = panel.querySelector('#pw-change-btn'); + const pwStatus = panel.querySelector('#pw-status'); + + function checkSettingsPw() { + const newPw = settingsNewPw.value; + const confirmPw = settingsConfirmPw.value; + const currentPw = settingsCurrentPw.value; + if (!newPw) { + pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; + pwHint.className = 'password-hint'; + pwChangeBtn.disabled = true; + return; + } + const result = clientValidatePassword(newPw); + if (!result.valid) { + pwHint.textContent = result.message; + pwHint.className = 'password-hint error'; + pwChangeBtn.disabled = true; + return; + } + pwHint.textContent = '密码强度符合要求'; + pwHint.className = 'password-hint success'; + pwChangeBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw; + } + + settingsCurrentPw.addEventListener('input', checkSettingsPw); + settingsNewPw.addEventListener('input', checkSettingsPw); + settingsConfirmPw.addEventListener('input', checkSettingsPw); + + pwChangeBtn.addEventListener('click', () => { + const currentPw = settingsCurrentPw.value; + const newPw = settingsNewPw.value; + const confirmPw = settingsConfirmPw.value; + if (newPw !== confirmPw) { + pwStatus.textContent = '两次密码不一致'; + pwStatus.className = 'settings-status error'; + return; + } + pwChangeBtn.disabled = true; + pwStatus.textContent = '正在修改...'; + pwStatus.className = 'settings-status'; + _onPasswordChanged = (result) => { + if (result.success) { + pwStatus.textContent = result.message || '密码修改成功'; + pwStatus.className = 'settings-status success'; + settingsCurrentPw.value = ''; + settingsNewPw.value = ''; + settingsConfirmPw.value = ''; + pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; + pwHint.className = 'password-hint'; + } else { + pwStatus.textContent = result.message || '修改失败'; + pwStatus.className = 'settings-status error'; + pwChangeBtn.disabled = false; + } + }; + send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw }); + }); + + document.addEventListener('keydown', _settingsEscape); + } + + function hideSettingsPanel() { + const overlay = document.getElementById('settings-overlay'); + if (overlay) overlay.remove(); + _onNotifyConfig = null; + _onNotifyTestResult = null; + document.removeEventListener('keydown', _settingsEscape); + } + + function _settingsEscape(e) { + if (e.key === 'Escape') hideSettingsPanel(); + } + + if (settingsBtn) { + settingsBtn.addEventListener('click', showSettingsPanel); + } + + // --- Force Change Password --- + function showForceChangePassword() { + const overlay = document.createElement('div'); + overlay.className = 'force-change-overlay'; + overlay.id = 'force-change-overlay'; + + const panel = document.createElement('div'); + panel.className = 'force-change-panel'; + + panel.innerHTML = ` + +

修改初始密码

+

首次登录需要设置新密码

+
+ +
至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
+ + +
+
+ `; + + overlay.appendChild(panel); + document.body.appendChild(overlay); + + const newPwInput = panel.querySelector('#fc-new-pw'); + const confirmPwInput = panel.querySelector('#fc-confirm-pw'); + const hintEl = panel.querySelector('#fc-hint'); + const submitBtn = panel.querySelector('#fc-submit-btn'); + const statusEl = panel.querySelector('#fc-status'); + + function checkStrength() { + const pw = newPwInput.value; + const confirm = confirmPwInput.value; + if (!pw) { + hintEl.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; + hintEl.className = 'password-hint'; + submitBtn.disabled = true; + return; + } + const result = clientValidatePassword(pw); + if (!result.valid) { + hintEl.textContent = result.message; + hintEl.className = 'password-hint error'; + submitBtn.disabled = true; + return; + } + hintEl.textContent = '密码强度符合要求'; + hintEl.className = 'password-hint success'; + submitBtn.disabled = !confirm || confirm !== pw; + } + + newPwInput.addEventListener('input', checkStrength); + confirmPwInput.addEventListener('input', checkStrength); + + submitBtn.addEventListener('click', () => { + const newPw = newPwInput.value; + const confirmPw = confirmPwInput.value; + if (newPw !== confirmPw) { + statusEl.textContent = '两次密码不一致'; + statusEl.className = 'fc-status error'; + return; + } + submitBtn.disabled = true; + statusEl.textContent = '正在修改...'; + statusEl.className = 'fc-status'; + send({ type: 'change_password', currentPassword: loginPasswordValue || localStorage.getItem('cc-web-pw') || '', newPassword: newPw }); + }); + + newPwInput.focus(); + } + + function hideForceChangePassword() { + const overlay = document.getElementById('force-change-overlay'); + if (overlay) overlay.remove(); + } + + function clientValidatePassword(pw) { + if (!pw || pw.length < 8) { + return { valid: false, message: '密码长度至少 8 位' }; + } + let types = 0; + if (/[a-z]/.test(pw)) types++; + if (/[A-Z]/.test(pw)) types++; + if (/[0-9]/.test(pw)) types++; + if (/[^a-zA-Z0-9]/.test(pw)) types++; + if (types < 2) { + return { valid: false, message: '需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' }; + } + return { valid: true, message: '' }; + } + + // --- Password Changed Handler --- + let _onPasswordChanged = null; + + function handlePasswordChanged(msg) { + if (msg.success) { + // Update token + authToken = msg.token; + localStorage.setItem('cc-web-token', msg.token); + // Update remembered password + if (localStorage.getItem('cc-web-pw')) { + // Clear old remembered password since it's changed + localStorage.removeItem('cc-web-pw'); + } + + // If force-change overlay is open, close it and load sessions + const fcOverlay = document.getElementById('force-change-overlay'); + if (fcOverlay) { + hideForceChangePassword(); + const lastSession = localStorage.getItem('cc-web-session'); + if (lastSession) { + send({ type: 'load_session', sessionId: lastSession }); + } + showToast('密码修改成功'); + } + + // If settings panel change password + if (_onPasswordChanged) { + _onPasswordChanged({ success: true, message: msg.message }); + _onPasswordChanged = null; + } + } else { + // Force-change error + const fcStatus = document.querySelector('#fc-status'); + if (fcStatus) { + fcStatus.textContent = msg.message || '修改失败'; + fcStatus.className = 'fc-status error'; + const btn = document.querySelector('#fc-submit-btn'); + if (btn) btn.disabled = false; + } + + // Settings panel error + if (_onPasswordChanged) { + _onPasswordChanged({ success: false, message: msg.message }); + _onPasswordChanged = null; + } + } + } + + // --- Helpers --- + function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function timeAgo(dateStr) { + if (!dateStr) return ''; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}天前`; + return new Date(dateStr).toLocaleDateString('zh-CN'); + } + + // --- Init --- + connect(); + + // Register Service Worker for mobile push notifications + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + } + + // Restore remembered password + const savedPw = localStorage.getItem('cc-web-pw'); + if (savedPw) { + loginPassword.value = savedPw; + rememberPw.checked = true; + } + + // Visibility change: re-sync state when user returns to tab (critical for mobile) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (!ws || ws.readyState > 1) { + // WS is dead, force reconnect + connect(); + } else if (ws.readyState === 1 && currentSessionId) { + // WS alive, re-check session state to sync UI (fixes stuck stop button) + send({ type: 'load_session', sessionId: currentSessionId }); + } + }); + + if (!authToken) { + loginOverlay.hidden = false; + app.hidden = true; + } else { + loginOverlay.hidden = true; + app.hidden = false; + } +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fa5bf8e --- /dev/null +++ b/public/index.html @@ -0,0 +1,92 @@ + + + + + + + + CC-Web + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..3a097fb --- /dev/null +++ b/public/style.css @@ -0,0 +1,1039 @@ +/* ============================================ + CC-Web — 和風暖色調 (Japanese Warm Theme) + ============================================ */ + +:root { + /* --- 配色 --- */ + --bg-primary: #faf6f0; /* 象牙 ivory */ + --bg-secondary: #f2ebe2; /* 亜麻 flax */ + --bg-tertiary: #e9e0d4; /* 砂 sand */ + --bg-bubble-user: #c0553a; /* 朱色 vermillion */ + --bg-bubble-assistant: #fff9f2; /* 練色 warm white */ + --text-primary: #2d1f14; /* 黒茶 dark brown */ + --text-secondary: #6b5a4d; /* 栗色 chestnut */ + --text-muted: #9a8b7d; /* 灰茶 gray-brown */ + --border-color: #ddd0c0; /* 枯色 withered */ + --accent: #c0553a; /* 朱色 vermillion */ + --accent-hover: #a84530; /* 深朱 deep vermillion */ + --accent-light: #f5ddd4; /* 薄朱 light vermillion */ + --success: #5d8a54; /* 抹茶 matcha */ + --danger: #c0553a; + --info: #5b7ea1; /* 縹色 blue-gray */ + --scrollbar-thumb: #c9baa9; + --scrollbar-track: transparent; + --sidebar-width: 280px; + --header-height: 52px; + --input-max-height: 200px; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +/* === Reset === */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + height: 100%; + height: -webkit-fill-available; +} + +body { + height: 100%; + height: 100dvh; + min-height: -webkit-fill-available; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans CJK SC', Roboto, sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); + overflow: hidden; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: var(--scrollbar-track); } +::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #b0a090; } + +/* === Login === */ +.login-overlay { + position: fixed; inset: 0; + display: flex; align-items: center; justify-content: center; + background: linear-gradient(135deg, #faf6f0 0%, #f0e8dc 50%, #e8dccf 100%); + z-index: 1000; +} +.login-box { + text-align: center; + padding: 48px 36px; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 20px; + width: 90%; + max-width: 360px; + box-shadow: 0 8px 32px rgba(45, 31, 20, 0.08); +} +.login-logo { + width: 64px; height: 64px; + margin: 0 auto 16px; + background: var(--accent); + color: #fff; + border-radius: 16px; + display: flex; align-items: center; justify-content: center; + font-size: 24px; font-weight: 800; + letter-spacing: -1px; +} +.login-box h2 { + font-size: 24px; + margin-bottom: 4px; + color: var(--text-primary); + font-weight: 700; +} +.login-box p { + color: var(--text-secondary); + margin-bottom: 28px; + font-size: 14px; +} +.login-box input { + width: 100%; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 15px; + outline: none; + margin-bottom: 12px; + transition: border-color 0.2s; +} +.login-box input:focus { border-color: var(--accent); } +.login-box button { + width: 100%; + padding: 12px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} +.login-box button:hover { background: var(--accent-hover); } +.remember-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; + cursor: pointer; + user-select: none; +} +.remember-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} +.login-error { + color: var(--danger); + margin-top: 12px; + font-size: 14px; +} + +/* === App Layout === */ +.app { + display: flex; + height: 100%; + height: 100dvh; + min-height: -webkit-fill-available; + width: 100%; + overflow: hidden; +} + +/* === Sidebar === */ +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: transform 0.3s ease; + overflow: hidden; +} +.sidebar-header { + padding: 12px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} +.new-chat-btn { + width: 100%; + padding: 10px; + background: var(--accent); + border: none; + border-radius: 10px; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} +.new-chat-btn:hover { background: var(--accent-hover); } +.session-list { + flex: 1; + overflow-y: auto; + padding: 8px; + -webkit-overflow-scrolling: touch; +} +.session-item { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + margin-bottom: 2px; + transition: background 0.15s; + position: relative; +} +.session-item:hover { background: var(--bg-tertiary); } +.session-item.active { background: var(--accent-light); } +.session-item-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + color: var(--text-primary); +} +.session-item.active .session-item-title { color: var(--accent); font-weight: 500; } +.session-item-time { + font-size: 11px; + color: var(--text-muted); + margin-left: 8px; + flex-shrink: 0; +} +.session-item-actions { + display: none; + gap: 2px; + margin-left: 4px; + flex-shrink: 0; +} +.session-item:hover .session-item-actions { display: flex; } +.session-item-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px 5px; + font-size: 13px; + border-radius: 4px; + line-height: 1; +} +.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } +.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); } +/* Inline edit in sidebar */ +.session-item-edit-input { + flex: 1; + padding: 2px 6px; + border: 1px solid var(--accent); + border-radius: 4px; + background: #fff; + color: var(--text-primary); + font-size: 14px; + outline: none; + min-width: 0; +} +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border-color); + text-align: center; + flex-shrink: 0; +} +.brand { font-size: 12px; color: var(--text-muted); } + +/* === Chat Main === */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; + background: var(--bg-primary); +} +.chat-header { + height: var(--header-height); + min-height: var(--header-height); + display: flex; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + gap: 10px; + flex-shrink: 0; +} +.menu-btn { + display: none; + background: none; + border: none; + color: var(--text-primary); + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; +} +.menu-btn:hover { background: var(--bg-tertiary); } +.chat-title { + flex: 1; + font-weight: 600; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + transition: background 0.15s; +} +.chat-title:hover { background: var(--bg-tertiary); } +.cost-display { + font-size: 12px; + color: var(--text-muted); + flex-shrink: 0; + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 6px; +} +.cost-display:empty { display: none; } + +/* Mode selector */ +.mode-select { + -webkit-appearance: none; + appearance: none; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 4px 24px 4px 10px; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + flex-shrink: 0; + outline: none; + transition: border-color 0.15s; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b5a4d'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} +.mode-select:focus { border-color: var(--accent); } +.mode-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* === Messages === */ +.messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} +.welcome-msg { + text-align: center; + margin: auto; + padding: 40px 20px; + color: var(--text-secondary); +} +.welcome-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.6; +} +.welcome-msg h3 { + font-size: 20px; + color: var(--text-primary); + margin-bottom: 8px; + font-weight: 600; +} +.welcome-msg p { font-size: 14px; } + +/* Message Bubbles */ +.msg { + display: flex; + gap: 10px; + max-width: 85%; + animation: fadeIn 0.25s ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.msg.user { align-self: flex-end; flex-direction: row-reverse; } +.msg.assistant { align-self: flex-start; } + +.msg-avatar { + width: 32px; + height: 32px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} +.msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; } +.msg.assistant .msg-avatar { background: var(--success); color: #fff; } + +.msg-bubble { + padding: 12px 16px; + border-radius: 16px; + line-height: 1.65; + word-break: break-word; +} +.msg.user .msg-bubble { + background: var(--bg-bubble-user); + color: #fff; + border-bottom-right-radius: 4px; +} +.msg.assistant .msg-bubble { + background: var(--bg-bubble-assistant); + border: 1px solid var(--border-color); + border-bottom-left-radius: 4px; + color: var(--text-primary); +} + +/* System messages */ +.msg.system { + align-self: center; + max-width: 90%; +} +.msg.system .msg-bubble { + background: var(--bg-tertiary); + border: 1px dashed var(--border-color); + border-radius: 10px; + color: var(--text-secondary); + font-size: 13px; + padding: 10px 16px; + text-align: center; + white-space: pre-line; +} + +/* Markdown content */ +.msg-bubble p { margin: 0 0 8px 0; } +.msg-bubble p:last-child { margin-bottom: 0; } +.msg-bubble ul, .msg-bubble ol { margin: 4px 0 8px 20px; } +.msg-bubble li { margin-bottom: 2px; } +.msg-bubble h1, .msg-bubble h2, .msg-bubble h3, .msg-bubble h4 { + margin: 12px 0 6px 0; +} +.msg-bubble h1 { font-size: 1.3em; } +.msg-bubble h2 { font-size: 1.15em; } +.msg-bubble h3 { font-size: 1.05em; } +.msg-bubble a { color: var(--info); text-decoration: none; } +.msg-bubble a:hover { text-decoration: underline; } +.msg-bubble blockquote { + border-left: 3px solid var(--border-color); + padding-left: 12px; + color: var(--text-secondary); + margin: 8px 0; +} +.msg-bubble table { + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; + width: 100%; + overflow-x: auto; + display: block; +} +.msg-bubble th, .msg-bubble td { + border: 1px solid var(--border-color); + padding: 6px 10px; + text-align: left; +} +.msg-bubble th { background: var(--bg-secondary); } + +/* Inline code */ +.msg-bubble code:not(pre code) { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.88em; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + color: var(--accent); +} +.msg.user .msg-bubble code:not(pre code) { + background: rgba(255,255,255,0.2); + color: #fff; +} + +/* Code blocks */ +.code-block-wrapper { + position: relative; + margin: 8px 0; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-color); +} +.code-block-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background: #2b2b2b; + font-size: 12px; + color: #999; +} +.code-copy-btn { + background: none; + border: none; + color: #999; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; +} +.code-copy-btn:hover { color: #fff; background: #444; } +.code-block-wrapper pre { + margin: 0; + padding: 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.code-block-wrapper pre code { + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 13px; + line-height: 1.5; +} + +/* Tool calls */ +.tool-call { + margin: 8px 0; + border: 1px solid var(--border-color); + border-radius: 10px; + overflow: hidden; +} +.tool-call summary { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 8px; + user-select: none; + list-style: none; +} +.tool-call summary::-webkit-details-marker { display: none; } +.tool-call summary::before { + content: '▸'; + font-size: 11px; + transition: transform 0.2s; +} +.tool-call[open] summary::before { transform: rotate(90deg); } +.tool-call summary:hover { background: var(--bg-tertiary); } +.tool-call-icon { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.tool-call-icon.done { background: var(--success); } +.tool-call-icon.running { background: #d4a33a; animation: pulse 1s infinite; } +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.tool-call-content { + padding: 8px 12px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-primary); + max-height: 250px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + font-family: 'SF Mono', monospace; +} + +/* Typing indicator */ +.typing-indicator { + display: inline-flex; + gap: 5px; + padding: 8px 4px; +} +.typing-indicator span { + width: 7px; height: 7px; + background: var(--accent); + border-radius: 50%; + opacity: 0.5; + animation: bounce 1.4s infinite; +} +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-7px); } +} + +/* === Slash Command Menu === */ +.cmd-menu { + position: absolute; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: #fff; + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 4px 20px rgba(45, 31, 20, 0.12); + padding: 6px; + min-width: 240px; + max-width: 320px; + z-index: 50; +} +.cmd-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; +} +.cmd-item:hover, .cmd-item.active { background: var(--accent-light); } +.cmd-item-cmd { + font-weight: 600; + font-size: 14px; + color: var(--accent); + white-space: nowrap; +} +.cmd-item-desc { + font-size: 13px; + color: var(--text-secondary); +} + +/* === Input Area === */ +.input-area { + padding: 10px 16px; + padding-bottom: max(14px, var(--safe-bottom)); + background: var(--bg-primary); + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} +.input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 8px 12px; + max-width: 800px; + margin: 0 auto; + transition: border-color 0.2s, box-shadow 0.2s; +} +.input-wrapper:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +#msg-input { + flex: 1; + background: transparent; + border: none; + color: var(--text-primary); + font-size: 15px; + font-family: inherit; + line-height: 1.5; + resize: none; + outline: none; + max-height: var(--input-max-height); + min-height: 24px; +} +#msg-input::placeholder { color: var(--text-muted); } +.send-btn, .abort-btn { + width: 36px; height: 36px; + border: none; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; justify-content: center; + flex-shrink: 0; + transition: background 0.15s, transform 0.1s; +} +.send-btn:active, .abort-btn:active { transform: scale(0.92); } +.send-btn { + background: var(--accent); + color: #fff; +} +.send-btn:hover { background: var(--accent-hover); } +.send-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.abort-btn { + background: var(--danger); + color: #fff; +} +.abort-btn:hover { background: var(--accent-hover); } + +/* === Option Picker === */ +.option-picker { + position: absolute; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: #fff; + border: 1px solid var(--border-color); + border-radius: 14px; + box-shadow: 0 8px 32px rgba(45, 31, 20, 0.15); + padding: 16px; + min-width: 280px; + max-width: 380px; + z-index: 50; + animation: fadeIn 0.2s ease; +} +.option-picker-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + padding: 0 4px; +} +.option-picker-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + cursor: pointer; + transition: background 0.12s; + margin-bottom: 4px; +} +.option-picker-item:last-child { margin-bottom: 0; } +.option-picker-item:hover { background: var(--accent-light); } +.option-picker-item.active { + background: var(--accent-light); + outline: 1px solid var(--accent); +} +.option-picker-item-info { flex: 1; min-width: 0; } +.option-picker-item-label { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); +} +.option-picker-item-desc { + font-size: 13px; + color: var(--text-secondary); + margin-top: 2px; +} +.option-picker-item-check { + color: var(--accent); + font-size: 16px; + font-weight: 700; + flex-shrink: 0; +} + +/* === Mobile === */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: 0; top: 0; bottom: 0; + z-index: 100; + transform: translateX(-100%); + } + .sidebar.open { transform: translateX(0); } + .sidebar-overlay { + position: fixed; inset: 0; + background: rgba(45, 31, 20, 0.3); + z-index: 99; + } + .menu-btn { display: block; } + .msg { max-width: 95%; } + .input-area { padding: 8px 10px; padding-bottom: max(10px, var(--safe-bottom)); } + .messages { padding: 12px 8px; gap: 10px; } + .session-item-actions { display: flex; } + .cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; } + .option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; } +} + +@media (max-width: 480px) { + .msg-avatar { width: 28px; height: 28px; font-size: 12px; border-radius: 8px; } + .msg-bubble { padding: 10px 12px; font-size: 14px; border-radius: 14px; } + .msg.user .msg-bubble { border-bottom-right-radius: 4px; } + .msg.assistant .msg-bubble { border-bottom-left-radius: 4px; } + .code-block-wrapper pre code { font-size: 12px; } + .input-wrapper { padding: 6px 10px; border-radius: 12px; } + .send-btn, .abort-btn { width: 34px; height: 34px; } +} + +/* === Utility === */ +[hidden] { display: none !important; } + +/* === Toast Notification === */ +.toast-notification { + position: fixed; + top: 16px; + right: 16px; + background: var(--success); + color: #fff; + padding: 12px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + z-index: 200; + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s, transform 0.3s; +} +.toast-notification.show { + opacity: 1; + transform: translateY(0); +} + +/* === Session Unread Dot === */ +.session-unread-dot { + width: 8px; + height: 8px; + background: var(--accent); + border-radius: 50%; + flex-shrink: 0; + margin-left: 4px; +} + +/* === Settings Button (sidebar) === */ +.settings-btn { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--text-muted); + padding: 4px 8px; + border-radius: 6px; + transition: color 0.15s, background 0.15s; + vertical-align: middle; + margin-right: 8px; +} +.settings-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } + +/* === Settings Overlay & Panel === */ +.settings-overlay { + position: fixed; inset: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(45, 31, 20, 0.4); + z-index: 1000; + animation: fadeIn 0.2s ease; +} +.settings-panel { + background: #fff; + border: 1px solid var(--border-color); + border-radius: 20px; + width: 90%; + max-width: 420px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(45, 31, 20, 0.12); + padding: 32px 28px; + animation: fadeIn 0.25s ease; +} +.settings-panel h3 { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 8px; +} +.settings-panel h3 .settings-close { + margin-left: auto; + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: var(--text-muted); + padding: 2px 6px; + border-radius: 6px; +} +.settings-panel h3 .settings-close:hover { color: var(--text-primary); background: var(--bg-tertiary); } +.settings-field { + margin-bottom: 16px; +} +.settings-field label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} +.settings-field input, +.settings-select { + width: 100%; + padding: 10px 14px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + font-family: inherit; + outline: none; + transition: border-color 0.2s; +} +.settings-field input:focus, +.settings-select:focus { border-color: var(--accent); } +.settings-select { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b5a4d'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} +.settings-actions { + display: flex; + gap: 10px; + margin-top: 24px; +} +.settings-actions button { + flex: 1; + padding: 10px; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + font-family: inherit; +} +.settings-actions .btn-test { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} +.settings-actions .btn-test:hover { background: var(--bg-secondary); } +.settings-actions .btn-save { + background: var(--accent); + color: #fff; +} +.settings-actions .btn-save:hover { background: var(--accent-hover); } +.settings-status { + margin-top: 12px; + font-size: 13px; + text-align: center; + min-height: 20px; +} +.settings-status.success { color: var(--success); } +.settings-status.error { color: var(--danger); } + +@media (max-width: 768px) { + .settings-panel { padding: 24px 20px; border-radius: 16px; } +} +@media (max-width: 480px) { + .settings-panel { width: 95%; padding: 20px 16px; } +} + +/* === Force Change Password Overlay === */ +.force-change-overlay { + position: fixed; inset: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(45, 31, 20, 0.6); + z-index: 2000; + animation: fadeIn 0.25s ease; +} +.force-change-panel { + text-align: center; + padding: 48px 36px; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 20px; + width: 90%; + max-width: 400px; + box-shadow: 0 8px 32px rgba(45, 31, 20, 0.15); + animation: fadeIn 0.25s ease; +} +.force-change-panel .login-logo { + width: 56px; height: 56px; + margin: 0 auto 12px; + background: var(--accent); + color: #fff; + border-radius: 14px; + display: flex; align-items: center; justify-content: center; + font-size: 20px; font-weight: 800; +} +.force-change-panel h2 { + font-size: 22px; + margin-bottom: 4px; + color: var(--text-primary); + font-weight: 700; +} +.force-change-panel p { + color: var(--text-secondary); + margin-bottom: 24px; + font-size: 14px; +} +.force-change-form input { + width: 100%; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 15px; + outline: none; + margin-bottom: 10px; + transition: border-color 0.2s; +} +.force-change-form input:focus { border-color: var(--accent); } +.fc-submit-btn { + width: 100%; + padding: 12px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, opacity 0.2s; + margin-top: 6px; +} +.fc-submit-btn:hover:not(:disabled) { background: var(--accent-hover); } +.fc-submit-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.fc-status { + margin-top: 10px; + font-size: 13px; + min-height: 18px; + color: var(--text-secondary); +} +.fc-status.error { color: var(--danger); } +.fc-status.success { color: var(--success); } + +/* === Password Hint === */ +.password-hint { + font-size: 12px; + color: var(--text-muted); + margin: -4px 0 10px 2px; + text-align: left; +} +.password-hint.error { color: var(--danger); } +.password-hint.success { color: var(--success); } + +/* === Settings Divider === */ +.settings-divider { + height: 1px; + background: var(--border-color); + margin: 24px 0; +} + +/* === Settings Section Title === */ +.settings-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 14px; +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..440c2dd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,28 @@ +// CC-Web Service Worker — handles push notifications on mobile +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SHOW_NOTIFICATION') { + event.waitUntil( + self.registration.showNotification(event.data.title || 'CC-Web', { + body: event.data.body || '', + icon: event.data.icon || undefined, + tag: 'cc-web-task', + renotify: true, + data: event.data.data || {}, + }) + ); + } +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + return client.focus(); + } + } + return self.clients.openWindow('/'); + }) + ); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..0424bf9 --- /dev/null +++ b/server.js @@ -0,0 +1,1214 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const { WebSocketServer } = require('ws'); + +// Load .env +const envPath = path.join(__dirname, '.env'); +if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m && !process.env[m[1].trim()]) process.env[m[1].trim()] = m[2].trim(); + } +} + +const PORT = parseInt(process.env.PORT) || 8002; +const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude'; +const SESSIONS_DIR = path.join(__dirname, 'sessions'); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const LOGS_DIR = path.join(__dirname, 'logs'); +const NOTIFY_CONFIG_PATH = path.join(__dirname, 'config', 'notify.json'); +const AUTH_CONFIG_PATH = path.join(__dirname, 'config', 'auth.json'); + +fs.mkdirSync(SESSIONS_DIR, { recursive: true }); +fs.mkdirSync(LOGS_DIR, { recursive: true }); +fs.mkdirSync(path.dirname(NOTIFY_CONFIG_PATH), { recursive: true }); + +// === Process Lifecycle Logger === +const LOG_FILE = path.join(LOGS_DIR, 'process.log'); +const LOG_MAX_SIZE = 2 * 1024 * 1024; // 2MB per file + +function plog(level, event, data = {}) { + const entry = { + ts: new Date().toISOString(), + level, + event, + ...data, + }; + const line = JSON.stringify(entry) + '\n'; + try { + // Simple rotation: if file > 2MB, rename to .old and start fresh + try { + const stat = fs.statSync(LOG_FILE); + if (stat.size > LOG_MAX_SIZE) { + const oldFile = LOG_FILE.replace('.log', '.old.log'); + try { fs.unlinkSync(oldFile); } catch {} + fs.renameSync(LOG_FILE, oldFile); + } + } catch {} + fs.appendFileSync(LOG_FILE, line); + } catch {} +} + +// === Notification System === +function loadNotifyConfig() { + try { + if (fs.existsSync(NOTIFY_CONFIG_PATH)) { + return JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8')); + } + } catch {} + // First run: migrate from .env PUSHPLUS_TOKEN + const token = process.env.PUSHPLUS_TOKEN || ''; + const config = { + provider: token ? 'pushplus' : 'off', + pushplus: { token }, + telegram: { botToken: '', chatId: '' }, + serverchan: { sendKey: '' }, + feishu: { webhook: '' }, + qqbot: { qmsgKey: '' }, + }; + saveNotifyConfig(config); + return config; +} + +function saveNotifyConfig(config) { + fs.writeFileSync(NOTIFY_CONFIG_PATH, JSON.stringify(config, null, 2)); +} + +function maskToken(str) { + if (!str || str.length <= 8) return str ? '****' : ''; + return str.slice(0, 4) + '****' + str.slice(-4); +} + +function getNotifyConfigMasked() { + const config = loadNotifyConfig(); + return { + provider: config.provider, + pushplus: { token: maskToken(config.pushplus?.token) }, + telegram: { botToken: maskToken(config.telegram?.botToken), chatId: config.telegram?.chatId || '' }, + serverchan: { sendKey: maskToken(config.serverchan?.sendKey) }, + feishu: { webhook: maskToken(config.feishu?.webhook) }, + qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) }, + }; +} + +function sendNotification(title, content) { + const config = loadNotifyConfig(); + if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true }); + const https = require('https'); + + return new Promise((resolve) => { + let url, data; + let isFormData = false; + switch (config.provider) { + case 'pushplus': { + if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' }); + url = 'https://www.pushplus.plus/send'; + data = JSON.stringify({ token: config.pushplus.token, title, content, template: 'txt' }); + break; + } + case 'telegram': { + if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' }); + url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`; + data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${content}` }); + break; + } + case 'serverchan': { + if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' }); + url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`; + data = JSON.stringify({ title, desp: content }); + break; + } + case 'feishu': { + if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' }); + url = config.feishu.webhook; + data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${content}` } }); + break; + } + case 'qqbot': { + if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' }); + url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`; + data = `msg=${encodeURIComponent(`${title}\n\n${content}`)}`; + isFormData = true; + break; + } + default: + return resolve({ ok: false, error: `未知通知方式: ${config.provider}` }); + } + + const parsed = new URL(url); + const contentType = isFormData ? 'application/x-www-form-urlencoded' : 'application/json'; + const req = https.request(parsed, { + method: 'POST', + headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(data) }, + }, (res) => { + let body = ''; + res.on('data', (c) => body += c); + res.on('end', () => { + plog('INFO', 'notify_response', { provider: config.provider, status: res.statusCode, body: body.slice(0, 200) }); + resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: body.slice(0, 200) }); + }); + }); + req.on('error', (e) => { + plog('WARN', 'notify_error', { provider: config.provider, error: e.message }); + resolve({ ok: false, error: e.message }); + }); + req.write(data); + req.end(); + }); +} + +// Load config on startup (ensures migration) +loadNotifyConfig(); + +// === Auth Config === +function generateRandomPassword(length = 12) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + const bytes = crypto.randomBytes(length); + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % chars.length]; + } + return result; +} + +function loadAuthConfig() { + // Priority 1: config/auth.json exists with password + try { + if (fs.existsSync(AUTH_CONFIG_PATH)) { + const config = JSON.parse(fs.readFileSync(AUTH_CONFIG_PATH, 'utf8')); + if (config.password) return config; + } + } catch {} + + // Priority 2: .env has CC_WEB_PASSWORD → migrate + const envPw = process.env.CC_WEB_PASSWORD; + if (envPw && envPw !== 'changeme') { + const config = { password: envPw, mustChange: false }; + saveAuthConfig(config); + return config; + } + + // Priority 3: Generate random password + const pw = generateRandomPassword(12); + const config = { password: pw, mustChange: true }; + saveAuthConfig(config); + console.log('========================================'); + console.log(' 自动生成初始密码: ' + pw); + console.log(' 首次登录后将要求修改密码'); + console.log('========================================'); + return config; +} + +function saveAuthConfig(config) { + fs.writeFileSync(AUTH_CONFIG_PATH, JSON.stringify(config, null, 2)); +} + +function validatePasswordStrength(pw) { + if (!pw || pw.length < 8) { + return { valid: false, message: '密码长度至少 8 位' }; + } + let types = 0; + if (/[a-z]/.test(pw)) types++; + if (/[A-Z]/.test(pw)) types++; + if (/[0-9]/.test(pw)) types++; + if (/[^a-zA-Z0-9]/.test(pw)) types++; + if (types < 2) { + return { valid: false, message: '密码需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' }; + } + return { valid: true, message: '' }; +} + +let authConfig = loadAuthConfig(); +let PASSWORD = authConfig.password; + +const activeTokens = new Set(); + +// Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer } +const activeProcesses = new Map(); + +// Track which session each ws is viewing: ws -> sessionId +const wsSessionMap = new Map(); + +const MODEL_MAP = { + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', +}; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +// === Utility Functions === + +function wsSend(ws, data) { + if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); +} + +function sanitizeId(id) { + return String(id).replace(/[^a-zA-Z0-9\-]/g, ''); +} + +function sessionPath(id) { + return path.join(SESSIONS_DIR, `${sanitizeId(id)}.json`); +} + +function runDir(sessionId) { + return path.join(SESSIONS_DIR, `${sanitizeId(sessionId)}-run`); +} + +function loadSession(id) { + try { + return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8')); + } catch { + return null; + } +} + +function saveSession(session) { + fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2)); +} + +function modelShortName(fullModel) { + if (!fullModel) return null; + const entry = Object.entries(MODEL_MAP).find(([, v]) => v === fullModel); + return entry ? entry[0] : null; +} + +function isProcessRunning(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanRunDir(sessionId) { + const dir = runDir(sessionId); + try { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); + } catch {} +} + +function sendSessionList(ws) { + try { + const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json')); + const sessions = []; + for (const f of files) { + try { + const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); + sessions.push({ id: s.id, title: s.title || 'Untitled', updated: s.updated, hasUnread: !!s.hasUnread }); + } catch {} + } + sessions.sort((a, b) => new Date(b.updated) - new Date(a.updated)); + wsSend(ws, { type: 'session_list', sessions }); + } catch { + wsSend(ws, { type: 'session_list', sessions: [] }); + } +} + +// === File Tailer === +// Tails a file and calls onLine for each new complete line. +class FileTailer { + constructor(filePath, onLine) { + this.filePath = filePath; + this.onLine = onLine; + this.offset = 0; + this.buffer = ''; + this.watcher = null; + this.interval = null; + this.stopped = false; + } + + start() { + this.readNew(); + try { + this.watcher = fs.watch(this.filePath, () => { + if (!this.stopped) this.readNew(); + }); + this.watcher.on('error', () => {}); + } catch {} + // Backup poll every 500ms (fs.watch not always reliable on all systems) + this.interval = setInterval(() => { + if (!this.stopped) this.readNew(); + }, 500); + } + + readNew() { + try { + const stat = fs.statSync(this.filePath); + if (stat.size <= this.offset) return; + const buf = Buffer.alloc(stat.size - this.offset); + const fd = fs.openSync(this.filePath, 'r'); + fs.readSync(fd, buf, 0, buf.length, this.offset); + fs.closeSync(fd); + this.offset = stat.size; + this.buffer += buf.toString(); + const lines = this.buffer.split('\n'); + this.buffer = lines.pop(); + for (const line of lines) { + if (line.trim()) this.onLine(line); + } + } catch {} + } + + stop() { + this.stopped = true; + if (this.watcher) { this.watcher.close(); this.watcher = null; } + if (this.interval) { clearInterval(this.interval); this.interval = null; } + } +} + +// === Process Lifecycle === + +function handleProcessComplete(sessionId, exitCode, signal) { + const entry = activeProcesses.get(sessionId); + if (!entry) return; + + const completeTime = new Date().toISOString(); + const wsConnected = !!entry.ws; + const disconnectGap = entry.wsDisconnectTime + ? ((new Date(completeTime) - new Date(entry.wsDisconnectTime)) / 1000).toFixed(1) + 's' + : null; + + // Read stderr for error clues + let stderrSnippet = ''; + try { + const errPath = path.join(runDir(sessionId), 'error.log'); + if (fs.existsSync(errPath)) { + const content = fs.readFileSync(errPath, 'utf8').trim(); + if (content) stderrSnippet = content.slice(-500); + } + } catch {} + + plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', { + sessionId: sessionId.slice(0, 8), + pid: entry.pid, + exitCode, + signal, + wsConnected, + wsDisconnectTime: entry.wsDisconnectTime || null, + disconnectToDeathGap: disconnectGap, + responseLen: (entry.fullText || '').length, + toolCallCount: (entry.toolCalls || []).length, + cost: entry.lastCost, + stderr: stderrSnippet || null, + }); + + // Final read + if (entry.tailer) { + entry.tailer.readNew(); + entry.tailer.stop(); + } + + // Save result to session + const session = loadSession(sessionId); + if (session && entry.fullText) { + session.messages.push({ + role: 'assistant', + content: entry.fullText, + toolCalls: entry.toolCalls || [], + timestamp: new Date().toISOString(), + }); + session.updated = new Date().toISOString(); + if (!entry.ws) session.hasUnread = true; + saveSession(session); + } + + // Notify client + if (entry.ws) { + wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null }); + sendSessionList(entry.ws); + } else { + // Process completed while browser was disconnected — notify all connected clients + const session = loadSession(sessionId); + const title = session?.title || 'Untitled'; + for (const client of wss.clients) { + if (client.readyState === 1) { + wsSend(client, { + type: 'background_done', + sessionId, + title, + costUsd: entry.lastCost || null, + responseLen: (entry.fullText || '').length, + }); + } + } + // Push notification + const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : ''; + const respLen = (entry.fullText || '').length; + sendNotification( + `CC-Web 任务完成`, + `会话: ${title}\n字数: ${respLen}\n费用: ${cost}` + ); + } + + activeProcesses.delete(sessionId); + cleanRunDir(sessionId); +} + +// Global PID monitor: detect process completion (especially after server restart) +setInterval(() => { + for (const [sessionId, entry] of activeProcesses) { + if (entry.pid && !isProcessRunning(entry.pid)) { + plog('INFO', 'pid_monitor_detected_exit', { + sessionId: sessionId.slice(0, 8), + pid: entry.pid, + wsConnected: !!entry.ws, + }); + handleProcessComplete(sessionId, null, 'unknown (detected by monitor)'); + } + } +}, 2000); + +// Recover processes that were running before server restart +function recoverProcesses() { + try { + const entries = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory()); + if (entries.length === 0) return; + plog('INFO', 'recovery_start', { runDirs: entries.length }); + for (const dirName of entries) { + const sessionId = dirName.replace('-run', ''); + const dir = path.join(SESSIONS_DIR, dirName); + const pidPath = path.join(dir, 'pid'); + const outputPath = path.join(dir, 'output.jsonl'); + + if (!fs.existsSync(pidPath)) { + try { fs.rmSync(dir, { recursive: true }); } catch {} + continue; + } + + const pid = parseInt(fs.readFileSync(pidPath, 'utf8')); + + if (isProcessRunning(pid)) { + console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`); + plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid }); + const entry = { pid, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null }; + activeProcesses.set(sessionId, entry); + + if (fs.existsSync(outputPath)) { + entry.tailer = new FileTailer(outputPath, (line) => { + try { + const event = JSON.parse(line); + processClaudeEvent(entry, event, sessionId); + } catch {} + }); + entry.tailer.start(); + } + } else { + // Process finished while server was down — read all output and save + console.log(`[recovery] Processing completed output for session ${sessionId}`); + plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid }); + if (fs.existsSync(outputPath)) { + const tempEntry = { pid: 0, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null }; + const content = fs.readFileSync(outputPath, 'utf8'); + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + processClaudeEvent(tempEntry, event, sessionId); + } catch {} + } + const session = loadSession(sessionId); + if (session && tempEntry.fullText) { + session.messages.push({ + role: 'assistant', + content: tempEntry.fullText, + toolCalls: tempEntry.toolCalls || [], + timestamp: new Date().toISOString(), + }); + session.updated = new Date().toISOString(); + saveSession(session); + } + } + try { fs.rmSync(dir, { recursive: true }); } catch {} + } + } + } catch (err) { + console.error('[recovery] Error:', err.message); + } +} + +// === HTTP Static File Server === +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname); + filePath = path.resolve(filePath); + + if (!filePath.startsWith(PUBLIC_DIR)) { + res.writeHead(403); + return res.end('Forbidden'); + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + return res.end('Not Found'); + } + const ext = path.extname(filePath); + res.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + }); +}); + +// === WebSocket Server === +const wss = new WebSocketServer({ server }); + +wss.on('connection', (ws) => { + let authenticated = false; + let authToken = null; + const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation + const wsConnectTime = new Date().toISOString(); + plog('INFO', 'ws_connect', { wsId }); + + ws.on('message', (raw) => { + let msg; + try { + msg = JSON.parse(raw); + } catch { + return wsSend(ws, { type: 'error', message: 'Invalid JSON' }); + } + + if (msg.type === 'auth') { + if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) { + authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex'); + activeTokens.add(authToken); + authenticated = true; + wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange }); + sendSessionList(ws); + } else { + wsSend(ws, { type: 'auth_result', success: false }); + } + return; + } + + if (!authenticated) { + return wsSend(ws, { type: 'error', message: 'Not authenticated' }); + } + + switch (msg.type) { + case 'message': + if (msg.text && msg.text.trim().startsWith('/')) { + handleSlashCommand(ws, msg.text.trim(), msg.sessionId); + } else { + handleMessage(ws, msg); + } + break; + case 'abort': + handleAbort(ws); + break; + case 'new_session': + handleNewSession(ws); + break; + case 'load_session': + handleLoadSession(ws, msg.sessionId); + break; + case 'delete_session': + handleDeleteSession(ws, msg.sessionId); + break; + case 'rename_session': + handleRenameSession(ws, msg.sessionId, msg.title); + break; + case 'set_mode': + handleSetMode(ws, msg.sessionId, msg.mode); + break; + case 'list_sessions': + sendSessionList(ws); + break; + case 'get_notify_config': + wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() }); + break; + case 'save_notify_config': + handleSaveNotifyConfig(ws, msg.config); + break; + case 'test_notify': + handleTestNotify(ws); + break; + case 'change_password': + handleChangePassword(ws, msg, authToken); + break; + default: + wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` }); + } + }); + + ws.on('close', () => handleDisconnect(ws, wsId)); + ws.on('error', (err) => { + plog('WARN', 'ws_error', { wsId, error: err.message }); + handleDisconnect(ws, wsId); + }); +}); + +// === Notify Config Handlers === +function handleSaveNotifyConfig(ws, newConfig) { + if (!newConfig || !newConfig.provider) { + return wsSend(ws, { type: 'error', message: '无效的通知配置' }); + } + const current = loadNotifyConfig(); + // Merge: only update fields that are not masked (contain ****) + const merged = { provider: newConfig.provider }; + // pushplus + merged.pushplus = { token: (newConfig.pushplus?.token && !newConfig.pushplus.token.includes('****')) ? newConfig.pushplus.token : current.pushplus?.token || '' }; + // telegram + merged.telegram = { + botToken: (newConfig.telegram?.botToken && !newConfig.telegram.botToken.includes('****')) ? newConfig.telegram.botToken : current.telegram?.botToken || '', + chatId: newConfig.telegram?.chatId !== undefined ? newConfig.telegram.chatId : current.telegram?.chatId || '', + }; + // serverchan + merged.serverchan = { sendKey: (newConfig.serverchan?.sendKey && !newConfig.serverchan.sendKey.includes('****')) ? newConfig.serverchan.sendKey : current.serverchan?.sendKey || '' }; + // feishu + merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' }; + // qqbot + merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' }; + + saveNotifyConfig(merged); + plog('INFO', 'notify_config_saved', { provider: merged.provider }); + wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() }); + wsSend(ws, { type: 'system_message', message: '通知配置已保存' }); +} + +function handleTestNotify(ws) { + const config = loadNotifyConfig(); + if (!config.provider || config.provider === 'off') { + return wsSend(ws, { type: 'notify_test_result', success: false, message: '通知已关闭,无法测试' }); + } + sendNotification('CC-Web 测试通知', '这是一条测试消息,如果你收到了说明通知配置正确!').then((result) => { + wsSend(ws, { type: 'notify_test_result', success: result.ok, message: result.ok ? '测试消息已发送,请检查是否收到' : `发送失败: ${result.error || result.body || '未知错误'}` }); + }); +} + +function handleChangePassword(ws, msg, currentToken) { + const { currentPassword, newPassword } = msg; + + // Validate current password + if (currentPassword !== PASSWORD) { + return wsSend(ws, { type: 'password_changed', success: false, message: '当前密码错误' }); + } + + // Validate new password strength + const strength = validatePasswordStrength(newPassword); + if (!strength.valid) { + return wsSend(ws, { type: 'password_changed', success: false, message: strength.message }); + } + + // Save new password + authConfig = { password: newPassword, mustChange: false }; + saveAuthConfig(authConfig); + PASSWORD = newPassword; + plog('INFO', 'password_changed', {}); + + // Clear all tokens (force all sessions to re-login) + activeTokens.clear(); + + // Generate new token for current connection + const newToken = crypto.randomBytes(32).toString('hex'); + activeTokens.add(newToken); + + wsSend(ws, { type: 'password_changed', success: true, token: newToken, message: '密码修改成功' }); +} + +// === Slash Command Handler === +function handleSlashCommand(ws, text, sessionId) { + const parts = text.split(/\s+/); + const cmd = parts[0].toLowerCase(); + let session = sessionId ? loadSession(sessionId) : null; + + switch (cmd) { + case '/clear': { + if (session) { + if (activeProcesses.has(sessionId)) { + const entry = activeProcesses.get(sessionId); + try { process.kill(entry.pid, 'SIGTERM'); } catch {} + if (entry.tailer) entry.tailer.stop(); + activeProcesses.delete(sessionId); + cleanRunDir(sessionId); + } + session.messages = []; + session.claudeSessionId = null; + session.updated = new Date().toISOString(); + saveSession(session); + wsSend(ws, { type: 'session_info', sessionId: session.id, messages: [], title: session.title }); + } + wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' }); + break; + } + + case '/model': { + const modelInput = parts[1]; + if (!modelInput) { + const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)'; + wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` }); + } else { + const modelKey = modelInput.toLowerCase(); + if (!MODEL_MAP[modelKey]) { + wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` }); + } else { + const model = MODEL_MAP[modelKey]; + if (session) { + session.model = model; + session.updated = new Date().toISOString(); + saveSession(session); + } + wsSend(ws, { type: 'model_changed', model: modelKey }); + wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` }); + } + } + break; + } + + case '/cost': { + const cost = session?.totalCost || 0; + wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` }); + break; + } + + case '/compact': { + if (session) { + session.claudeSessionId = null; + session.updated = new Date().toISOString(); + saveSession(session); + } + wsSend(ws, { type: 'system_message', message: '上下文已压缩(Claude 会话 ID 已重置,下次发送将开始新的 Claude 会话,但聊天记录保留)。' }); + break; + } + + case '/mode': { + const modeInput = parts[1]; + const VALID_MODES = ['default', 'plan', 'yolo']; + const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' }; + if (!modeInput) { + const cur = session?.permissionMode || 'yolo'; + wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` }); + } else if (VALID_MODES.includes(modeInput.toLowerCase())) { + const mode = modeInput.toLowerCase(); + if (session) { + session.permissionMode = mode; + session.claudeSessionId = null; + session.updated = new Date().toISOString(); + saveSession(session); + } + wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` }); + wsSend(ws, { type: 'mode_changed', mode }); + } else { + wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` }); + } + break; + } + + case '/help': { + wsSend(ws, { + type: 'system_message', + message: '可用指令:\n' + + '/clear — 清除当前会话(含上下文)\n' + + '/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n' + + '/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' + + '/cost — 查看当前会话累计费用\n' + + '/compact — 压缩上下文(重置 Claude 会话但保留聊天记录)\n' + + '/help — 显示本帮助', + }); + break; + } + + default: + wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` }); + } +} + +// === Session Handlers === +function handleNewSession(ws) { + const id = crypto.randomUUID(); + const session = { + id, + title: 'New Chat', + created: new Date().toISOString(), + updated: new Date().toISOString(), + claudeSessionId: null, + model: null, + permissionMode: 'yolo', + totalCost: 0, + messages: [], + }; + saveSession(session); + wsSessionMap.set(ws, id); + wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null }); + sendSessionList(ws); +} + +function handleLoadSession(ws, sessionId) { + const session = loadSession(sessionId); + if (!session) { + return wsSend(ws, { type: 'error', message: 'Session not found' }); + } + + // Detach ws from any previous session's process + for (const [, entry] of activeProcesses) { + if (entry.ws === ws) entry.ws = null; + } + + wsSessionMap.set(ws, sessionId); + + // Read and clear unread flag + const hadUnread = !!session.hasUnread; + if (session.hasUnread) { + session.hasUnread = false; + saveSession(session); + } + + wsSend(ws, { + type: 'session_info', + sessionId: session.id, + messages: session.messages, + title: session.title, + mode: session.permissionMode || 'yolo', + model: modelShortName(session.model), + hasUnread: hadUnread, + }); + + // Resume streaming if process is still active + if (activeProcesses.has(sessionId)) { + const entry = activeProcesses.get(sessionId); + entry.ws = ws; + entry.wsDisconnectTime = null; // clear disconnect marker + plog('INFO', 'ws_resume_attach', { + sessionId: sessionId.slice(0, 8), + pid: entry.pid, + responseLen: (entry.fullText || '').length, + }); + wsSend(ws, { + type: 'resume_generating', + sessionId, + text: entry.fullText || '', + toolCalls: entry.toolCalls || [], + }); + } +} + +function handleDeleteSession(ws, sessionId) { + if (activeProcesses.has(sessionId)) { + const entry = activeProcesses.get(sessionId); + try { process.kill(entry.pid, 'SIGTERM'); } catch {} + if (entry.tailer) entry.tailer.stop(); + activeProcesses.delete(sessionId); + if (entry.ws) wsSend(entry.ws, { type: 'done', sessionId }); + } + cleanRunDir(sessionId); + try { + const p = sessionPath(sessionId); + if (fs.existsSync(p)) fs.unlinkSync(p); + sendSessionList(ws); + } catch { + wsSend(ws, { type: 'error', message: 'Failed to delete session' }); + } +} + +function handleRenameSession(ws, sessionId, title) { + if (!sessionId || !title) return; + const session = loadSession(sessionId); + if (session) { + session.title = String(title).slice(0, 100); + session.updated = new Date().toISOString(); + saveSession(session); + sendSessionList(ws); + wsSend(ws, { type: 'session_renamed', sessionId, title: session.title }); + } +} + +function handleSetMode(ws, sessionId, mode) { + const VALID_MODES = ['default', 'plan', 'yolo']; + if (!mode || !VALID_MODES.includes(mode)) return; + if (sessionId) { + const session = loadSession(sessionId); + if (session) { + session.permissionMode = mode; + session.claudeSessionId = null; + session.updated = new Date().toISOString(); + saveSession(session); + } + } + wsSend(ws, { type: 'mode_changed', mode }); +} + +function handleDisconnect(ws, wsId) { + const affectedSessions = []; + for (const [sid, entry] of activeProcesses) { + if (entry.ws === ws) { + entry.ws = null; + entry.wsDisconnectTime = new Date().toISOString(); + affectedSessions.push({ sessionId: sid.slice(0, 8), pid: entry.pid }); + } + } + wsSessionMap.delete(ws); + plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions }); +} + +function handleAbort(ws) { + const sessionId = wsSessionMap.get(ws); + if (!sessionId) return; + const entry = activeProcesses.get(sessionId); + if (!entry) return; + + plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid }); + try { process.kill(entry.pid, 'SIGTERM'); } catch {} + setTimeout(() => { + try { process.kill(entry.pid, 'SIGKILL'); } catch {} + }, 3000); + // handleProcessComplete will be triggered by the PID monitor +} + +// === Claude Message Handler === +function handleMessage(ws, msg) { + const { text, sessionId, mode } = msg; + if (!text || !text.trim()) return; + + if (sessionId && activeProcesses.has(sessionId)) { + return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' }); + } + + let session; + if (sessionId) session = loadSession(sessionId); + if (!session) { + const id = crypto.randomUUID(); + session = { + id, + title: text.slice(0, 60).replace(/\n/g, ' '), + created: new Date().toISOString(), + updated: new Date().toISOString(), + claudeSessionId: null, + model: null, + permissionMode: mode || 'yolo', + totalCost: 0, + messages: [], + }; + } + + if (mode && ['default', 'plan', 'yolo'].includes(mode)) { + session.permissionMode = mode; + } + + if (session.title === 'New Chat' || session.title === 'Untitled') { + session.title = text.slice(0, 60).replace(/\n/g, ' '); + } + + session.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() }); + session.updated = new Date().toISOString(); + saveSession(session); + + const currentSessionId = session.id; + + for (const [, entry] of activeProcesses) { + if (entry.ws === ws) entry.ws = null; + } + wsSessionMap.set(ws, currentSessionId); + + if (!sessionId) { + wsSend(ws, { type: 'session_info', sessionId: currentSessionId, messages: session.messages, title: session.title, mode: session.permissionMode || 'yolo', model: modelShortName(session.model) }); + } + sendSessionList(ws); + + // Build claude args + const args = ['-p', '--output-format', 'stream-json', '--verbose']; + 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) { + args.push('--model', session.model); + } + + const env = { ...process.env }; + delete env.CLAUDECODE; + delete env.CLAUDE_CODE; + delete env.CC_WEB_PASSWORD; + + // === Detached process with file-based I/O === + const dir = runDir(currentSessionId); + fs.mkdirSync(dir, { recursive: true }); + + const inputPath = path.join(dir, 'input.txt'); + const outputPath = path.join(dir, 'output.jsonl'); + const errorPath = path.join(dir, 'error.log'); + + fs.writeFileSync(inputPath, text); + + const inputFd = fs.openSync(inputPath, 'r'); + const outputFd = fs.openSync(outputPath, 'w'); + const errorFd = fs.openSync(errorPath, 'w'); + + let proc; + try { + proc = spawn(CLAUDE_PATH, args, { + env, + cwd: process.env.HOME || process.cwd(), + stdio: [inputFd, outputFd, errorFd], + detached: true, + }); + } catch (err) { + fs.closeSync(inputFd); + fs.closeSync(outputFd); + fs.closeSync(errorFd); + cleanRunDir(currentSessionId); + plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message }); + return wsSend(ws, { type: 'error', message: `启动 Claude 失败: ${err.message}` }); + } + + fs.closeSync(inputFd); + fs.closeSync(outputFd); + fs.closeSync(errorFd); + + fs.writeFileSync(path.join(dir, 'pid'), String(proc.pid)); + proc.unref(); // Process survives Node.js exit + + plog('INFO', 'process_spawn', { + sessionId: currentSessionId.slice(0, 8), + pid: proc.pid, + mode: permMode, + model: session.model || 'default', + resume: !!session.claudeSessionId, + args: args.join(' '), + }); + + // Fast exit detection (while Node.js is running) + proc.on('exit', (code, signal) => { + plog('INFO', 'process_exit_event', { + sessionId: currentSessionId.slice(0, 8), + pid: proc.pid, + exitCode: code, + signal: signal, + }); + // Small delay to ensure file is fully flushed + setTimeout(() => handleProcessComplete(currentSessionId, code, signal), 300); + }); + + const entry = { pid: proc.pid, ws, fullText: '', toolCalls: [], lastCost: null, tailer: null }; + activeProcesses.set(currentSessionId, entry); + + // Tail the output file for real-time streaming + entry.tailer = new FileTailer(outputPath, (line) => { + try { + const event = JSON.parse(line); + processClaudeEvent(entry, event, currentSessionId); + } catch {} + }); + entry.tailer.start(); +} + +// === Claude Event Processing === +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 tc = { name: block.name, id: block.id, input: truncateObj(block.input, 500), 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 truncateObj(obj, maxLen) { + const s = JSON.stringify(obj); + if (s.length <= maxLen) return obj; + return s.slice(0, maxLen) + '...'; +} + +// === Startup === +recoverProcesses(); + +// Periodic heartbeat: log active processes status every 60s +setInterval(() => { + if (activeProcesses.size === 0) return; + const procs = []; + for (const [sid, entry] of activeProcesses) { + const alive = isProcessRunning(entry.pid); + procs.push({ + sessionId: sid.slice(0, 8), + pid: entry.pid, + alive, + wsConnected: !!entry.ws, + wsDisconnectTime: entry.wsDisconnectTime || null, + responseLen: (entry.fullText || '').length, + }); + } + plog('INFO', 'heartbeat', { activeCount: procs.length, wsClients: wss.clients.size, processes: procs }); +}, 60000); + +plog('INFO', 'server_start', { port: PORT }); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`CC-Web server listening on 127.0.0.1:${PORT}`); +});