deepccc 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `deepccc` 是一个轻量级本地编程 Agent,针对 DeepSeek 使用体验做了优化,同时支持其他 OpenAI-compatible 模型接口。
4
4
 
5
- 它提供交互式命令行、JSONL 流式输出、本地文件工具、命令执行、项目提示词自动注入和持久化上下文。
5
+ 它提供本地 Web UI、交互式命令行、JSONL 流式输出、本地文件工具、命令执行、项目提示词自动注入和持久化上下文。
6
6
 
7
7
  ## 当前状态
8
8
 
@@ -91,14 +91,33 @@ $env:DEEPCCC_STREAMING="true"
91
91
  "email": "20184052+wzj998@users.noreply.github.com"
92
92
  }
93
93
  },
94
- "rawStreamLogs": {
94
+ "rawStreamLogs": {
95
95
  "enabled": true,
96
96
  "maxBytesPerTurn": 1048576,
97
97
  "retentionDays": 7,
98
- "keepCompleted": false
99
- }
100
- }
101
- ```
98
+ "keepCompleted": false
99
+ },
100
+ "web": {
101
+ "port": 28080,
102
+ "openOnStart": true
103
+ }
104
+ }
105
+ ```
106
+
107
+ ## Web UI
108
+
109
+ 全局安装后可以直接启动本地网页版:
110
+
111
+ ```bash
112
+ deepccc web
113
+ ```
114
+
115
+ 默认只监听 `http://127.0.0.1:28080/`,不会暴露到局域网。可在
116
+ `~/.deepccc/config.json` 的 `web.port` 修改端口,`web.openOnStart` 控制启动时是否自动打开浏览器;也可以临时使用 `deepccc web --port 28081 --no-open`。
117
+
118
+ Web UI 支持新建、恢复、重命名和删除多会话,多个会话可以同时运行,即使它们指向同一个工作目录。每个会话可独立选择 model、subModel 和 effort,并持续复用 CLI 已保存的历史。注意:当前版本不自动创建 Git worktree;同目录的多个运行中 Agent 直接修改同一组文件,页面会提示覆盖与冲突风险。
119
+
120
+ API 设置采用单一 Provider 配置,支持 OpenAI-compatible 与 Anthropic Messages。完整 API Key 只保存在本机 `~/.deepccc/config.json`,浏览器读取设置时仅返回掩码。高风险命令会在网页中暂停并请求“拒绝、允许一次、本会话允许、永久允许”,浏览器断开或审批超时默认拒绝。
102
121
 
103
122
  `git.coAuthor.enabled` 默认开启。DeepCCC 通过 `run_command` 创建 Git 提交时会保留用户为
104
123
  主 Author,并追加 `Co-authored-by: DeepCCC <20184052+wzj998@users.noreply.github.com>`。
package/dist/cli.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * node bin/deepccc.mjs
6
6
  * node bin/deepccc.mjs --model deepseek-v4-pro
7
7
  * node bin/deepccc.mjs --stream-json --prompt "hello"
8
+ * node bin/deepccc.mjs web
8
9
  *
9
10
  * 交互模式(TTY)下,单轮回复渲染为固定"过程区块":状态行 + 折叠工具行 +
10
11
  * 原地更新正文,不再滚屏刷 JSON;完成/停止/异常后定型留在屏幕上。
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * node bin/deepccc.mjs
6
6
  * node bin/deepccc.mjs --model deepseek-v4-pro
7
7
  * node bin/deepccc.mjs --stream-json --prompt "hello"
8
+ * node bin/deepccc.mjs web
8
9
  *
9
10
  * 交互模式(TTY)下,单轮回复渲染为固定"过程区块":状态行 + 折叠工具行 +
10
11
  * 原地更新正文,不再滚屏刷 JSON;完成/停止/异常后定型留在屏幕上。
@@ -145,6 +146,7 @@ function printHelp(appConfig) {
145
146
  " --stream-json One-shot mode: write JSONL events to stdout",
146
147
  " --prompt <text> Prompt text for --stream-json",
147
148
  " --plain Force plain streaming output (no progress block renderer)",
149
+ " web Start the local Web UI (default http://127.0.0.1:28080)",
148
150
  " --dangerously-bypass-permissions Skip all permission prompts (aligns with chatccc's bypass mode)",
149
151
  " --help, -h Show help",
150
152
  "",
@@ -608,6 +610,21 @@ function runSkillCreate(argv) {
608
610
  console.log("hot reload: 下一次对话自动生效,无需重启");
609
611
  }
610
612
  async function main() {
613
+ if (process.argv[2] === "web") {
614
+ const webArgs = process.argv.slice(3);
615
+ const portIndex = webArgs.indexOf("--port");
616
+ const port = portIndex >= 0 && webArgs[portIndex + 1] ? Number(webArgs[portIndex + 1]) : undefined;
617
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) {
618
+ throw new Error("--port must be an integer between 1 and 65535");
619
+ }
620
+ const { startDeepCccWebServer } = await import("./web-server.js");
621
+ const handle = await startDeepCccWebServer({
622
+ ...(port ? { port } : {}),
623
+ ...(webArgs.includes("--no-open") ? { openBrowser: false } : {}),
624
+ });
625
+ console.log(`DeepCCC Web UI: ${handle.url}${handle.reused ? " (reused existing server)" : ""}`);
626
+ return;
627
+ }
611
628
  // skill create 子命令:deepccc skill create <name> [--scope global|project] [--description "..."]
612
629
  // 默认创建在全局 ~/.deepccc/skills(Codex 目录结构),--scope project 创建到 <cwd>/.deepccc/skills。
613
630
  if (process.argv[2] === "skill" && process.argv[3] === "create") {
package/dist/config.d.ts CHANGED
@@ -34,13 +34,25 @@ export interface DeepCccConfig {
34
34
  retentionDays: number;
35
35
  keepCompleted: boolean;
36
36
  };
37
+ web: {
38
+ port: number;
39
+ openOnStart: boolean;
40
+ };
37
41
  }
38
42
  export declare const DEEPCCC_HOME: string;
39
43
  export declare const RAW_STREAM_LOGS_DIR: string;
44
+ export declare const CONFIG_PATH: string;
40
45
  /**
41
46
  * 默认配置(不读环境/文件)。导出供测试断言默认值;运行时请用 loadConfig 结果。
42
47
  */
43
48
  export declare const DEFAULT_CONFIG: DeepCccConfig;
49
+ export declare function readConfigFile(): Partial<DeepCccConfig>;
44
50
  export declare function normalizeDeepCccProvider(value: unknown): DeepCccProvider;
51
+ export declare function loadConfig(): DeepCccConfig;
45
52
  export declare function ensureConfigDir(): void;
53
+ export type DeepCccConfigPatch = Partial<Pick<DeepCccConfig, "provider" | "apiKey" | "baseURL" | "model" | "subModel" | "effort" | "maxOutputTokens" | "streaming" | "contextWindow">> & {
54
+ web?: Partial<DeepCccConfig["web"]>;
55
+ };
56
+ /** Persist only user-editable Web fields while preserving advanced configuration. */
57
+ export declare function saveConfigPatch(patch: DeepCccConfigPatch): DeepCccConfig;
46
58
  export declare const config: DeepCccConfig;
package/dist/config.js CHANGED
@@ -1,9 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  export const DEEPCCC_HOME = join(homedir(), ".deepccc");
5
5
  export const RAW_STREAM_LOGS_DIR = join(DEEPCCC_HOME, "raw-stream-logs");
6
- const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
6
+ export const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
7
7
  /**
8
8
  * 默认配置(不读环境/文件)。导出供测试断言默认值;运行时请用 loadConfig 结果。
9
9
  */
@@ -31,8 +31,12 @@ export const DEFAULT_CONFIG = {
31
31
  retentionDays: 7,
32
32
  keepCompleted: false,
33
33
  },
34
+ web: {
35
+ port: 28_080,
36
+ openOnStart: true,
37
+ },
34
38
  };
35
- function readConfigFile() {
39
+ export function readConfigFile() {
36
40
  if (!existsSync(CONFIG_PATH))
37
41
  return {};
38
42
  const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
@@ -70,13 +74,14 @@ export function normalizeDeepCccProvider(value) {
70
74
  return normalized;
71
75
  throw new Error(`DEEPCCC_PROVIDER/provider must be "openai" or "anthropic", received: ${String(value)}`);
72
76
  }
73
- function loadConfig() {
77
+ export function loadConfig() {
74
78
  const file = readConfigFile();
75
79
  const rawLogs = file.rawStreamLogs && typeof file.rawStreamLogs === "object"
76
80
  ? file.rawStreamLogs
77
81
  : {};
78
82
  const git = file.git && typeof file.git === "object" ? file.git : {};
79
83
  const coAuthor = git.coAuthor && typeof git.coAuthor === "object" ? git.coAuthor : {};
84
+ const web = file.web && typeof file.web === "object" ? file.web : {};
80
85
  return {
81
86
  provider: normalizeDeepCccProvider(env("DEEPCCC_PROVIDER") ?? file.provider ?? DEFAULT_CONFIG.provider),
82
87
  apiKey: env("DEEPCCC_API_KEY") ?? env("DEEPSEEK_API_KEY") ?? file.apiKey ?? DEFAULT_CONFIG.apiKey,
@@ -101,9 +106,49 @@ function loadConfig() {
101
106
  retentionDays: numberEnv("DEEPCCC_RAW_STREAM_RETENTION_DAYS") ?? rawLogs.retentionDays ?? DEFAULT_CONFIG.rawStreamLogs.retentionDays,
102
107
  keepCompleted: boolEnv("DEEPCCC_RAW_STREAM_KEEP_COMPLETED") ?? rawLogs.keepCompleted ?? DEFAULT_CONFIG.rawStreamLogs.keepCompleted,
103
108
  },
109
+ web: {
110
+ port: normalizeWebPort(web.port),
111
+ openOnStart: web.openOnStart ?? DEFAULT_CONFIG.web.openOnStart,
112
+ },
104
113
  };
105
114
  }
106
115
  export function ensureConfigDir() {
107
116
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
108
117
  }
118
+ /** Persist only user-editable Web fields while preserving advanced configuration. */
119
+ export function saveConfigPatch(patch) {
120
+ const existing = readConfigFile();
121
+ const { web: webPatch, ...flatPatch } = patch;
122
+ const next = {
123
+ ...existing,
124
+ ...flatPatch,
125
+ ...(webPatch ? { web: { ...DEFAULT_CONFIG.web, ...(existing.web ?? {}), ...webPatch } } : {}),
126
+ };
127
+ if (patch.provider !== undefined)
128
+ next.provider = normalizeDeepCccProvider(patch.provider);
129
+ if (patch.baseURL !== undefined && !patch.baseURL.trim())
130
+ throw new Error("baseURL must not be empty");
131
+ if (patch.model !== undefined && !patch.model.trim())
132
+ throw new Error("model must not be empty");
133
+ if (patch.effort !== undefined && !["", "none", "minimal", "low", "medium", "high", "xhigh", "max"].includes(patch.effort)) {
134
+ throw new Error(`Unsupported effort: ${patch.effort}`);
135
+ }
136
+ if (patch.contextWindow !== undefined && (!Number.isInteger(patch.contextWindow) || patch.contextWindow <= 0)) {
137
+ throw new Error("contextWindow must be a positive integer");
138
+ }
139
+ if (webPatch?.port !== undefined) {
140
+ next.web = { ...(next.web ?? DEFAULT_CONFIG.web), port: normalizeWebPort(webPatch.port) };
141
+ }
142
+ ensureConfigDir();
143
+ const tempPath = `${CONFIG_PATH}.${process.pid}.tmp`;
144
+ writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
145
+ renameSync(tempPath, CONFIG_PATH);
146
+ return loadConfig();
147
+ }
148
+ function normalizeWebPort(value) {
149
+ const parsed = Number(value ?? DEFAULT_CONFIG.web.port);
150
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535)
151
+ return DEFAULT_CONFIG.web.port;
152
+ return parsed;
153
+ }
109
154
  export const config = loadConfig();
package/dist/context.d.ts CHANGED
@@ -66,6 +66,7 @@ export declare function normalizeBuiltinSessionId(value: string): string;
66
66
  export declare function defaultBuiltinSessionId(cwd?: string): string;
67
67
  export declare function newBuiltinSessionId(now?: Date, suffix?: string): string;
68
68
  export declare function getBuiltinContextSession(sessionId: string, contextDir?: string): BuiltinContextSessionInfo | null;
69
+ export declare function readBuiltinContextState(sessionId: string, contextDir?: string): BuiltinContextState | null;
69
70
  export declare function listBuiltinContextSessions(contextDir?: string): BuiltinContextSessionInfo[];
70
71
  export declare function latestBuiltinSessionForCwd(cwd: string, contextDir?: string): BuiltinContextSessionInfo | null;
71
72
  /**
package/dist/context.js CHANGED
@@ -135,6 +135,18 @@ function readSessionInfo(contextDir, sessionId) {
135
135
  export function getBuiltinContextSession(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
136
136
  return readSessionInfo(contextDir, sessionId);
137
137
  }
138
+ export function readBuiltinContextState(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
139
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
140
+ const filePath = contextFilePath(contextDir, normalizedSessionId);
141
+ if (!existsSync(filePath))
142
+ return null;
143
+ try {
144
+ return normalizeState(JSON.parse(readFileSync(filePath, "utf8")), normalizedSessionId);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
138
150
  export function listBuiltinContextSessions(contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
139
151
  if (!existsSync(contextDir))
140
152
  return [];
package/dist/index.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface ChatSessionConfig {
39
39
  effort?: string;
40
40
  /** Maximum output tokens for the main conversation; unset uses the Provider default. */
41
41
  maxOutputTokens?: number;
42
+ /** Override whether the main conversation uses streaming requests. */
43
+ streaming?: boolean;
42
44
  }
43
45
  export interface ChatSessionOptions {
44
46
  /** Session working directory. */
@@ -141,6 +143,7 @@ export declare class ChatSession {
141
143
  private maxSteps?;
142
144
  private effort;
143
145
  private maxOutputTokens?;
146
+ private streaming;
144
147
  private permissionMode;
145
148
  private permissionResolver?;
146
149
  private permissionGate;
package/dist/index.js CHANGED
@@ -249,6 +249,7 @@ export class ChatSession {
249
249
  maxSteps;
250
250
  effort;
251
251
  maxOutputTokens;
252
+ streaming;
252
253
  permissionMode;
253
254
  permissionResolver;
254
255
  permissionGate;
@@ -316,6 +317,7 @@ export class ChatSession {
316
317
  this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
317
318
  this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
318
319
  this.maxOutputTokens = normalizeMaxOutputTokens(overrides.maxOutputTokens ?? appConfig.maxOutputTokens);
320
+ this.streaming = overrides.streaming ?? appConfig.streaming;
319
321
  this.apiKey = apiKey;
320
322
  this.baseURL = baseURL;
321
323
  const provider = this.provider === "anthropic"
@@ -470,7 +472,7 @@ export class ChatSession {
470
472
  messages: attemptMessages,
471
473
  };
472
474
  let stream;
473
- if (appConfig.streaming) {
475
+ if (this.streaming) {
474
476
  const result = streamText(generationOptions);
475
477
  stream = result.fullStream ?? textStreamToFullStream(result.textStream);
476
478
  }
@@ -0,0 +1 @@
1
+ export declare const DEEPCCC_WEB_PAGE = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>DeepCCC</title>\n<style>\n:root{color-scheme:dark;--bg:#0c0e13;--panel:#13161d;--panel2:#181c25;--soft:#202530;--line:#2a303d;--text:#f1f3f8;--muted:#929aaa;--accent:#8b7cf6;--accent2:#5ee4b1;--danger:#ff6f89;--warning:#f2b84b;--shadow:0 24px 70px rgba(0,0,0,.36)}\n*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(circle at 75% -15%,rgba(112,87,246,.17),transparent 35%),var(--bg);color:var(--text);font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Microsoft YaHei\",sans-serif;overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer}.app{height:100%;display:grid;grid-template-columns:286px minmax(0,1fr)}\n.sidebar{min-width:0;background:rgba(18,21,28,.94);border-right:1px solid var(--line);display:flex;flex-direction:column}.brand{height:70px;display:flex;align-items:center;gap:12px;padding:0 18px;border-bottom:1px solid var(--line)}.mark{width:36px;height:36px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(135deg,#7f6df4,#b25bdb);box-shadow:0 9px 25px rgba(127,109,244,.32);font-weight:900}.brand-copy strong{display:block;font-size:16px;letter-spacing:.01em}.brand-copy span{font-size:10px;color:var(--muted);letter-spacing:.13em}.sidebar-actions{display:flex;gap:8px;padding:14px}.primary,.ghost,.icon{border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--panel2);font-weight:700}.primary{background:linear-gradient(135deg,#7666eb,#9a67e8);border:0;box-shadow:0 8px 22px rgba(119,102,235,.25)}.sidebar-actions .primary{flex:1;padding:10px}.icon{width:40px;padding:0;font-size:17px}.ghost{padding:9px 12px}.primary:hover,.ghost:hover,.icon:hover{filter:brightness(1.1)}\n.session-list{flex:1;overflow:auto;padding:0 10px 14px}.session-empty{padding:38px 18px;color:var(--muted);text-align:center}.session-item{width:100%;display:block;padding:12px;border:1px solid transparent;border-radius:12px;background:transparent;color:var(--text);text-align:left;margin:3px 0}.session-item:hover{background:var(--panel2)}.session-item.active{background:linear-gradient(135deg,rgba(124,105,241,.2),rgba(93,74,207,.08));border-color:rgba(139,124,246,.35)}.session-title{display:flex;align-items:center;gap:8px;font-weight:750}.status-dot{width:7px;height:7px;border-radius:50%;background:#626a78}.status-dot.running{background:var(--accent2);box-shadow:0 0 0 4px rgba(94,228,177,.1)}.session-meta{margin-top:5px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-foot{padding:12px 16px;border-top:1px solid var(--line);color:var(--muted);font-size:10px;display:flex;justify-content:space-between}\n.main{min-width:0;height:100%;display:flex;flex-direction:column}.topbar{height:70px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 22px;border-bottom:1px solid var(--line);background:rgba(12,14,19,.7);backdrop-filter:blur(18px)}.session-heading{min-width:0}.session-heading h1{font-size:16px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.session-heading p{font-size:11px;color:var(--muted);margin:3px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.top-actions{display:flex;gap:8px}.top-actions button{height:38px}.content{flex:1;min-height:0;overflow-y:auto;scroll-behavior:smooth}.welcome{height:100%;display:grid;place-items:center;padding:30px}.welcome-card{max-width:650px;text-align:center}.welcome-mark{width:72px;height:72px;margin:0 auto 20px;border-radius:24px;display:grid;place-items:center;background:linear-gradient(135deg,#7764eb,#b45fd7);font-size:29px;box-shadow:0 20px 50px rgba(119,100,235,.28)}.welcome h2{font-size:28px;margin:0 0 10px;letter-spacing:-.02em}.welcome p{color:var(--muted);margin:0 auto;max-width:480px}.hint-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:28px}.hint{padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(24,28,37,.65);font-size:12px;color:var(--muted)}\n.thread{width:min(900px,calc(100% - 36px));margin:0 auto;padding:28px 0 150px}.empty-thread{margin:80px auto 0;max-width:520px;padding:28px;border:1px dashed #353c4a;border-radius:18px;background:rgba(20,23,31,.55);text-align:center}.empty-thread strong{display:block;font-size:18px;margin-bottom:7px}.empty-thread span{color:var(--muted);font-size:12px}.message{display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px;margin:0 0 22px}.avatar{width:30px;height:30px;border-radius:10px;display:grid;place-items:center;background:var(--soft);font-size:11px;font-weight:900}.message.user .avatar{background:#304058}.message.assistant .avatar{background:linear-gradient(135deg,#7363e8,#9a61dc)}.bubble{min-width:0;padding:2px 0;color:#dfe3eb}.bubble .role{font-size:11px;color:var(--muted);font-weight:800;margin-bottom:7px}.markdown{font-size:14px;line-height:1.75;overflow-wrap:anywhere}.markdown p{margin:.35em 0}.markdown pre{padding:14px;border:1px solid var(--line);border-radius:11px;background:#0a0c11;overflow:auto;font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace}.markdown code{padding:2px 5px;border-radius:5px;background:var(--soft);font:12px ui-monospace,SFMono-Regular,Consolas,monospace}.markdown pre code{padding:0;background:transparent}.tool-card{margin:8px 0;border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:hidden}.tool-card summary{padding:9px 11px;color:#bbc2d0;font-size:12px;font-weight:700}.tool-card pre{margin:0;border:0;border-top:1px solid var(--line);border-radius:0;max-height:260px}.run-state{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;margin:8px 0 20px}.pulse{width:8px;height:8px;border-radius:50%;background:var(--accent2);animation:pulse 1.4s infinite}@keyframes pulse{50%{opacity:.35;transform:scale(.72)}}.workspace-warning{width:min(900px,calc(100% - 36px));margin:14px auto 0;padding:9px 12px;border:1px solid rgba(242,184,75,.3);background:rgba(242,184,75,.08);border-radius:10px;color:#e8c879;font-size:11px}\n.composer-shell{position:absolute;left:286px;right:0;bottom:0;padding:0 20px 18px;background:linear-gradient(transparent,var(--bg) 32%)}.composer{width:min(900px,100%);margin:auto;border:1px solid #343b49;border-radius:17px;background:rgba(25,29,38,.96);box-shadow:var(--shadow);overflow:hidden}.composer textarea{width:100%;min-height:68px;max-height:180px;resize:none;border:0;outline:0;padding:15px 16px 8px;background:transparent;color:var(--text)}.composer textarea::placeholder{color:#737b8b}.composer-bar{display:flex;align-items:center;gap:8px;padding:7px 9px}.composer-bar input,.composer-bar select{min-width:0;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--muted);padding:7px 9px;font-size:11px}.composer-bar input{width:180px}.composer-bar select{width:104px}.send{margin-left:auto;width:36px;height:36px;border:0;border-radius:10px;background:var(--accent);color:#fff;font-size:17px}.stop{margin-left:auto;height:36px;border:1px solid rgba(255,111,137,.4);border-radius:10px;background:rgba(255,111,137,.12);color:#ff91a5;padding:0 13px;font-weight:700}.composer-disabled{opacity:.5;pointer-events:none}\n.modal-backdrop{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:20px;background:rgba(4,5,8,.66);backdrop-filter:blur(7px)}.modal-backdrop.hidden{display:none}.modal{width:min(560px,100%);max-height:calc(100vh - 40px);overflow:auto;border:1px solid #353c4a;border-radius:18px;background:#171a22;box-shadow:0 30px 100px rgba(0,0,0,.55);padding:22px}.modal h2{margin:0 0 5px;font-size:20px}.modal>.subtitle{margin:0 0 20px;color:var(--muted);font-size:12px}.field{display:grid;gap:7px;margin:13px 0}.field label{font-size:11px;color:#b7becb;font-weight:800}.field input,.field select{width:100%;border:1px solid var(--line);border-radius:10px;background:#10131a;color:var(--text);padding:10px 11px;outline:none}.field input:focus,.field select:focus{border-color:var(--accent)}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.check{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.danger{border:1px solid rgba(255,111,137,.35);background:rgba(255,111,137,.1);color:#ff849b;border-radius:10px;padding:9px 12px;font-weight:700}.approval{width:min(620px,100%)}.approval-badge{display:inline-flex;padding:5px 8px;border-radius:999px;background:rgba(242,184,75,.13);color:#f1c565;font-size:10px;font-weight:900}.approval pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:13px;border:1px solid var(--line);border-radius:10px;background:#0d0f15;color:#dce0e9}.approval-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.toast{position:fixed;right:20px;top:20px;z-index:50;max-width:420px;padding:11px 14px;border-radius:10px;background:#283143;border:1px solid #3b465b;box-shadow:var(--shadow)}.toast.error{background:#602536;border-color:#913a52}.hidden{display:none!important}\n@media(max-width:760px){.app{grid-template-columns:78px minmax(0,1fr)}.brand{padding:0;justify-content:center}.brand-copy,.sidebar-actions .primary,.session-item>div:not(.session-title),.session-title span:last-child,.sidebar-foot{display:none}.sidebar-actions{justify-content:center;padding:12px 8px}.session-item{padding:11px;display:grid;place-items:center}.composer-shell{left:78px}.hint-grid{grid-template-columns:1fr}.topbar{padding:0 14px}.thread{width:calc(100% - 24px)}.row{grid-template-columns:1fr}.approval-actions{grid-template-columns:1fr 1fr}}\n</style>\n</head>\n<body>\n<div class=\"app\">\n <aside class=\"sidebar\">\n <div class=\"brand\"><div class=\"mark\">D</div><div class=\"brand-copy\"><strong>DeepCCC</strong><span>LOCAL AGENT</span></div></div>\n <div class=\"sidebar-actions\"><button id=\"new-session\" class=\"primary\">\uFF0B \u65B0\u5EFA\u4F1A\u8BDD</button><button id=\"open-settings\" class=\"icon\" title=\"API \u8BBE\u7F6E\">\u2699</button></div>\n <div id=\"session-list\" class=\"session-list\"></div>\n <div class=\"sidebar-foot\"><span>127.0.0.1</span><span id=\"server-port\">28080</span></div>\n </aside>\n <main class=\"main\">\n <header class=\"topbar\"><div class=\"session-heading\"><h1 id=\"session-title\">DeepCCC</h1><p id=\"session-cwd\">\u9009\u62E9\u6216\u521B\u5EFA\u4E00\u4E2A\u4F1A\u8BDD</p></div><div class=\"top-actions\"><button id=\"rename-session\" class=\"ghost hidden\">\u91CD\u547D\u540D</button><button id=\"delete-session\" class=\"danger hidden\">\u5220\u9664</button></div></header>\n <div id=\"warning\" class=\"workspace-warning hidden\"></div>\n <section id=\"content\" class=\"content\"><div class=\"welcome\"><div class=\"welcome-card\"><div class=\"welcome-mark\">\u2726</div><h2>\u628A\u5DE5\u4F5C\u4EA4\u7ED9 DeepCCC</h2><p>\u5728\u6D4F\u89C8\u5668\u91CC\u7BA1\u7406\u591A\u4E2A\u672C\u5730 Coding Agent \u4F1A\u8BDD\uFF0C\u5207\u6362\u6A21\u578B\u4E0E\u63A8\u7406\u5F3A\u5EA6\uFF0C\u5E76\u5B9E\u65F6\u5BA1\u6279\u9AD8\u98CE\u9669\u64CD\u4F5C\u3002</p><div class=\"hint-grid\"><div class=\"hint\">\u591A\u4F1A\u8BDD\u5E76\u884C\u8FD0\u884C</div><div class=\"hint\">\u672C\u5730\u6587\u4EF6\u4E0E\u5386\u53F2</div><div class=\"hint\">API \u81EA\u7531\u914D\u7F6E</div></div></div></div></section>\n </main>\n</div>\n<div id=\"composer-shell\" class=\"composer-shell hidden\"><div class=\"composer\"><textarea id=\"prompt\" placeholder=\"\u63CF\u8FF0\u4F60\u60F3\u5B8C\u6210\u7684\u4EFB\u52A1\u2026\"></textarea><div class=\"composer-bar\"><input id=\"session-model\" list=\"recent-models\" aria-label=\"\u6A21\u578B\"><datalist id=\"recent-models\"></datalist><select id=\"session-effort\" aria-label=\"\u63A8\u7406\u5F3A\u5EA6\"><option value=\"\">\u9ED8\u8BA4 effort</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select><button id=\"send\" class=\"send\" title=\"\u53D1\u9001\">\u2191</button><button id=\"stop\" class=\"stop hidden\">\u25A0 \u505C\u6B62</button></div></div></div>\n\n<div id=\"new-modal\" class=\"modal-backdrop hidden\"><form id=\"new-form\" class=\"modal\"><h2>\u65B0\u5EFA Agent \u4F1A\u8BDD</h2><p class=\"subtitle\">\u6BCF\u4E2A\u4F1A\u8BDD\u62E5\u6709\u72EC\u7ACB\u4E0A\u4E0B\u6587\uFF1B\u540C\u76EE\u5F55\u4F1A\u8BDD\u53EF\u4EE5\u5E76\u53D1\uFF0C\u4F46\u6587\u4EF6\u4FEE\u6539\u53EF\u80FD\u51B2\u7A81\u3002</p><div class=\"field\"><label>\u5DE5\u4F5C\u76EE\u5F55</label><input id=\"new-cwd\" required></div><div class=\"field\"><label>\u4F1A\u8BDD\u540D\u79F0\uFF08\u53EF\u9009\uFF09</label><input id=\"new-title\" placeholder=\"\u9ED8\u8BA4\u4F7F\u7528\u76EE\u5F55\u540D\"></div><div class=\"row\"><div class=\"field\"><label>\u6A21\u578B</label><input id=\"new-model\" list=\"recent-models\"></div><div class=\"field\"><label>Effort</label><select id=\"new-effort\"><option value=\"\">\u9ED8\u8BA4</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select></div></div><div class=\"modal-actions\"><button type=\"button\" data-close=\"new-modal\" class=\"ghost\">\u53D6\u6D88</button><button class=\"primary\" type=\"submit\">\u521B\u5EFA\u4F1A\u8BDD</button></div></form></div>\n\n<div id=\"settings-modal\" class=\"modal-backdrop hidden\"><form id=\"settings-form\" class=\"modal\"><h2>API \u4E0E Web \u8BBE\u7F6E</h2><p class=\"subtitle\">\u5355\u4E00 API \u914D\u7F6E\u4F5C\u4E3A\u6240\u6709\u65B0\u4F1A\u8BDD\u7684\u9ED8\u8BA4\u503C\uFF1B\u4F1A\u8BDD\u53EF\u5355\u72EC\u8986\u76D6\u6A21\u578B\u548C effort\u3002</p><div class=\"row\"><div class=\"field\"><label>API \u534F\u8BAE</label><select id=\"cfg-provider\"><option value=\"openai\">OpenAI-compatible</option><option value=\"anthropic\">Anthropic Messages</option></select></div><div class=\"field\"><label>API Key</label><input id=\"cfg-api-key\" type=\"password\" placeholder=\"\u7559\u7A7A\u4FDD\u7559\u5F53\u524D Key\"></div></div><div class=\"field\"><label>Base URL</label><input id=\"cfg-base-url\" required></div><div class=\"row\"><div class=\"field\"><label>\u9ED8\u8BA4\u6A21\u578B</label><input id=\"cfg-model\" required></div><div class=\"field\"><label>\u5B50\u6A21\u578B\uFF08\u53EF\u9009\uFF09</label><input id=\"cfg-sub-model\"></div></div><div class=\"row\"><div class=\"field\"><label>\u9ED8\u8BA4 Effort</label><select id=\"cfg-effort\"><option value=\"\">\u4E0D\u4F20</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select></div><div class=\"field\"><label>Context Window</label><input id=\"cfg-context\" type=\"number\" min=\"1\"></div></div><div class=\"row\"><div class=\"field\"><label>Web \u7AEF\u53E3</label><input id=\"cfg-port\" type=\"number\" min=\"1\" max=\"65535\"></div><div class=\"field\"><label>Max Output Tokens\uFF08\u53EF\u9009\uFF09</label><input id=\"cfg-max-output\" type=\"number\" min=\"1\"></div></div><label class=\"check\"><input id=\"cfg-streaming\" type=\"checkbox\">\u542F\u7528\u6D41\u5F0F\u8BF7\u6C42</label><label class=\"check\"><input id=\"cfg-open\" type=\"checkbox\">\u8FD0\u884C deepccc web \u65F6\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668</label><div id=\"key-state\" class=\"subtitle\"></div><div class=\"modal-actions\"><button type=\"button\" data-close=\"settings-modal\" class=\"ghost\">\u53D6\u6D88</button><button class=\"primary\" type=\"submit\">\u4FDD\u5B58\u8BBE\u7F6E</button></div></form></div>\n\n<div id=\"rename-modal\" class=\"modal-backdrop hidden\"><form id=\"rename-form\" class=\"modal\"><h2>\u91CD\u547D\u540D\u4F1A\u8BDD</h2><div class=\"field\"><label>\u540D\u79F0</label><input id=\"rename-title\" required maxlength=\"120\"></div><div class=\"modal-actions\"><button type=\"button\" data-close=\"rename-modal\" class=\"ghost\">\u53D6\u6D88</button><button class=\"primary\">\u4FDD\u5B58</button></div></form></div>\n\n<div id=\"approval-modal\" class=\"modal-backdrop hidden\"><div class=\"modal approval\"><span class=\"approval-badge\">\u9700\u8981\u6388\u6743</span><h2>Agent \u8BF7\u6C42\u6267\u884C\u9AD8\u98CE\u9669\u64CD\u4F5C</h2><p id=\"approval-detail\" class=\"subtitle\"></p><pre id=\"approval-action\"></pre><div class=\"approval-actions\"><button data-answer=\"deny\" class=\"danger\">\u62D2\u7EDD</button><button data-answer=\"allow\" class=\"ghost\">\u5141\u8BB8\u4E00\u6B21</button><button data-answer=\"allow-session\" class=\"ghost\">\u672C\u4F1A\u8BDD\u5141\u8BB8</button><button data-answer=\"allow-always\" class=\"primary\">\u6C38\u4E45\u5141\u8BB8</button></div></div></div>\n<div id=\"toast\" class=\"toast hidden\"></div>\n<script>\n(function(){\n var state={sessions:[],selectedId:null,detail:null,config:null,eventSource:null,reloadTimer:null};\n var $=function(id){return document.getElementById(id);};\n async function api(path,options){var response=await fetch(path,Object.assign({headers:{'content-type':'application/json; charset=utf-8'}},options||{}));var data=await response.json().catch(function(){return {error:'\u54CD\u5E94\u4E0D\u662F\u6709\u6548 JSON'};});if(!response.ok)throw new Error(data.error||('HTTP '+response.status));return data;}\n function toast(message,error){var el=$('toast');el.textContent=message;el.className='toast'+(error?' error':'');clearTimeout(toast.timer);toast.timer=setTimeout(function(){el.className='toast hidden';},3500);}\n function openModal(id){$(id).classList.remove('hidden');}function closeModal(id){$(id).classList.add('hidden');}\n function escapeHtml(value){return String(value||'').replace(/[&<>\"']/g,function(ch){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[ch];});}\n function markdown(value){var text=escapeHtml(value);var blocks=[];var tick=String.fromCharCode(96);var fence=tick+tick+tick;var pattern=new RegExp(fence+'([a-z0-9_-]*)\\\\n([\\\\s\\\\S]*?)'+fence,'gi');text=text.replace(pattern,function(_,lang,code){blocks.push('<pre><code data-lang=\"'+lang+'\">'+code+'</code></pre>');return '@@BLOCK'+(blocks.length-1)+'@@';});text=text.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>').replace(new RegExp(tick+'([^'+tick+']+)'+tick,'g'),'<code>$1</code>').replace(/\\n/g,'<br>');text=text.replace(/@@BLOCK(\\d+)@@/g,function(_,index){return blocks[Number(index)];});return text;}\n function statusText(status){return status==='running'?'\u6267\u884C\u4E2D':'\u7A7A\u95F2';}\n function renderSessions(){var root=$('session-list');root.innerHTML='';if(!state.sessions.length){root.innerHTML='<div class=\"session-empty\">\u8FD8\u6CA1\u6709\u4F1A\u8BDD<br>\u70B9\u51FB\u4E0A\u65B9\u6309\u94AE\u5F00\u59CB</div>';return;}state.sessions.forEach(function(session){var button=document.createElement('button');button.className='session-item'+(session.sessionId===state.selectedId?' active':'');button.innerHTML='<div class=\"session-title\"><i class=\"status-dot '+session.status+'\"></i><span>'+escapeHtml(session.title)+'</span></div><div class=\"session-meta\">'+escapeHtml(session.model||'\u9ED8\u8BA4\u6A21\u578B')+' \u00B7 '+escapeHtml(session.effort||'\u9ED8\u8BA4 effort')+'</div><div class=\"session-meta\">'+escapeHtml(session.cwd)+'</div>';button.addEventListener('click',function(){selectSession(session.sessionId);});root.appendChild(button);});}\n function messageHtml(role,content){return '<article class=\"message '+role+'\"><div class=\"avatar\">'+(role==='user'?'\u4F60':'D')+'</div><div class=\"bubble\"><div class=\"role\">'+(role==='user'?'YOU':'DEEPCCC')+'</div><div class=\"markdown\">'+markdown(content)+'</div></div></article>';}\n function toolHtml(event){var data=event.data||{};var item=data.type==='agent'?data.data:data;return '<details class=\"tool-card\"><summary>'+escapeHtml(item.type==='tool_use'?'\u8C03\u7528\u5DE5\u5177 \u00B7 '+(item.name||'tool'):'\u5DE5\u5177\u7ED3\u679C \u00B7 '+(item.name||item.tool_use_id||'tool'))+'</summary><pre>'+escapeHtml(JSON.stringify(item.type==='tool_use'?item.input:item.content,null,2))+'</pre></details>';}\n function renderDetail(){var detail=state.detail;if(!detail)return;$('session-title').textContent=detail.title;$('session-cwd').textContent=detail.cwd+' \u00B7 '+statusText(detail.status);$('rename-session').classList.remove('hidden');$('delete-session').classList.remove('hidden');$('composer-shell').classList.remove('hidden');$('session-model').value=detail.model||'';$('session-effort').value=detail.effort||'';var html='<div class=\"thread\">';if(!(detail.messages||[]).length&&detail.status!=='running')html+='<div class=\"empty-thread\"><strong>\u51C6\u5907\u5F00\u59CB\u8FD9\u4E2A\u4F1A\u8BDD</strong><span>\u5728\u4E0B\u65B9\u8F93\u5165\u4EFB\u52A1\u3002DeepCCC \u4F1A\u8BFB\u53D6\u5F53\u524D\u9879\u76EE\u6307\u4EE4\u3001\u8C03\u7528\u5DE5\u5177\u5E76\u6301\u7EED\u4FDD\u5B58\u4E0A\u4E0B\u6587\u3002</span></div>';(detail.messages||[]).forEach(function(message){html+=messageHtml(message.role,message.content);});if(detail.status==='running'){var live=(detail.events||[]).slice().reverse().find(function(event){return event.type==='agent'&&event.data&&event.data.type==='text';});var current=(detail.events||[]).filter(function(event){return event.type==='agent'&&event.data&&(event.data.type==='tool_use'||event.data.type==='tool_result');});current.forEach(function(event){html+=toolHtml(event);});if(live)html+=messageHtml('assistant',live.data.accumulated||live.data.text||'');html+='<div class=\"run-state\"><i class=\"pulse\"></i>Agent \u6B63\u5728\u5DE5\u4F5C</div>';}html+='</div>';$('content').innerHTML=html;$('send').classList.toggle('hidden',detail.status==='running');$('stop').classList.toggle('hidden',detail.status!=='running');$('prompt').disabled=detail.status==='running';$('session-model').disabled=detail.status==='running';$('session-effort').disabled=detail.status==='running';renderWarning();renderApproval();requestAnimationFrame(function(){$('content').scrollTop=$('content').scrollHeight;});}\n function renderWarning(){if(!state.detail)return;var conflicts=state.sessions.filter(function(item){return item.sessionId!==state.detail.sessionId&&item.status==='running'&&item.cwd===state.detail.cwd;});var el=$('warning');if(conflicts.length){el.textContent='\u26A0 \u5F53\u524D\u76EE\u5F55\u8FD8\u6709 '+conflicts.length+' \u4E2A Agent \u4F1A\u8BDD\u6B63\u5728\u8FD0\u884C\u3002\u591A\u4E2A\u4F1A\u8BDD\u76F4\u63A5\u4FEE\u6539\u540C\u4E00\u76EE\u5F55\uFF0C\u53EF\u80FD\u4EA7\u751F\u8986\u76D6\u6216\u51B2\u7A81\u3002';el.classList.remove('hidden');}else el.classList.add('hidden');}\n function renderApproval(){var approval=state.detail&&state.detail.pendingApproval;if(!approval){closeModal('approval-modal');return;}$('approval-detail').textContent=approval.detail||('\u5DE5\u5177\uFF1A'+approval.tool);$('approval-action').textContent=approval.action;openModal('approval-modal');}\n async function loadSessions(){var result=await api('/api/sessions');state.sessions=result.sessions||[];renderSessions();if(state.selectedId&&!state.sessions.some(function(item){return item.sessionId===state.selectedId;}))state.selectedId=null;}\n async function loadDetail(){if(!state.selectedId)return;var result=await api('/api/sessions/'+encodeURIComponent(state.selectedId));state.detail=result.session;renderDetail();await loadSessions();}\n function scheduleDetail(){clearTimeout(state.reloadTimer);state.reloadTimer=setTimeout(function(){loadDetail().catch(function(error){toast(error.message,true);});},120);}\n async function selectSession(id){state.selectedId=id;renderSessions();if(state.eventSource)state.eventSource.close();state.eventSource=new EventSource('/api/sessions/'+encodeURIComponent(id)+'/events');state.eventSource.onmessage=scheduleDetail;await loadDetail();}\n async function loadConfig(){var result=await api('/api/config');state.config=result.config;$('cfg-provider').value=state.config.provider;$('cfg-base-url').value=state.config.baseURL;$('cfg-model').value=state.config.model;$('cfg-sub-model').value=state.config.subModel||'';$('cfg-effort').value=state.config.effort||'';$('cfg-context').value=state.config.contextWindow;$('cfg-max-output').value=state.config.maxOutputTokens||'';$('cfg-streaming').checked=state.config.streaming!==false;$('cfg-port').value=state.config.web.port;$('cfg-open').checked=state.config.web.openOnStart!==false;$('server-port').textContent=state.config.web.port;$('key-state').textContent=state.config.apiKeyConfigured?'\u5F53\u524D API Key\uFF1A'+state.config.apiKeyMask:'\u5C1A\u672A\u914D\u7F6E API Key';$('new-cwd').value=state.config.defaultCwd||'';$('new-model').value=state.config.model;var models=[state.config.model].concat(state.sessions.map(function(item){return item.model;})).filter(Boolean).filter(function(value,index,array){return array.indexOf(value)===index;});$('recent-models').innerHTML=models.map(function(model){return '<option value=\"'+escapeHtml(model)+'\">';}).join('');}\n $('new-session').addEventListener('click',function(){openModal('new-modal');setTimeout(function(){$('new-cwd').focus();},0);});$('open-settings').addEventListener('click',function(){loadConfig().then(function(){openModal('settings-modal');});});document.querySelectorAll('[data-close]').forEach(function(button){button.addEventListener('click',function(){closeModal(button.dataset.close);});});\n $('new-form').addEventListener('submit',async function(event){event.preventDefault();try{var result=await api('/api/sessions',{method:'POST',body:JSON.stringify({cwd:$('new-cwd').value,title:$('new-title').value,model:$('new-model').value,effort:$('new-effort').value})});closeModal('new-modal');$('new-title').value='';await loadSessions();await selectSession(result.session.sessionId);}catch(error){toast(error.message,true);}});\n $('settings-form').addEventListener('submit',async function(event){event.preventDefault();var body={provider:$('cfg-provider').value,baseURL:$('cfg-base-url').value,model:$('cfg-model').value,subModel:$('cfg-sub-model').value,effort:$('cfg-effort').value,contextWindow:Number($('cfg-context').value),streaming:$('cfg-streaming').checked,web:{port:Number($('cfg-port').value),openOnStart:$('cfg-open').checked}};if($('cfg-max-output').value)body.maxOutputTokens=Number($('cfg-max-output').value);if($('cfg-api-key').value)body.apiKey=$('cfg-api-key').value;try{await api('/api/config',{method:'PUT',body:JSON.stringify(body)});$('cfg-api-key').value='';closeModal('settings-modal');toast('\u8BBE\u7F6E\u5DF2\u4FDD\u5B58\uFF0C\u65B0\u914D\u7F6E\u4ECE\u4E0B\u4E00\u6B21\u65B0\u5EFA\u4F1A\u8BDD\u751F\u6548');await loadConfig();}catch(error){toast(error.message,true);}});\n async function send(){if(!state.selectedId||!$('prompt').value.trim())return;try{if($('session-model').value!==state.detail.model||$('session-effort').value!==state.detail.effort){await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'PATCH',body:JSON.stringify({model:$('session-model').value,effort:$('session-effort').value})});}var text=$('prompt').value;$('prompt').value='';await api('/api/sessions/'+encodeURIComponent(state.selectedId)+'/messages',{method:'POST',body:JSON.stringify({text:text})});await loadDetail();}catch(error){toast(error.message,true);}}\n $('send').addEventListener('click',send);$('prompt').addEventListener('keydown',function(event){if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send();}});$('stop').addEventListener('click',async function(){try{await api('/api/sessions/'+encodeURIComponent(state.selectedId)+'/stop',{method:'POST',body:'{}'});toast('\u5DF2\u8BF7\u6C42\u505C\u6B62');}catch(error){toast(error.message,true);}});\n $('rename-session').addEventListener('click',function(){$('rename-title').value=state.detail.title;openModal('rename-modal');});$('rename-form').addEventListener('submit',async function(event){event.preventDefault();try{await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'PATCH',body:JSON.stringify({title:$('rename-title').value})});closeModal('rename-modal');await loadDetail();}catch(error){toast(error.message,true);}});$('delete-session').addEventListener('click',async function(){if(!confirm('\u5220\u9664\u8FD9\u4E2A\u4F1A\u8BDD\u53CA\u5176\u672C\u5730\u5386\u53F2\uFF1F\u4E0D\u4F1A\u5220\u9664\u5DE5\u4F5C\u76EE\u5F55\u4E2D\u7684\u6587\u4EF6\u3002'))return;try{await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'DELETE'});if(state.eventSource)state.eventSource.close();state.selectedId=null;state.detail=null;$('composer-shell').classList.add('hidden');$('rename-session').classList.add('hidden');$('delete-session').classList.add('hidden');$('session-title').textContent='DeepCCC';$('session-cwd').textContent='\u9009\u62E9\u6216\u521B\u5EFA\u4E00\u4E2A\u4F1A\u8BDD';$('content').innerHTML='<div class=\"welcome\"><div class=\"welcome-card\"><div class=\"welcome-mark\">\u2726</div><h2>\u4F1A\u8BDD\u5DF2\u5220\u9664</h2><p>\u4ECE\u5DE6\u4FA7\u9009\u62E9\u5176\u4ED6\u4F1A\u8BDD\u6216\u521B\u5EFA\u4E00\u4E2A\u65B0\u7684\u4EFB\u52A1\u3002</p></div></div>';await loadSessions();}catch(error){toast(error.message,true);}});\n document.querySelectorAll('[data-answer]').forEach(function(button){button.addEventListener('click',async function(){var approval=state.detail&&state.detail.pendingApproval;if(!approval)return;try{await api('/api/approvals/'+encodeURIComponent(approval.approvalId),{method:'POST',body:JSON.stringify({answer:button.dataset.answer})});closeModal('approval-modal');scheduleDetail();}catch(error){toast(error.message,true);}});});\n Promise.all([loadSessions(),loadConfig()]).then(function(){if(state.sessions[0])selectSession(state.sessions[0].sessionId);}).catch(function(error){toast(error.message,true);});\n})();\n</script>\n</body></html>";
@@ -0,0 +1,74 @@
1
+ export const DEEPCCC_WEB_PAGE = `<!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>DeepCCC</title>
7
+ <style>
8
+ :root{color-scheme:dark;--bg:#0c0e13;--panel:#13161d;--panel2:#181c25;--soft:#202530;--line:#2a303d;--text:#f1f3f8;--muted:#929aaa;--accent:#8b7cf6;--accent2:#5ee4b1;--danger:#ff6f89;--warning:#f2b84b;--shadow:0 24px 70px rgba(0,0,0,.36)}
9
+ *{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(circle at 75% -15%,rgba(112,87,246,.17),transparent 35%),var(--bg);color:var(--text);font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer}.app{height:100%;display:grid;grid-template-columns:286px minmax(0,1fr)}
10
+ .sidebar{min-width:0;background:rgba(18,21,28,.94);border-right:1px solid var(--line);display:flex;flex-direction:column}.brand{height:70px;display:flex;align-items:center;gap:12px;padding:0 18px;border-bottom:1px solid var(--line)}.mark{width:36px;height:36px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(135deg,#7f6df4,#b25bdb);box-shadow:0 9px 25px rgba(127,109,244,.32);font-weight:900}.brand-copy strong{display:block;font-size:16px;letter-spacing:.01em}.brand-copy span{font-size:10px;color:var(--muted);letter-spacing:.13em}.sidebar-actions{display:flex;gap:8px;padding:14px}.primary,.ghost,.icon{border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--panel2);font-weight:700}.primary{background:linear-gradient(135deg,#7666eb,#9a67e8);border:0;box-shadow:0 8px 22px rgba(119,102,235,.25)}.sidebar-actions .primary{flex:1;padding:10px}.icon{width:40px;padding:0;font-size:17px}.ghost{padding:9px 12px}.primary:hover,.ghost:hover,.icon:hover{filter:brightness(1.1)}
11
+ .session-list{flex:1;overflow:auto;padding:0 10px 14px}.session-empty{padding:38px 18px;color:var(--muted);text-align:center}.session-item{width:100%;display:block;padding:12px;border:1px solid transparent;border-radius:12px;background:transparent;color:var(--text);text-align:left;margin:3px 0}.session-item:hover{background:var(--panel2)}.session-item.active{background:linear-gradient(135deg,rgba(124,105,241,.2),rgba(93,74,207,.08));border-color:rgba(139,124,246,.35)}.session-title{display:flex;align-items:center;gap:8px;font-weight:750}.status-dot{width:7px;height:7px;border-radius:50%;background:#626a78}.status-dot.running{background:var(--accent2);box-shadow:0 0 0 4px rgba(94,228,177,.1)}.session-meta{margin-top:5px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-foot{padding:12px 16px;border-top:1px solid var(--line);color:var(--muted);font-size:10px;display:flex;justify-content:space-between}
12
+ .main{min-width:0;height:100%;display:flex;flex-direction:column}.topbar{height:70px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 22px;border-bottom:1px solid var(--line);background:rgba(12,14,19,.7);backdrop-filter:blur(18px)}.session-heading{min-width:0}.session-heading h1{font-size:16px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.session-heading p{font-size:11px;color:var(--muted);margin:3px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.top-actions{display:flex;gap:8px}.top-actions button{height:38px}.content{flex:1;min-height:0;overflow-y:auto;scroll-behavior:smooth}.welcome{height:100%;display:grid;place-items:center;padding:30px}.welcome-card{max-width:650px;text-align:center}.welcome-mark{width:72px;height:72px;margin:0 auto 20px;border-radius:24px;display:grid;place-items:center;background:linear-gradient(135deg,#7764eb,#b45fd7);font-size:29px;box-shadow:0 20px 50px rgba(119,100,235,.28)}.welcome h2{font-size:28px;margin:0 0 10px;letter-spacing:-.02em}.welcome p{color:var(--muted);margin:0 auto;max-width:480px}.hint-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:28px}.hint{padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(24,28,37,.65);font-size:12px;color:var(--muted)}
13
+ .thread{width:min(900px,calc(100% - 36px));margin:0 auto;padding:28px 0 150px}.empty-thread{margin:80px auto 0;max-width:520px;padding:28px;border:1px dashed #353c4a;border-radius:18px;background:rgba(20,23,31,.55);text-align:center}.empty-thread strong{display:block;font-size:18px;margin-bottom:7px}.empty-thread span{color:var(--muted);font-size:12px}.message{display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px;margin:0 0 22px}.avatar{width:30px;height:30px;border-radius:10px;display:grid;place-items:center;background:var(--soft);font-size:11px;font-weight:900}.message.user .avatar{background:#304058}.message.assistant .avatar{background:linear-gradient(135deg,#7363e8,#9a61dc)}.bubble{min-width:0;padding:2px 0;color:#dfe3eb}.bubble .role{font-size:11px;color:var(--muted);font-weight:800;margin-bottom:7px}.markdown{font-size:14px;line-height:1.75;overflow-wrap:anywhere}.markdown p{margin:.35em 0}.markdown pre{padding:14px;border:1px solid var(--line);border-radius:11px;background:#0a0c11;overflow:auto;font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace}.markdown code{padding:2px 5px;border-radius:5px;background:var(--soft);font:12px ui-monospace,SFMono-Regular,Consolas,monospace}.markdown pre code{padding:0;background:transparent}.tool-card{margin:8px 0;border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:hidden}.tool-card summary{padding:9px 11px;color:#bbc2d0;font-size:12px;font-weight:700}.tool-card pre{margin:0;border:0;border-top:1px solid var(--line);border-radius:0;max-height:260px}.run-state{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;margin:8px 0 20px}.pulse{width:8px;height:8px;border-radius:50%;background:var(--accent2);animation:pulse 1.4s infinite}@keyframes pulse{50%{opacity:.35;transform:scale(.72)}}.workspace-warning{width:min(900px,calc(100% - 36px));margin:14px auto 0;padding:9px 12px;border:1px solid rgba(242,184,75,.3);background:rgba(242,184,75,.08);border-radius:10px;color:#e8c879;font-size:11px}
14
+ .composer-shell{position:absolute;left:286px;right:0;bottom:0;padding:0 20px 18px;background:linear-gradient(transparent,var(--bg) 32%)}.composer{width:min(900px,100%);margin:auto;border:1px solid #343b49;border-radius:17px;background:rgba(25,29,38,.96);box-shadow:var(--shadow);overflow:hidden}.composer textarea{width:100%;min-height:68px;max-height:180px;resize:none;border:0;outline:0;padding:15px 16px 8px;background:transparent;color:var(--text)}.composer textarea::placeholder{color:#737b8b}.composer-bar{display:flex;align-items:center;gap:8px;padding:7px 9px}.composer-bar input,.composer-bar select{min-width:0;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--muted);padding:7px 9px;font-size:11px}.composer-bar input{width:180px}.composer-bar select{width:104px}.send{margin-left:auto;width:36px;height:36px;border:0;border-radius:10px;background:var(--accent);color:#fff;font-size:17px}.stop{margin-left:auto;height:36px;border:1px solid rgba(255,111,137,.4);border-radius:10px;background:rgba(255,111,137,.12);color:#ff91a5;padding:0 13px;font-weight:700}.composer-disabled{opacity:.5;pointer-events:none}
15
+ .modal-backdrop{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:20px;background:rgba(4,5,8,.66);backdrop-filter:blur(7px)}.modal-backdrop.hidden{display:none}.modal{width:min(560px,100%);max-height:calc(100vh - 40px);overflow:auto;border:1px solid #353c4a;border-radius:18px;background:#171a22;box-shadow:0 30px 100px rgba(0,0,0,.55);padding:22px}.modal h2{margin:0 0 5px;font-size:20px}.modal>.subtitle{margin:0 0 20px;color:var(--muted);font-size:12px}.field{display:grid;gap:7px;margin:13px 0}.field label{font-size:11px;color:#b7becb;font-weight:800}.field input,.field select{width:100%;border:1px solid var(--line);border-radius:10px;background:#10131a;color:var(--text);padding:10px 11px;outline:none}.field input:focus,.field select:focus{border-color:var(--accent)}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.check{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.danger{border:1px solid rgba(255,111,137,.35);background:rgba(255,111,137,.1);color:#ff849b;border-radius:10px;padding:9px 12px;font-weight:700}.approval{width:min(620px,100%)}.approval-badge{display:inline-flex;padding:5px 8px;border-radius:999px;background:rgba(242,184,75,.13);color:#f1c565;font-size:10px;font-weight:900}.approval pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:13px;border:1px solid var(--line);border-radius:10px;background:#0d0f15;color:#dce0e9}.approval-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.toast{position:fixed;right:20px;top:20px;z-index:50;max-width:420px;padding:11px 14px;border-radius:10px;background:#283143;border:1px solid #3b465b;box-shadow:var(--shadow)}.toast.error{background:#602536;border-color:#913a52}.hidden{display:none!important}
16
+ @media(max-width:760px){.app{grid-template-columns:78px minmax(0,1fr)}.brand{padding:0;justify-content:center}.brand-copy,.sidebar-actions .primary,.session-item>div:not(.session-title),.session-title span:last-child,.sidebar-foot{display:none}.sidebar-actions{justify-content:center;padding:12px 8px}.session-item{padding:11px;display:grid;place-items:center}.composer-shell{left:78px}.hint-grid{grid-template-columns:1fr}.topbar{padding:0 14px}.thread{width:calc(100% - 24px)}.row{grid-template-columns:1fr}.approval-actions{grid-template-columns:1fr 1fr}}
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div class="app">
21
+ <aside class="sidebar">
22
+ <div class="brand"><div class="mark">D</div><div class="brand-copy"><strong>DeepCCC</strong><span>LOCAL AGENT</span></div></div>
23
+ <div class="sidebar-actions"><button id="new-session" class="primary">+ 新建会话</button><button id="open-settings" class="icon" title="API 设置">⚙</button></div>
24
+ <div id="session-list" class="session-list"></div>
25
+ <div class="sidebar-foot"><span>127.0.0.1</span><span id="server-port">28080</span></div>
26
+ </aside>
27
+ <main class="main">
28
+ <header class="topbar"><div class="session-heading"><h1 id="session-title">DeepCCC</h1><p id="session-cwd">选择或创建一个会话</p></div><div class="top-actions"><button id="rename-session" class="ghost hidden">重命名</button><button id="delete-session" class="danger hidden">删除</button></div></header>
29
+ <div id="warning" class="workspace-warning hidden"></div>
30
+ <section id="content" class="content"><div class="welcome"><div class="welcome-card"><div class="welcome-mark">✦</div><h2>把工作交给 DeepCCC</h2><p>在浏览器里管理多个本地 Coding Agent 会话,切换模型与推理强度,并实时审批高风险操作。</p><div class="hint-grid"><div class="hint">多会话并行运行</div><div class="hint">本地文件与历史</div><div class="hint">API 自由配置</div></div></div></div></section>
31
+ </main>
32
+ </div>
33
+ <div id="composer-shell" class="composer-shell hidden"><div class="composer"><textarea id="prompt" placeholder="描述你想完成的任务…"></textarea><div class="composer-bar"><input id="session-model" list="recent-models" aria-label="模型"><datalist id="recent-models"></datalist><select id="session-effort" aria-label="推理强度"><option value="">默认 effort</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select><button id="send" class="send" title="发送">↑</button><button id="stop" class="stop hidden">■ 停止</button></div></div></div>
34
+
35
+ <div id="new-modal" class="modal-backdrop hidden"><form id="new-form" class="modal"><h2>新建 Agent 会话</h2><p class="subtitle">每个会话拥有独立上下文;同目录会话可以并发,但文件修改可能冲突。</p><div class="field"><label>工作目录</label><input id="new-cwd" required></div><div class="field"><label>会话名称(可选)</label><input id="new-title" placeholder="默认使用目录名"></div><div class="row"><div class="field"><label>模型</label><input id="new-model" list="recent-models"></div><div class="field"><label>Effort</label><select id="new-effort"><option value="">默认</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select></div></div><div class="modal-actions"><button type="button" data-close="new-modal" class="ghost">取消</button><button class="primary" type="submit">创建会话</button></div></form></div>
36
+
37
+ <div id="settings-modal" class="modal-backdrop hidden"><form id="settings-form" class="modal"><h2>API 与 Web 设置</h2><p class="subtitle">单一 API 配置作为所有新会话的默认值;会话可单独覆盖模型和 effort。</p><div class="row"><div class="field"><label>API 协议</label><select id="cfg-provider"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic Messages</option></select></div><div class="field"><label>API Key</label><input id="cfg-api-key" type="password" placeholder="留空保留当前 Key"></div></div><div class="field"><label>Base URL</label><input id="cfg-base-url" required></div><div class="row"><div class="field"><label>默认模型</label><input id="cfg-model" required></div><div class="field"><label>子模型(可选)</label><input id="cfg-sub-model"></div></div><div class="row"><div class="field"><label>默认 Effort</label><select id="cfg-effort"><option value="">不传</option><option>none</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option></select></div><div class="field"><label>Context Window</label><input id="cfg-context" type="number" min="1"></div></div><div class="row"><div class="field"><label>Web 端口</label><input id="cfg-port" type="number" min="1" max="65535"></div><div class="field"><label>Max Output Tokens(可选)</label><input id="cfg-max-output" type="number" min="1"></div></div><label class="check"><input id="cfg-streaming" type="checkbox">启用流式请求</label><label class="check"><input id="cfg-open" type="checkbox">运行 deepccc web 时自动打开浏览器</label><div id="key-state" class="subtitle"></div><div class="modal-actions"><button type="button" data-close="settings-modal" class="ghost">取消</button><button class="primary" type="submit">保存设置</button></div></form></div>
38
+
39
+ <div id="rename-modal" class="modal-backdrop hidden"><form id="rename-form" class="modal"><h2>重命名会话</h2><div class="field"><label>名称</label><input id="rename-title" required maxlength="120"></div><div class="modal-actions"><button type="button" data-close="rename-modal" class="ghost">取消</button><button class="primary">保存</button></div></form></div>
40
+
41
+ <div id="approval-modal" class="modal-backdrop hidden"><div class="modal approval"><span class="approval-badge">需要授权</span><h2>Agent 请求执行高风险操作</h2><p id="approval-detail" class="subtitle"></p><pre id="approval-action"></pre><div class="approval-actions"><button data-answer="deny" class="danger">拒绝</button><button data-answer="allow" class="ghost">允许一次</button><button data-answer="allow-session" class="ghost">本会话允许</button><button data-answer="allow-always" class="primary">永久允许</button></div></div></div>
42
+ <div id="toast" class="toast hidden"></div>
43
+ <script>
44
+ (function(){
45
+ var state={sessions:[],selectedId:null,detail:null,config:null,eventSource:null,reloadTimer:null};
46
+ var $=function(id){return document.getElementById(id);};
47
+ async function api(path,options){var response=await fetch(path,Object.assign({headers:{'content-type':'application/json; charset=utf-8'}},options||{}));var data=await response.json().catch(function(){return {error:'响应不是有效 JSON'};});if(!response.ok)throw new Error(data.error||('HTTP '+response.status));return data;}
48
+ function toast(message,error){var el=$('toast');el.textContent=message;el.className='toast'+(error?' error':'');clearTimeout(toast.timer);toast.timer=setTimeout(function(){el.className='toast hidden';},3500);}
49
+ function openModal(id){$(id).classList.remove('hidden');}function closeModal(id){$(id).classList.add('hidden');}
50
+ function escapeHtml(value){return String(value||'').replace(/[&<>"']/g,function(ch){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch];});}
51
+ function markdown(value){var text=escapeHtml(value);var blocks=[];var tick=String.fromCharCode(96);var fence=tick+tick+tick;var pattern=new RegExp(fence+'([a-z0-9_-]*)\\\\n([\\\\s\\\\S]*?)'+fence,'gi');text=text.replace(pattern,function(_,lang,code){blocks.push('<pre><code data-lang="'+lang+'">'+code+'</code></pre>');return '@@BLOCK'+(blocks.length-1)+'@@';});text=text.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>').replace(new RegExp(tick+'([^'+tick+']+)'+tick,'g'),'<code>$1</code>').replace(/\\n/g,'<br>');text=text.replace(/@@BLOCK(\\d+)@@/g,function(_,index){return blocks[Number(index)];});return text;}
52
+ function statusText(status){return status==='running'?'执行中':'空闲';}
53
+ function renderSessions(){var root=$('session-list');root.innerHTML='';if(!state.sessions.length){root.innerHTML='<div class="session-empty">还没有会话<br>点击上方按钮开始</div>';return;}state.sessions.forEach(function(session){var button=document.createElement('button');button.className='session-item'+(session.sessionId===state.selectedId?' active':'');button.innerHTML='<div class="session-title"><i class="status-dot '+session.status+'"></i><span>'+escapeHtml(session.title)+'</span></div><div class="session-meta">'+escapeHtml(session.model||'默认模型')+' · '+escapeHtml(session.effort||'默认 effort')+'</div><div class="session-meta">'+escapeHtml(session.cwd)+'</div>';button.addEventListener('click',function(){selectSession(session.sessionId);});root.appendChild(button);});}
54
+ function messageHtml(role,content){return '<article class="message '+role+'"><div class="avatar">'+(role==='user'?'你':'D')+'</div><div class="bubble"><div class="role">'+(role==='user'?'YOU':'DEEPCCC')+'</div><div class="markdown">'+markdown(content)+'</div></div></article>';}
55
+ function toolHtml(event){var data=event.data||{};var item=data.type==='agent'?data.data:data;return '<details class="tool-card"><summary>'+escapeHtml(item.type==='tool_use'?'调用工具 · '+(item.name||'tool'):'工具结果 · '+(item.name||item.tool_use_id||'tool'))+'</summary><pre>'+escapeHtml(JSON.stringify(item.type==='tool_use'?item.input:item.content,null,2))+'</pre></details>';}
56
+ function renderDetail(){var detail=state.detail;if(!detail)return;$('session-title').textContent=detail.title;$('session-cwd').textContent=detail.cwd+' · '+statusText(detail.status);$('rename-session').classList.remove('hidden');$('delete-session').classList.remove('hidden');$('composer-shell').classList.remove('hidden');$('session-model').value=detail.model||'';$('session-effort').value=detail.effort||'';var html='<div class="thread">';if(!(detail.messages||[]).length&&detail.status!=='running')html+='<div class="empty-thread"><strong>准备开始这个会话</strong><span>在下方输入任务。DeepCCC 会读取当前项目指令、调用工具并持续保存上下文。</span></div>';(detail.messages||[]).forEach(function(message){html+=messageHtml(message.role,message.content);});if(detail.status==='running'){var live=(detail.events||[]).slice().reverse().find(function(event){return event.type==='agent'&&event.data&&event.data.type==='text';});var current=(detail.events||[]).filter(function(event){return event.type==='agent'&&event.data&&(event.data.type==='tool_use'||event.data.type==='tool_result');});current.forEach(function(event){html+=toolHtml(event);});if(live)html+=messageHtml('assistant',live.data.accumulated||live.data.text||'');html+='<div class="run-state"><i class="pulse"></i>Agent 正在工作</div>';}html+='</div>';$('content').innerHTML=html;$('send').classList.toggle('hidden',detail.status==='running');$('stop').classList.toggle('hidden',detail.status!=='running');$('prompt').disabled=detail.status==='running';$('session-model').disabled=detail.status==='running';$('session-effort').disabled=detail.status==='running';renderWarning();renderApproval();requestAnimationFrame(function(){$('content').scrollTop=$('content').scrollHeight;});}
57
+ function renderWarning(){if(!state.detail)return;var conflicts=state.sessions.filter(function(item){return item.sessionId!==state.detail.sessionId&&item.status==='running'&&item.cwd===state.detail.cwd;});var el=$('warning');if(conflicts.length){el.textContent='⚠ 当前目录还有 '+conflicts.length+' 个 Agent 会话正在运行。多个会话直接修改同一目录,可能产生覆盖或冲突。';el.classList.remove('hidden');}else el.classList.add('hidden');}
58
+ function renderApproval(){var approval=state.detail&&state.detail.pendingApproval;if(!approval){closeModal('approval-modal');return;}$('approval-detail').textContent=approval.detail||('工具:'+approval.tool);$('approval-action').textContent=approval.action;openModal('approval-modal');}
59
+ async function loadSessions(){var result=await api('/api/sessions');state.sessions=result.sessions||[];renderSessions();if(state.selectedId&&!state.sessions.some(function(item){return item.sessionId===state.selectedId;}))state.selectedId=null;}
60
+ async function loadDetail(){if(!state.selectedId)return;var result=await api('/api/sessions/'+encodeURIComponent(state.selectedId));state.detail=result.session;renderDetail();await loadSessions();}
61
+ function scheduleDetail(){clearTimeout(state.reloadTimer);state.reloadTimer=setTimeout(function(){loadDetail().catch(function(error){toast(error.message,true);});},120);}
62
+ async function selectSession(id){state.selectedId=id;renderSessions();if(state.eventSource)state.eventSource.close();state.eventSource=new EventSource('/api/sessions/'+encodeURIComponent(id)+'/events');state.eventSource.onmessage=scheduleDetail;await loadDetail();}
63
+ async function loadConfig(){var result=await api('/api/config');state.config=result.config;$('cfg-provider').value=state.config.provider;$('cfg-base-url').value=state.config.baseURL;$('cfg-model').value=state.config.model;$('cfg-sub-model').value=state.config.subModel||'';$('cfg-effort').value=state.config.effort||'';$('cfg-context').value=state.config.contextWindow;$('cfg-max-output').value=state.config.maxOutputTokens||'';$('cfg-streaming').checked=state.config.streaming!==false;$('cfg-port').value=state.config.web.port;$('cfg-open').checked=state.config.web.openOnStart!==false;$('server-port').textContent=state.config.web.port;$('key-state').textContent=state.config.apiKeyConfigured?'当前 API Key:'+state.config.apiKeyMask:'尚未配置 API Key';$('new-cwd').value=state.config.defaultCwd||'';$('new-model').value=state.config.model;var models=[state.config.model].concat(state.sessions.map(function(item){return item.model;})).filter(Boolean).filter(function(value,index,array){return array.indexOf(value)===index;});$('recent-models').innerHTML=models.map(function(model){return '<option value="'+escapeHtml(model)+'">';}).join('');}
64
+ $('new-session').addEventListener('click',function(){openModal('new-modal');setTimeout(function(){$('new-cwd').focus();},0);});$('open-settings').addEventListener('click',function(){loadConfig().then(function(){openModal('settings-modal');});});document.querySelectorAll('[data-close]').forEach(function(button){button.addEventListener('click',function(){closeModal(button.dataset.close);});});
65
+ $('new-form').addEventListener('submit',async function(event){event.preventDefault();try{var result=await api('/api/sessions',{method:'POST',body:JSON.stringify({cwd:$('new-cwd').value,title:$('new-title').value,model:$('new-model').value,effort:$('new-effort').value})});closeModal('new-modal');$('new-title').value='';await loadSessions();await selectSession(result.session.sessionId);}catch(error){toast(error.message,true);}});
66
+ $('settings-form').addEventListener('submit',async function(event){event.preventDefault();var body={provider:$('cfg-provider').value,baseURL:$('cfg-base-url').value,model:$('cfg-model').value,subModel:$('cfg-sub-model').value,effort:$('cfg-effort').value,contextWindow:Number($('cfg-context').value),streaming:$('cfg-streaming').checked,web:{port:Number($('cfg-port').value),openOnStart:$('cfg-open').checked}};if($('cfg-max-output').value)body.maxOutputTokens=Number($('cfg-max-output').value);if($('cfg-api-key').value)body.apiKey=$('cfg-api-key').value;try{await api('/api/config',{method:'PUT',body:JSON.stringify(body)});$('cfg-api-key').value='';closeModal('settings-modal');toast('设置已保存,新配置从下一次新建会话生效');await loadConfig();}catch(error){toast(error.message,true);}});
67
+ async function send(){if(!state.selectedId||!$('prompt').value.trim())return;try{if($('session-model').value!==state.detail.model||$('session-effort').value!==state.detail.effort){await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'PATCH',body:JSON.stringify({model:$('session-model').value,effort:$('session-effort').value})});}var text=$('prompt').value;$('prompt').value='';await api('/api/sessions/'+encodeURIComponent(state.selectedId)+'/messages',{method:'POST',body:JSON.stringify({text:text})});await loadDetail();}catch(error){toast(error.message,true);}}
68
+ $('send').addEventListener('click',send);$('prompt').addEventListener('keydown',function(event){if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send();}});$('stop').addEventListener('click',async function(){try{await api('/api/sessions/'+encodeURIComponent(state.selectedId)+'/stop',{method:'POST',body:'{}'});toast('已请求停止');}catch(error){toast(error.message,true);}});
69
+ $('rename-session').addEventListener('click',function(){$('rename-title').value=state.detail.title;openModal('rename-modal');});$('rename-form').addEventListener('submit',async function(event){event.preventDefault();try{await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'PATCH',body:JSON.stringify({title:$('rename-title').value})});closeModal('rename-modal');await loadDetail();}catch(error){toast(error.message,true);}});$('delete-session').addEventListener('click',async function(){if(!confirm('删除这个会话及其本地历史?不会删除工作目录中的文件。'))return;try{await api('/api/sessions/'+encodeURIComponent(state.selectedId),{method:'DELETE'});if(state.eventSource)state.eventSource.close();state.selectedId=null;state.detail=null;$('composer-shell').classList.add('hidden');$('rename-session').classList.add('hidden');$('delete-session').classList.add('hidden');$('session-title').textContent='DeepCCC';$('session-cwd').textContent='选择或创建一个会话';$('content').innerHTML='<div class="welcome"><div class="welcome-card"><div class="welcome-mark">✦</div><h2>会话已删除</h2><p>从左侧选择其他会话或创建一个新的任务。</p></div></div>';await loadSessions();}catch(error){toast(error.message,true);}});
70
+ document.querySelectorAll('[data-answer]').forEach(function(button){button.addEventListener('click',async function(){var approval=state.detail&&state.detail.pendingApproval;if(!approval)return;try{await api('/api/approvals/'+encodeURIComponent(approval.approvalId),{method:'POST',body:JSON.stringify({answer:button.dataset.answer})});closeModal('approval-modal');scheduleDetail();}catch(error){toast(error.message,true);}});});
71
+ Promise.all([loadSessions(),loadConfig()]).then(function(){if(state.sessions[0])selectSession(state.sessions[0].sessionId);}).catch(function(error){toast(error.message,true);});
72
+ })();
73
+ </script>
74
+ </body></html>`;
@@ -0,0 +1,99 @@
1
+ import { type ChatEvent, type ChatSessionConfig } from "./index.js";
2
+ import type { PermissionAnswer, PermissionRequest, PermissionResolver } from "./permissions.js";
3
+ import { WebSessionStore, type CreateWebSessionInput, type UpdateWebSessionInput, type WebSessionMeta } from "./web-session-store.js";
4
+ export interface WebRuntimeConfig extends ChatSessionConfig {
5
+ provider: "openai" | "anthropic";
6
+ apiKey: string;
7
+ baseURL: string;
8
+ model: string;
9
+ subModel: string;
10
+ effort: string;
11
+ contextWindow: number;
12
+ streaming: boolean;
13
+ }
14
+ export interface WebRuntimeEvent {
15
+ eventId: number;
16
+ at: string;
17
+ type: "user" | "agent" | "run_started" | "run_finished" | "approval" | "approval_resolved" | "session_updated";
18
+ data: unknown;
19
+ }
20
+ export interface PendingWebApproval extends PermissionRequest {
21
+ approvalId: string;
22
+ sessionId: string;
23
+ createdAt: string;
24
+ }
25
+ export interface WebSessionFactoryInput {
26
+ meta: WebSessionMeta;
27
+ config: WebRuntimeConfig;
28
+ permissionResolver: PermissionResolver;
29
+ }
30
+ export interface WebSessionAgent {
31
+ chat(input: string, signal?: AbortSignal): AsyncIterable<ChatEvent>;
32
+ }
33
+ export interface DeepCccWebRuntimeOptions {
34
+ store?: WebSessionStore;
35
+ loadConfig: () => WebRuntimeConfig;
36
+ sessionFactory?: (input: WebSessionFactoryInput) => WebSessionAgent;
37
+ approvalTimeoutMs?: number;
38
+ now?: () => Date;
39
+ idFactory?: () => string;
40
+ }
41
+ export declare class DeepCccWebRuntime {
42
+ readonly store: WebSessionStore;
43
+ private readonly loadConfig;
44
+ private readonly sessionFactory;
45
+ private readonly approvalTimeoutMs;
46
+ private readonly now;
47
+ private readonly idFactory;
48
+ private readonly agents;
49
+ private readonly activeRuns;
50
+ private readonly events;
51
+ private readonly listeners;
52
+ private readonly approvals;
53
+ private nextEventId;
54
+ constructor(options: DeepCccWebRuntimeOptions);
55
+ listSessions(): Promise<{
56
+ status: string;
57
+ schemaVersion: 1;
58
+ sessionId: string;
59
+ title: string;
60
+ cwd: string;
61
+ model: string;
62
+ subModel: string;
63
+ effort: string;
64
+ createdAt: string;
65
+ updatedAt: string;
66
+ }[]>;
67
+ createSession(input: CreateWebSessionInput): Promise<WebSessionMeta>;
68
+ getSession(sessionId: string): Promise<{
69
+ status: string;
70
+ runId: string | null;
71
+ messages: import("./context.js").BuiltinContextMessage[];
72
+ summary: string;
73
+ events: WebRuntimeEvent[];
74
+ pendingApproval: PendingWebApproval | null;
75
+ schemaVersion: 1;
76
+ sessionId: string;
77
+ title: string;
78
+ cwd: string;
79
+ model: string;
80
+ subModel: string;
81
+ effort: string;
82
+ createdAt: string;
83
+ updatedAt: string;
84
+ }>;
85
+ updateSession(sessionId: string, patch: UpdateWebSessionInput): Promise<WebSessionMeta>;
86
+ deleteSession(sessionId: string): Promise<boolean>;
87
+ sendMessage(sessionId: string, text: string): Promise<{
88
+ runId: string;
89
+ }>;
90
+ stopSession(sessionId: string): Promise<boolean>;
91
+ waitForIdle(sessionId: string): Promise<void>;
92
+ resolveApproval(approvalId: string, answer: PermissionAnswer): Promise<boolean>;
93
+ subscribe(sessionId: string, listener: (event: WebRuntimeEvent) => void): () => void;
94
+ private run;
95
+ private createAgent;
96
+ private requestApproval;
97
+ private emit;
98
+ private requireSession;
99
+ }
@@ -0,0 +1,191 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ChatSession } from "./index.js";
3
+ import { readBuiltinContextState } from "./context.js";
4
+ import { WebSessionStore, } from "./web-session-store.js";
5
+ export class DeepCccWebRuntime {
6
+ store;
7
+ loadConfig;
8
+ sessionFactory;
9
+ approvalTimeoutMs;
10
+ now;
11
+ idFactory;
12
+ agents = new Map();
13
+ activeRuns = new Map();
14
+ events = new Map();
15
+ listeners = new Map();
16
+ approvals = new Map();
17
+ nextEventId = 1;
18
+ constructor(options) {
19
+ this.store = options.store ?? new WebSessionStore();
20
+ this.loadConfig = options.loadConfig;
21
+ this.approvalTimeoutMs = options.approvalTimeoutMs ?? 5 * 60_000;
22
+ this.now = options.now ?? (() => new Date());
23
+ this.idFactory = options.idFactory ?? randomUUID;
24
+ this.sessionFactory = options.sessionFactory ?? ((input) => new ChatSession({
25
+ provider: input.config.provider,
26
+ apiKey: input.config.apiKey,
27
+ baseURL: input.config.baseURL,
28
+ model: input.meta.model || input.config.model,
29
+ subModel: input.meta.subModel || input.config.subModel,
30
+ effort: input.meta.effort || input.config.effort,
31
+ maxOutputTokens: input.config.maxOutputTokens,
32
+ streaming: input.config.streaming,
33
+ }, {
34
+ cwd: input.meta.cwd,
35
+ persist: true,
36
+ sessionId: input.meta.sessionId,
37
+ contextWindow: input.config.contextWindow,
38
+ permissionMode: "ask",
39
+ permissionResolver: input.permissionResolver,
40
+ }));
41
+ }
42
+ async listSessions() {
43
+ const sessions = await this.store.list();
44
+ return sessions.map((meta) => ({ ...meta, status: this.activeRuns.has(meta.sessionId) ? "running" : "idle" }));
45
+ }
46
+ async createSession(input) {
47
+ const config = this.loadConfig();
48
+ const session = await this.store.create({
49
+ ...input,
50
+ model: input.model ?? config.model,
51
+ subModel: input.subModel ?? config.subModel,
52
+ effort: input.effort ?? config.effort,
53
+ });
54
+ this.emit(session.sessionId, "session_updated", session);
55
+ return session;
56
+ }
57
+ async getSession(sessionId) {
58
+ const meta = await this.requireSession(sessionId);
59
+ const context = readBuiltinContextState(sessionId, this.store.rootDir);
60
+ const pendingApproval = [...this.approvals.values()].find((entry) => entry.approval.sessionId === sessionId)?.approval ?? null;
61
+ return {
62
+ ...meta,
63
+ status: this.activeRuns.has(sessionId) ? "running" : "idle",
64
+ runId: this.activeRuns.get(sessionId)?.runId ?? null,
65
+ messages: context?.messages ?? [],
66
+ summary: context?.summary ?? "",
67
+ events: this.events.get(sessionId) ?? [],
68
+ pendingApproval,
69
+ };
70
+ }
71
+ async updateSession(sessionId, patch) {
72
+ if (this.activeRuns.has(sessionId))
73
+ throw new Error("Cannot change session settings while the Agent is running");
74
+ const updated = await this.store.update(sessionId, patch);
75
+ this.agents.delete(sessionId);
76
+ this.emit(sessionId, "session_updated", updated);
77
+ return updated;
78
+ }
79
+ async deleteSession(sessionId) {
80
+ if (this.activeRuns.has(sessionId))
81
+ throw new Error("Stop the running session before deleting it");
82
+ this.agents.delete(sessionId);
83
+ this.events.delete(sessionId);
84
+ return this.store.delete(sessionId);
85
+ }
86
+ async sendMessage(sessionId, text) {
87
+ const prompt = text.trim();
88
+ if (!prompt)
89
+ throw new Error("Message must not be empty");
90
+ if (this.activeRuns.has(sessionId))
91
+ throw new Error("This session is already running");
92
+ let meta = await this.requireSession(sessionId);
93
+ if (meta.title === "新会话" || meta.title === "New session" || meta.title === meta.cwd.split(/[\\/]/).at(-1)) {
94
+ meta = await this.store.update(sessionId, { title: prompt.replace(/\s+/g, " ").slice(0, 42) });
95
+ }
96
+ const runId = `run-${this.idFactory()}`;
97
+ const controller = new AbortController();
98
+ this.events.set(sessionId, []);
99
+ this.emit(sessionId, "user", { text: prompt });
100
+ this.emit(sessionId, "run_started", { runId });
101
+ const promise = this.run(meta, prompt, runId, controller.signal)
102
+ .finally(() => this.activeRuns.delete(sessionId));
103
+ this.activeRuns.set(sessionId, { runId, controller, promise });
104
+ void promise.catch(() => { });
105
+ return { runId };
106
+ }
107
+ async stopSession(sessionId) {
108
+ const active = this.activeRuns.get(sessionId);
109
+ if (!active)
110
+ return false;
111
+ active.controller.abort();
112
+ return true;
113
+ }
114
+ async waitForIdle(sessionId) {
115
+ await this.activeRuns.get(sessionId)?.promise;
116
+ }
117
+ async resolveApproval(approvalId, answer) {
118
+ const waiter = this.approvals.get(approvalId);
119
+ if (!waiter)
120
+ return false;
121
+ clearTimeout(waiter.timeout);
122
+ this.approvals.delete(approvalId);
123
+ waiter.resolve(answer);
124
+ this.emit(waiter.approval.sessionId, "approval_resolved", { approvalId, answer });
125
+ return true;
126
+ }
127
+ subscribe(sessionId, listener) {
128
+ const set = this.listeners.get(sessionId) ?? new Set();
129
+ set.add(listener);
130
+ this.listeners.set(sessionId, set);
131
+ return () => {
132
+ set.delete(listener);
133
+ if (!set.size)
134
+ this.listeners.delete(sessionId);
135
+ };
136
+ }
137
+ async run(meta, prompt, runId, signal) {
138
+ try {
139
+ const agent = this.agents.get(meta.sessionId) ?? this.createAgent(meta);
140
+ this.agents.set(meta.sessionId, agent);
141
+ for await (const event of agent.chat(prompt, signal))
142
+ this.emit(meta.sessionId, "agent", event);
143
+ this.emit(meta.sessionId, "run_finished", { runId, outcome: signal.aborted ? "stopped" : "done" });
144
+ await this.store.update(meta.sessionId, {});
145
+ }
146
+ catch (err) {
147
+ const stopped = signal.aborted || (err instanceof Error && err.name === "AbortError");
148
+ this.emit(meta.sessionId, "agent", { type: "error", message: stopped ? "已停止" : err.message });
149
+ this.emit(meta.sessionId, "run_finished", { runId, outcome: stopped ? "stopped" : "error" });
150
+ }
151
+ }
152
+ createAgent(meta) {
153
+ return this.sessionFactory({
154
+ meta,
155
+ config: this.loadConfig(),
156
+ permissionResolver: (request) => this.requestApproval(meta.sessionId, request),
157
+ });
158
+ }
159
+ requestApproval(sessionId, request) {
160
+ const approvalId = `approval-${this.idFactory()}`;
161
+ const approval = {
162
+ ...request,
163
+ approvalId,
164
+ sessionId,
165
+ createdAt: this.now().toISOString(),
166
+ };
167
+ return new Promise((resolve) => {
168
+ const timeout = setTimeout(() => {
169
+ this.approvals.delete(approvalId);
170
+ resolve("deny");
171
+ this.emit(sessionId, "approval_resolved", { approvalId, answer: "deny", timedOut: true });
172
+ }, this.approvalTimeoutMs);
173
+ timeout.unref?.();
174
+ this.approvals.set(approvalId, { approval, resolve, timeout });
175
+ this.emit(sessionId, "approval", approval);
176
+ });
177
+ }
178
+ emit(sessionId, type, data) {
179
+ const event = { eventId: this.nextEventId++, at: this.now().toISOString(), type, data };
180
+ const events = [...(this.events.get(sessionId) ?? []), event].slice(-500);
181
+ this.events.set(sessionId, events);
182
+ for (const listener of this.listeners.get(sessionId) ?? [])
183
+ listener(event);
184
+ }
185
+ async requireSession(sessionId) {
186
+ const meta = await this.store.get(sessionId);
187
+ if (!meta)
188
+ throw new Error(`DeepCCC web session not found: ${sessionId}`);
189
+ return meta;
190
+ }
191
+ }
@@ -0,0 +1,40 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type DeepCccConfig, type DeepCccConfigPatch } from "./config.js";
3
+ import { DeepCccWebRuntime } from "./web-runtime.js";
4
+ export interface PublicDeepCccConfig {
5
+ provider: "openai" | "anthropic";
6
+ baseURL: string;
7
+ model: string;
8
+ subModel: string;
9
+ effort: string;
10
+ maxOutputTokens?: number;
11
+ streaming: boolean;
12
+ contextWindow: number;
13
+ web: {
14
+ port: number;
15
+ openOnStart: boolean;
16
+ };
17
+ apiKeyConfigured: boolean;
18
+ apiKeyMask: string;
19
+ defaultCwd?: string;
20
+ }
21
+ export interface DeepCccWebRequestHandlerOptions {
22
+ runtime: Pick<DeepCccWebRuntime, "listSessions" | "createSession" | "getSession" | "updateSession" | "deleteSession" | "sendMessage" | "stopSession" | "resolveApproval" | "subscribe">;
23
+ getPublicConfig: () => PublicDeepCccConfig;
24
+ saveConfig: (patch: DeepCccConfigPatch) => Promise<PublicDeepCccConfig> | PublicDeepCccConfig;
25
+ defaultCwd: string;
26
+ }
27
+ export interface StartDeepCccWebOptions {
28
+ port?: number;
29
+ openBrowser?: boolean;
30
+ defaultCwd?: string;
31
+ }
32
+ export interface DeepCccWebHandle {
33
+ url: string;
34
+ port: number;
35
+ reused: boolean;
36
+ close(): Promise<void>;
37
+ }
38
+ export declare function toPublicConfig(config: DeepCccConfig, defaultCwd?: string): PublicDeepCccConfig;
39
+ export declare function createDeepCccWebRequestHandler(options: DeepCccWebRequestHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
40
+ export declare function startDeepCccWebServer(options?: StartDeepCccWebOptions): Promise<DeepCccWebHandle>;
@@ -0,0 +1,243 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { stat } from "node:fs/promises";
4
+ import { loadConfig, saveConfigPatch, } from "./config.js";
5
+ import { DeepCccWebRuntime } from "./web-runtime.js";
6
+ import { DEEPCCC_WEB_PAGE } from "./web-page.js";
7
+ const MAX_BODY_BYTES = 512 * 1024;
8
+ const HOST = "127.0.0.1";
9
+ let defaultHandle = null;
10
+ function runtimeConfig() {
11
+ const config = loadConfig();
12
+ return {
13
+ provider: config.provider,
14
+ apiKey: config.apiKey,
15
+ baseURL: config.baseURL,
16
+ model: config.model,
17
+ subModel: config.subModel,
18
+ effort: config.effort,
19
+ maxOutputTokens: config.maxOutputTokens,
20
+ contextWindow: config.contextWindow,
21
+ streaming: config.streaming,
22
+ };
23
+ }
24
+ export function toPublicConfig(config, defaultCwd = process.cwd()) {
25
+ return {
26
+ provider: config.provider,
27
+ baseURL: config.baseURL,
28
+ model: config.model,
29
+ subModel: config.subModel,
30
+ effort: config.effort,
31
+ ...(config.maxOutputTokens ? { maxOutputTokens: config.maxOutputTokens } : {}),
32
+ streaming: config.streaming,
33
+ contextWindow: config.contextWindow,
34
+ web: config.web,
35
+ apiKeyConfigured: !!config.apiKey,
36
+ apiKeyMask: maskSecret(config.apiKey),
37
+ defaultCwd,
38
+ };
39
+ }
40
+ export function createDeepCccWebRequestHandler(options) {
41
+ return async function handle(req, res) {
42
+ const url = new URL(req.url ?? "/", `http://${HOST}`);
43
+ const path = url.pathname;
44
+ const method = req.method ?? "GET";
45
+ try {
46
+ if (method === "GET" && (path === "/" || path === "/index.html")) {
47
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
48
+ }
49
+ if (method === "GET" && path === "/api/health") {
50
+ return jsonReply(res, 200, { ok: true, service: "deepccc-web" });
51
+ }
52
+ if (method === "GET" && path === "/api/config") {
53
+ return jsonReply(res, 200, { ok: true, config: { ...options.getPublicConfig(), defaultCwd: options.defaultCwd } });
54
+ }
55
+ if (method === "PUT" && path === "/api/config") {
56
+ const body = await readJson(req);
57
+ const config = await options.saveConfig(body);
58
+ return jsonReply(res, 200, { ok: true, config: { ...config, defaultCwd: options.defaultCwd } });
59
+ }
60
+ if (method === "GET" && path === "/api/sessions") {
61
+ return jsonReply(res, 200, { ok: true, sessions: await options.runtime.listSessions() });
62
+ }
63
+ if (method === "POST" && path === "/api/sessions") {
64
+ const body = await readJson(req);
65
+ const cwd = body.cwd?.trim() || options.defaultCwd;
66
+ await assertDirectory(cwd);
67
+ const session = await options.runtime.createSession({ ...body, cwd });
68
+ return jsonReply(res, 201, { ok: true, session });
69
+ }
70
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
71
+ if (sessionMatch && method === "GET") {
72
+ return jsonReply(res, 200, { ok: true, session: await options.runtime.getSession(decodeURIComponent(sessionMatch[1])) });
73
+ }
74
+ if (sessionMatch && method === "PATCH") {
75
+ const body = await readJson(req);
76
+ const session = await options.runtime.updateSession(decodeURIComponent(sessionMatch[1]), {
77
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
78
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
79
+ ...(typeof body.subModel === "string" ? { subModel: body.subModel } : {}),
80
+ ...(typeof body.effort === "string" ? { effort: body.effort } : {}),
81
+ });
82
+ return jsonReply(res, 200, { ok: true, session });
83
+ }
84
+ if (sessionMatch && method === "DELETE") {
85
+ const deleted = await options.runtime.deleteSession(decodeURIComponent(sessionMatch[1]));
86
+ return jsonReply(res, deleted ? 200 : 404, { ok: deleted });
87
+ }
88
+ const messagesMatch = path.match(/^\/api\/sessions\/([^/]+)\/messages$/);
89
+ if (messagesMatch && method === "POST") {
90
+ const body = await readJson(req);
91
+ const result = await options.runtime.sendMessage(decodeURIComponent(messagesMatch[1]), body.text ?? "");
92
+ return jsonReply(res, 202, { ok: true, ...result });
93
+ }
94
+ const stopMatch = path.match(/^\/api\/sessions\/([^/]+)\/stop$/);
95
+ if (stopMatch && method === "POST") {
96
+ return jsonReply(res, 200, { ok: true, stopped: await options.runtime.stopSession(decodeURIComponent(stopMatch[1])) });
97
+ }
98
+ const eventsMatch = path.match(/^\/api\/sessions\/([^/]+)\/events$/);
99
+ if (eventsMatch && method === "GET") {
100
+ return openEventStream(req, res, decodeURIComponent(eventsMatch[1]), options.runtime);
101
+ }
102
+ const approvalMatch = path.match(/^\/api\/approvals\/([^/]+)$/);
103
+ if (approvalMatch && method === "POST") {
104
+ const body = await readJson(req);
105
+ if (!body.answer || !["allow", "allow-session", "allow-always", "deny"].includes(body.answer)) {
106
+ throw new WebHttpError(400, "Invalid approval answer");
107
+ }
108
+ const resolved = await options.runtime.resolveApproval(decodeURIComponent(approvalMatch[1]), body.answer);
109
+ return jsonReply(res, resolved ? 200 : 404, { ok: resolved });
110
+ }
111
+ if (path.startsWith("/api/"))
112
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
113
+ if (method === "GET")
114
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
115
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
116
+ }
117
+ catch (err) {
118
+ const status = err instanceof WebHttpError ? err.status : 500;
119
+ jsonReply(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) });
120
+ }
121
+ };
122
+ }
123
+ export async function startDeepCccWebServer(options = {}) {
124
+ const config = loadConfig();
125
+ const port = options.port ?? config.web.port;
126
+ const url = `http://${HOST}:${port}/`;
127
+ if (defaultHandle?.port === port) {
128
+ if (options.openBrowser !== false)
129
+ openBrowser(url);
130
+ return defaultHandle;
131
+ }
132
+ if (await isExistingDeepCccServer(port)) {
133
+ const reused = { url, port, reused: true, close: async () => { } };
134
+ if (options.openBrowser !== false)
135
+ openBrowser(url);
136
+ return reused;
137
+ }
138
+ const runtime = new DeepCccWebRuntime({ loadConfig: runtimeConfig });
139
+ const defaultCwd = options.defaultCwd ?? process.cwd();
140
+ const handler = createDeepCccWebRequestHandler({
141
+ runtime,
142
+ defaultCwd,
143
+ getPublicConfig: () => toPublicConfig(loadConfig(), defaultCwd),
144
+ saveConfig: (patch) => toPublicConfig(saveConfigPatch(patch), defaultCwd),
145
+ });
146
+ const server = createServer((req, res) => { void handler(req, res); });
147
+ await new Promise((resolve, reject) => {
148
+ const onError = (err) => reject(err);
149
+ server.once("error", onError);
150
+ server.listen(port, HOST, () => {
151
+ server.off("error", onError);
152
+ resolve();
153
+ });
154
+ });
155
+ const handle = {
156
+ url,
157
+ port,
158
+ reused: false,
159
+ close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())),
160
+ };
161
+ defaultHandle = handle;
162
+ if (options.openBrowser ?? config.web.openOnStart)
163
+ openBrowser(url);
164
+ return handle;
165
+ }
166
+ function openEventStream(req, res, sessionId, runtime) {
167
+ res.writeHead(200, {
168
+ "content-type": "text/event-stream; charset=utf-8",
169
+ "cache-control": "no-cache, no-transform",
170
+ connection: "keep-alive",
171
+ "x-accel-buffering": "no",
172
+ });
173
+ res.write(": connected\n\n");
174
+ const send = (event) => res.write(`id: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`);
175
+ const unsubscribe = runtime.subscribe(sessionId, send);
176
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
177
+ heartbeat.unref?.();
178
+ req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
179
+ }
180
+ async function readJson(req) {
181
+ const chunks = [];
182
+ let bytes = 0;
183
+ for await (const chunk of req) {
184
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
185
+ bytes += buffer.length;
186
+ if (bytes > MAX_BODY_BYTES)
187
+ throw new WebHttpError(413, "Request body is too large");
188
+ chunks.push(buffer);
189
+ }
190
+ if (!chunks.length)
191
+ return {};
192
+ try {
193
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
194
+ }
195
+ catch {
196
+ throw new WebHttpError(400, "Invalid JSON body");
197
+ }
198
+ }
199
+ async function assertDirectory(path) {
200
+ try {
201
+ if (!(await stat(path)).isDirectory())
202
+ throw new Error("not a directory");
203
+ }
204
+ catch {
205
+ throw new WebHttpError(400, `工作目录不存在:${path}`);
206
+ }
207
+ }
208
+ function maskSecret(value) {
209
+ if (!value)
210
+ return "";
211
+ const suffix = value.slice(-4);
212
+ return `${value.slice(0, Math.min(3, Math.max(0, value.length - 4)))}••••${suffix}`;
213
+ }
214
+ function jsonReply(res, status, value) {
215
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
216
+ res.end(JSON.stringify(value));
217
+ }
218
+ function textReply(res, status, value, contentType) {
219
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
220
+ res.end(value);
221
+ }
222
+ class WebHttpError extends Error {
223
+ status;
224
+ constructor(status, message) {
225
+ super(message);
226
+ this.status = status;
227
+ }
228
+ }
229
+ async function isExistingDeepCccServer(port) {
230
+ try {
231
+ const response = await fetch(`http://${HOST}:${port}/api/health`, { signal: AbortSignal.timeout(800) });
232
+ return response.ok && (await response.json()).service === "deepccc-web";
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ }
238
+ function openBrowser(url) {
239
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
240
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
241
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
242
+ child.unref();
243
+ }
@@ -0,0 +1,43 @@
1
+ export interface WebSessionMeta {
2
+ schemaVersion: 1;
3
+ sessionId: string;
4
+ title: string;
5
+ cwd: string;
6
+ model: string;
7
+ subModel: string;
8
+ effort: string;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ }
12
+ export interface CreateWebSessionInput {
13
+ cwd: string;
14
+ title?: string;
15
+ model?: string;
16
+ subModel?: string;
17
+ effort?: string;
18
+ }
19
+ export interface UpdateWebSessionInput {
20
+ title?: string;
21
+ model?: string;
22
+ subModel?: string;
23
+ effort?: string;
24
+ }
25
+ export interface WebSessionStoreOptions {
26
+ rootDir?: string;
27
+ now?: () => Date;
28
+ idFactory?: () => string;
29
+ }
30
+ export declare class WebSessionStore {
31
+ readonly rootDir: string;
32
+ private readonly now;
33
+ private readonly idFactory;
34
+ constructor(options?: WebSessionStoreOptions);
35
+ create(input: CreateWebSessionInput): Promise<WebSessionMeta>;
36
+ get(sessionId: string): Promise<WebSessionMeta | null>;
37
+ list(): Promise<WebSessionMeta[]>;
38
+ update(sessionId: string, patch: UpdateWebSessionInput): Promise<WebSessionMeta>;
39
+ delete(sessionId: string): Promise<boolean>;
40
+ private require;
41
+ private save;
42
+ private metaPath;
43
+ }
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { DEFAULT_BUILTIN_CONTEXT_DIR, getBuiltinContextSession, normalizeBuiltinSessionId, } from "./context.js";
5
+ export class WebSessionStore {
6
+ rootDir;
7
+ now;
8
+ idFactory;
9
+ constructor(options = {}) {
10
+ this.rootDir = options.rootDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
11
+ this.now = options.now ?? (() => new Date());
12
+ this.idFactory = options.idFactory ?? (() => `web-${randomUUID()}`);
13
+ }
14
+ async create(input) {
15
+ const cwd = cleanCwd(input.cwd);
16
+ const timestamp = this.now().toISOString();
17
+ const sessionId = normalizeBuiltinSessionId(this.idFactory());
18
+ const meta = {
19
+ schemaVersion: 1,
20
+ sessionId,
21
+ title: cleanTitle(input.title) || basename(cwd) || "新会话",
22
+ cwd,
23
+ model: input.model?.trim() ?? "",
24
+ subModel: input.subModel?.trim() ?? "",
25
+ effort: input.effort?.trim() ?? "",
26
+ createdAt: timestamp,
27
+ updatedAt: timestamp,
28
+ };
29
+ await this.save(meta);
30
+ return meta;
31
+ }
32
+ async get(sessionId) {
33
+ const normalized = normalizeBuiltinSessionId(sessionId);
34
+ try {
35
+ return parseMeta(JSON.parse(await readFile(this.metaPath(normalized), "utf8")));
36
+ }
37
+ catch (err) {
38
+ if (err.code === "ENOENT") {
39
+ const context = getBuiltinContextSession(normalized, this.rootDir);
40
+ if (!context)
41
+ return null;
42
+ const cwd = context.cwd ?? process.cwd();
43
+ return {
44
+ schemaVersion: 1,
45
+ sessionId: normalized,
46
+ title: basename(cwd) || normalized,
47
+ cwd,
48
+ model: "",
49
+ subModel: "",
50
+ effort: "",
51
+ createdAt: new Date(context.createdAt).toISOString(),
52
+ updatedAt: new Date(context.updatedAt).toISOString(),
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+ }
58
+ async list() {
59
+ let entries;
60
+ try {
61
+ entries = await readdir(this.rootDir);
62
+ }
63
+ catch (err) {
64
+ if (err.code === "ENOENT")
65
+ return [];
66
+ throw err;
67
+ }
68
+ const sessions = await Promise.all(entries.map((entry) => this.get(entry)));
69
+ return sessions
70
+ .filter((session) => session !== null)
71
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.sessionId.localeCompare(b.sessionId));
72
+ }
73
+ async update(sessionId, patch) {
74
+ const current = await this.require(sessionId);
75
+ const updated = {
76
+ ...current,
77
+ ...(patch.title !== undefined ? { title: cleanTitle(patch.title) || current.title } : {}),
78
+ ...(patch.model !== undefined ? { model: patch.model.trim() } : {}),
79
+ ...(patch.subModel !== undefined ? { subModel: patch.subModel.trim() } : {}),
80
+ ...(patch.effort !== undefined ? { effort: patch.effort.trim() } : {}),
81
+ updatedAt: this.now().toISOString(),
82
+ };
83
+ await this.save(updated);
84
+ return updated;
85
+ }
86
+ async delete(sessionId) {
87
+ const normalized = normalizeBuiltinSessionId(sessionId);
88
+ if (!await this.get(normalized))
89
+ return false;
90
+ await rm(join(this.rootDir, normalized), { recursive: true, force: true });
91
+ return true;
92
+ }
93
+ async require(sessionId) {
94
+ const meta = await this.get(sessionId);
95
+ if (!meta)
96
+ throw new Error(`DeepCCC web session not found: ${sessionId}`);
97
+ return meta;
98
+ }
99
+ async save(meta) {
100
+ const path = this.metaPath(meta.sessionId);
101
+ await mkdir(join(this.rootDir, meta.sessionId), { recursive: true });
102
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
103
+ await writeFile(tempPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
104
+ await rename(tempPath, path);
105
+ }
106
+ metaPath(sessionId) {
107
+ const normalized = normalizeBuiltinSessionId(sessionId);
108
+ return join(this.rootDir, normalized, "web.json");
109
+ }
110
+ }
111
+ function cleanCwd(value) {
112
+ if (typeof value !== "string" || !value.trim())
113
+ throw new Error("cwd must be a non-empty path");
114
+ return resolve(value.trim());
115
+ }
116
+ function cleanTitle(value) {
117
+ return value?.trim().slice(0, 120) ?? "";
118
+ }
119
+ function parseMeta(value) {
120
+ if (!value || typeof value !== "object" || Array.isArray(value))
121
+ throw new Error("Invalid web session metadata");
122
+ const meta = value;
123
+ if (meta.schemaVersion !== 1)
124
+ throw new Error("Unsupported web session metadata");
125
+ for (const field of ["sessionId", "title", "cwd", "model", "subModel", "effort", "createdAt", "updatedAt"]) {
126
+ if (typeof meta[field] !== "string")
127
+ throw new Error(`Invalid web session ${field}`);
128
+ }
129
+ return meta;
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.28",
3
+ "version": "0.2.0",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [