xitto-kernel 0.6.4 → 0.8.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5
4
+
5
+ - **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
6
+ - **任務清單** → `.xitto-server/tasks/<id>.json`(每任務一檔,狀態變更時覆寫),啟動載回 → **歷史成品重啟後自動顯示**
7
+ - **對話 session** → `.xitto-server/sessions/<id>.json`,啟動載回 → **重啟後仍能「繼續/調整」**(對話脈絡跨重啟)
8
+ - **重啟收尾**:載入時還停在 `running`/`queued`/`needs-input` 的(agent 已隨進程消失)標 `interrupted`「已中斷(重啟)」
9
+ - 對標 Claude Code「對話自動落地」;但許願台是**自動顯示歷史**(成品清單),非明確 `--resume`(它是 chat,單位不同)
10
+ - 1 個新測試(落地/載回/interrupted)+ 真實端到端:跑任務→重啟(新 server 同 baseDir)→歷史顯示 + 接續對話寫出「重啟前只在對話講過的偏好 42」。測試 189/189
11
+
12
+ ## 0.8.4
13
+
14
+ - **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
15
+ - **許願頁** = 主區(許願輸入 + 當前任務/成品)+ **歷史側欄**(sticky,捲動主區時歷史保持可見)
16
+ - **工作台** = 檔案瀏覽器在左、**檔案預覽在右**(並排,不必上下捲)
17
+ - 容器加寬到 1180px;` (max-width:860px)` 自動收成單欄(窄螢幕/手機不變)
18
+ - 純 CSS/HTML 結構調整;測試 188/188,內嵌 JS 語法檢查 + 服務頁面結構驗證通過
19
+
20
+ ## 0.8.3
21
+
22
+ - **工作台改逐層瀏覽(不一次攤平整個專案)**:原本 `listWorkspaceFiles` 會遞迴把所有檔案攤成一長串,對真實專案太雜。改成像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,有「上一層」。
23
+ - 新增 `listDir(wsDir, sub)`(列單層,排除內部目錄,防穿越);`/v1/workspaces/files` 改吃 `sub=` 逐層
24
+ - 網頁工作台改可導航(麵包屑 + 進子資料夾 + 上一層);切換空間/分頁時重置到根
25
+ - 1 個新測試(listDir 不遞迴/子目錄分開/防穿越)+ 真實 server 端到端(根→src→src/utils)。測試 188/188
26
+
27
+ ## 0.8.2
28
+
29
+ - **`npm run serve:local`**:一行啟動本地就地模式(= `XITTO_SERVER_LOCAL=1 XITTO_SERVER_SANDBOX=off`,token 預設 `secret`、可用 `XITTO_SERVER_TOKEN` 覆寫)。不用每次手打那串環境變數。
30
+
31
+ ## 0.8.1
32
+
33
+ - **資料夾用「選」的(本地模式)**:不用打路徑。
34
+ - 瀏覽器原生選資料夾拿不到絕對路徑(安全限制),改由 **local server 端列資料夾** → 網頁「📁 選資料夾」鈕,從家目錄點進去挑一個
35
+ - 新增 `GET /v1/fs?path=`(**僅本地模式**;列子資料夾,排除 . 開頭/node_modules;託管模式回 403,不洩漏主機結構)
36
+ - 網頁資料夾瀏覽器 modal(上一層/家目錄/選定);空間下拉以 📁 標真實資料夾
37
+ - 1 個新測試(/v1/fs 本地列檔 / 託管 403)+ 真實 server 端到端(家目錄→xiza→選 xitto*)。測試 187/187
38
+
39
+ ## 0.8.0
40
+
41
+ - **本地就地模式(許願台像 Claude Code 改你選的真實資料夾)**:打通「隔離(許願台)」與「就地(Claude Code)」兩個檔案模型。
42
+ - `XITTO_SERVER_LOCAL=1` 時,workspace 可為**真實資料夾的絕對路徑** → 任務**就地改該資料夾的檔**(無隔離副本);網頁「新專案」可貼路徑
43
+ - 新增 `workspaceDir(baseDir, ws, local)`:local + 絕對路徑 → 就地;**否則(含託管收到絕對路徑)→ 消毒成管理空間,不逃逸**
44
+ - 工作台端點改 query `?ws=`(容納絕對路徑);in-place 資料夾不存在 → 400
45
+ - 網頁注入 `__LOCAL__`;空間下拉以 `📁` 標真實資料夾
46
+ - 4 個新測試(workspaceDir 就地/不逃逸/管理)+ 真實 server 端到端
47
+ (local:就地改 /tmp/myproj/calc.js 的 a-b→a+b、無副本;hosted:絕對路徑被消毒不逃逸)。測試 186/186
48
+
49
+ ## 0.7.1
50
+
51
+ - **成品迭代「繼續/調整」(迭代有脈絡)**:在許願台補上迭代閉環。
52
+ - 完成的成品上加「↳ 繼續/調整這個成果」→ 送出**後續任務**,接續這次的對話(`sessionId`)+ 同工作區
53
+ → agent 同時有「**檔案 + 當時的討論與理由**」,不只是檔案
54
+ - 預設每個許願是乾淨新對話(不暴脹);按「繼續」才接續那條線(像 ChatGPT 新對話 vs 接著聊)
55
+ - 任務 view 加 `continued`(帶 sessionId = 接續);歷史以 `↳` 標出接續鏈
56
+ - 底層 sessionId 接續 kernel 本已支援,本版只是接到 UI + 標記
57
+ - 1 個測試(view.continued)+ 真實 server 端到端:任務1 私下說「最不喜歡 Go」(只在對話、不在檔案)→ 後續任務「刪掉我最不喜歡的」→ 正確刪掉 Go(langs.txt 剩 Rust/Zig)。測試 185/185
58
+
59
+ ## 0.7.0
60
+
61
+ - **工作台分頁(看見並管理持久工作區)**:許願台加「許願 | 📂 工作台」**同頁分頁**(不是另開頁面,共用空間/檢視器/認證)。
62
+ - **許願**=交辦任務的主流程(不變);**工作台**=列出當前專案累積的**所有檔案**(看/下載/刪)
63
+ - server 新增 `GET /v1/workspaces/:ws/files`(列檔,排除 .xitto-kernel/tmp/node_modules)、`GET/DELETE /v1/workspaces/:ws/file`(取/刪,防穿越)
64
+ - 檔案檢視器抽為通用 `renderFile(base,…)`,任務成品與工作台共用;`serveFile` 抽出(content-type/下載)
65
+ - 讓持久工作空間從「半成品(檔案累積但看不見)」變成可見可管理;刻意保持輕量(檔案清單,非 IDE)
66
+ - 4 個新測試(safeWs 防穿越 / listWorkspaceFiles 排除內部目錄)+ 真實 server 端到端(兩任務累積→列/取/刪/防穿越)。測試 184/184
67
+
3
68
  ## 0.6.4
4
69
 
5
70
  - **許願台「展開過程」+ 彩色 diff(借 Claude Code 的工具卡/⌥展開,翻譯成非技術版)**:
package/README.md CHANGED
@@ -96,6 +96,8 @@ const o = await kernel.runOutcome('建立 greet.js 並寫個範例驗證');
96
96
  **🪄 許願台網頁(給非技術使用者:瀏覽器打開就用)**
97
97
  ```bash
98
98
  XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:8787/
99
+ # 本地就地模式(可選真實資料夾、就地改檔,沙箱關):
100
+ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設 secret(可用 XITTO_SERVER_TOKEN 覆寫)
99
101
  ```
100
102
  不用終端機、不用碰金鑰(伺服器端管)。介面以**結果**為中心,不是聊天:
101
103
  - **許願**:打一句「你想完成什麼」→ 交辦(背景跑 goal loop)
@@ -105,10 +107,19 @@ XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:
105
107
  - **展開過程**:預設安靜(只給進度與成品);想看細節按「展開過程」→ 完整步驟卡(讀/改/跑,人話)+ **編輯的彩色 diff**(綠 +/紅 -)。同一畫面服務「只要結果」與「想看細節」兩種人(對標 Claude Code 的 ⏺/⎿ + ctrl+r 展開)
106
108
  - **需要你回答**:agent 暫停提問時,跳出問題 + 回答框(澄清通道)
107
109
  - **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
110
+ - **繼續/調整(迭代有脈絡)**:成品上有「↳ 繼續/調整這個成果」——打一句想改什麼/想深入什麼,送出一個**後續任務**,**接續這次的對話(sessionId)+ 同工作區**。agent 同時有「檔案 + 當時的討論與理由」,不只是檔案。預設每個許願是乾淨新對話(不暴脹),按「繼續」才接續那條線(像 ChatGPT 開新對話 vs 接著聊)。歷史以 `↳` 標出接續鏈
108
111
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
109
112
 
113
+ **桌面雙欄佈局**:許願頁=主區(許願+當前任務/成品)+ 歷史 sticky 側欄;工作台=檔案瀏覽器左、預覽右並排。容器 1180px,窄螢幕自動收單欄。
114
+
115
+ **工作台分頁(看見並管理你的工作區)**:許願台頂部有「許願 | 📂 工作台」分頁(同頁切換,不是另開頁面)。**許願**=交辦任務的主流程;**工作台**=**逐層瀏覽**當前專案的檔案(像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,不一次遞迴攤平),看/下載/刪。單一任務用許願就夠;做專案的人切到工作台看「我累積了什麼、拿任意檔、清理」——讓持久工作空間從隱形變成可見可管理。刻意保持輕量(檔案清單,不是 IDE)。
116
+
110
117
  **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
111
118
 
119
+ **本地就地模式(像 Claude Code 改你選的真實資料夾)**:`XITTO_SERVER_LOCAL=1` 時,網頁多一個「**📁 選資料夾**」鈕——**用點的**從家目錄瀏覽進你的真實資料夾並選定(不用打路徑;瀏覽器拿不到絕對路徑,所以由 local server 端列資料夾),或「新專案」直接貼絕對路徑也行。任務就**就地改那個資料夾的檔**(不另開隔離副本),工作台列的也是它。這把「許願台(隔離,服務非技術使用者)」和「Claude Code(就地,改你現有的 codebase)」兩個模型打通:**本機自用想就地 → 給路徑;隔離/託管 → 給名稱**。**安全**:只在 `local` 模式才認絕對路徑;**託管模式收到絕對路徑會被消毒成管理空間,不會逃逸到主機任意路徑**。
120
+
121
+ **重啟後歷史還在(持久化)**:任務清單落地 `.xitto-server/tasks/`、對話 session 落地 `.xitto-server/sessions/`,啟動時載回——所以**重啟後歷史成品自動顯示、舊成品仍能「繼續/調整」**(對話脈絡也在)。重啟時還在跑/待答的任務會標「已中斷(重啟)」。對標 Claude Code「對話自動落地」,但許願台是**自動顯示歷史**(成品清單),而非 Claude Code 的明確 `--resume`。
122
+
112
123
  **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
113
124
 
114
125
  零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.6.4",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "demo": "node examples/demo.js",
43
43
  "eval": "node eval/run.js",
44
44
  "serve": "node src/app/server.js",
45
+ "serve:local": "XITTO_SERVER_LOCAL=1 XITTO_SERVER_SANDBOX=off XITTO_SERVER_TOKEN=${XITTO_SERVER_TOKEN:-secret} node src/app/server.js",
45
46
  "start": "node bin/xitto-kernel.js"
46
47
  },
47
48
  "exports": {
package/src/app/server.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
4
  // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
5
5
  import { createServer } from 'node:http';
6
- import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
6
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
7
7
  import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { homedir } from 'node:os';
9
10
  import { createKernel } from '../kernel/index.js';
10
11
  import { loadModel } from './providers.js';
11
12
  import { createCodingPack } from '../packs/coding/index.js';
@@ -30,6 +31,44 @@ export function contentTypeFor(name) { const ext = (String(name).split('.').pop(
30
31
  // 工具參數摘要(給「展開過程」步驟卡):取最有意義的參數。
31
32
  const argSummary = (args) => { if (!args || typeof args !== 'object') return ''; const v = args.command ?? args.path ?? args.pattern ?? args.query ?? args.url ?? args.name ?? args.topic; return (v != null && v !== '') ? String(v).replace(/\s+/g, ' ').slice(0, 80) : ''; };
32
33
 
34
+ // workspace 名稱消毒(防穿越)。
35
+ export const safeWs = (w) => (String(w || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default');
36
+
37
+ // 解析 workspace → 真實目錄。本地模式 + 絕對路徑 → 就地用該真實資料夾(像 Claude Code);
38
+ // 否則(含託管模式收到絕對路徑)→ 消毒成管理空間 ws/<name>,不會逃逸到主機任意路徑。
39
+ export function workspaceDir(baseDir, ws, local) {
40
+ return (local && isAbsolute(String(ws || ''))) ? String(ws) : join(baseDir, 'ws', safeWs(ws));
41
+ }
42
+
43
+ // 列工作區檔案(給「工作台」分頁):遞迴,排除內部目錄,回 [{path,size,mtime}]。
44
+ const SKIP_WS = new Set(['.xitto-kernel', 'node_modules', '.git', 'tmp', '.swebench-repos']);
45
+ export function listWorkspaceFiles(dir, base = dir, out = [], depth = 0) {
46
+ if (depth > 8 || out.length > 2000) return out;
47
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
48
+ for (const e of entries) {
49
+ if (SKIP_WS.has(e.name)) continue;
50
+ const full = join(dir, e.name);
51
+ if (e.isDirectory()) listWorkspaceFiles(full, base, out, depth + 1);
52
+ else if (e.isFile()) { try { const s = statSync(full); out.push({ path: relative(base, full), size: s.size, mtime: s.mtimeMs }); } catch { /* 略 */ } }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // 列單一層級(給工作台逐層瀏覽,不一次遞迴攤平整個專案):回 { sub, dirs:[], files:[{name,size,mtime}] }。
58
+ export function listDir(wsDir, sub) {
59
+ const rel = String(sub || '').replace(/^\/+|\/+$/g, '');
60
+ const dir = rel === '' ? wsDir : resolveArtifact(wsDir, rel);
61
+ if (!dir || !existsSync(dir)) return null;
62
+ const dirs = [], files = [];
63
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; }
64
+ for (const e of entries) {
65
+ if (SKIP_WS.has(e.name)) continue;
66
+ if (e.isDirectory()) dirs.push(e.name);
67
+ else if (e.isFile()) { try { const s = statSync(join(dir, e.name)); files.push({ name: e.name, size: s.size, mtime: s.mtimeMs }); } catch { /* 略 */ } }
68
+ }
69
+ return { sub: rel, dirs: dirs.sort(), files: files.sort((a, b) => b.mtime - a.mtime) };
70
+ }
71
+
33
72
  // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
34
73
  export function resolveArtifact(workdir, rel) {
35
74
  if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
@@ -61,13 +100,27 @@ export const mapEvent = (ev) => {
61
100
  * @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
62
101
  * @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
63
102
  */
64
- export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
103
+ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500, persistDir } = {}) {
65
104
  const tasks = new Map(); // id -> task
66
105
  const queue = []; // 等待中的 task
67
106
  const subs = new Map(); // id -> Set<(ev)=>void>
68
107
  let active = 0;
69
108
 
70
- const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
109
+ // 持久化:每個任務落地一個 json(重啟後歷史成品還在)。runtime 欄位(_agent/events…)不存。
110
+ const snapshot = (t) => ({ id: t.id, status: t.status, spec: t.spec, result: t.result, error: t.error, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, progress: t.progress || null, pending: t.pending || null });
111
+ const persistTask = (t) => { if (!persistDir) return; try { mkdirSync(persistDir, { recursive: true }); writeFileSync(join(persistDir, t.id + '.json'), JSON.stringify(snapshot(t))); } catch { /* 略 */ } };
112
+ if (persistDir && existsSync(persistDir)) {
113
+ for (const f of readdirSync(persistDir).filter((x) => x.endsWith('.json')).sort()) {
114
+ try {
115
+ const t = JSON.parse(readFileSync(join(persistDir, f), 'utf8'));
116
+ if (['running', 'queued', 'needs-input'].includes(t.status)) { t.status = 'interrupted'; t.pending = null; } // 進程已死 → 標中斷
117
+ t.events = [];
118
+ tasks.set(t.id, t);
119
+ } catch { /* 壞檔略 */ }
120
+ }
121
+ }
122
+
123
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, continued: !!t.spec.sessionId, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
71
124
 
72
125
  const emit = (t, ev) => {
73
126
  t.events.push(ev);
@@ -96,6 +149,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
96
149
  const makeAsk = (t) => ({ question, options }) => {
97
150
  t.status = 'needs-input'; t.pending = { question: String(question || ''), options: options || null };
98
151
  emit(t, { type: 'needs_input', question: t.pending.question, options: t.pending.options });
152
+ persistTask(t);
99
153
  return new Promise((resolve) => { t._answer = resolve; });
100
154
  };
101
155
 
@@ -113,6 +167,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
113
167
  if (t._cancelling && t.status !== 'error') t.status = 'cancelled'; // 使用者中斷
114
168
  t.finishedAt = new Date().toISOString();
115
169
  emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
170
+ persistTask(t);
116
171
  active--;
117
172
  try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
118
173
  pump();
@@ -124,6 +179,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
124
179
  enqueue(spec) {
125
180
  const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
126
181
  tasks.set(t.id, t);
182
+ persistTask(t);
127
183
  queue.push(t);
128
184
  pump();
129
185
  return t;
@@ -141,7 +197,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
141
197
  if (t.status === 'queued') {
142
198
  const i = queue.indexOf(t); if (i >= 0) queue.splice(i, 1);
143
199
  t.status = 'cancelled'; t.finishedAt = new Date().toISOString();
144
- emit(t, { type: 'end', status: 'cancelled' });
200
+ emit(t, { type: 'end', status: 'cancelled' }); persistTask(t);
145
201
  return true;
146
202
  }
147
203
  if (typeof t._answer === 'function') { const r = t._answer; t._answer = null; t.pending = null; r(''); } // 解除待答阻塞
@@ -154,7 +210,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
154
210
  const t = tasks.get(id);
155
211
  if (!t || typeof t._answer !== 'function') return false;
156
212
  const resolve = t._answer; t._answer = null; t.pending = null; t.status = 'running';
157
- emit(t, { type: 'answered', answer: String(text ?? '') });
213
+ emit(t, { type: 'answered', answer: String(text ?? '') }); persistTask(t);
158
214
  resolve(String(text ?? ''));
159
215
  return true;
160
216
  },
@@ -173,15 +229,31 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
173
229
  * @returns {import('node:http').Server}
174
230
  */
175
231
  export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
176
- const sessions = new Map(); // sessionId -> { pack, history }
232
+ const sessions = new Map(); // sessionId -> { history }
177
233
  mkdirSync(baseDir, { recursive: true });
178
234
 
235
+ // 對話 session 持久化(讓「繼續/調整」跨重啟可用):啟動載回 + 每次更新落地。
236
+ const sessDir = join(baseDir, 'sessions');
237
+ try { if (existsSync(sessDir)) for (const f of readdirSync(sessDir).filter((x) => x.endsWith('.json'))) { try { sessions.set(f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(sessDir, f), 'utf8'))); } catch { /* 略 */ } } } catch { /* 略 */ }
238
+ const persistSession = (id, sess) => { try { mkdirSync(sessDir, { recursive: true }); writeFileSync(join(sessDir, id + '.json'), JSON.stringify({ history: sess.history })); } catch { /* 略 */ } };
239
+
179
240
  const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
180
241
  // header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
181
242
  const authed = (req) => { if (!token) return true; if (req.headers.authorization === `Bearer ${token}`) return true; try { return new URL(req.url, 'http://x').searchParams.get('token') === token; } catch { return false; } };
182
243
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
183
244
  const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); });
184
245
  const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
246
+ // 依副檔名給 content-type 回傳檔案(圖片顯示/md 渲染/下載皆走這)。
247
+ const serveFile = (res, full, rel, download) => {
248
+ if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
249
+ try {
250
+ const ct = contentTypeFor(rel);
251
+ const isText = /^text\/|json|xml|javascript|svg/.test(ct);
252
+ const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
253
+ if (download) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
254
+ res.writeHead(200, headers); return res.end(readFileSync(full));
255
+ } catch (e) { return json(res, 500, { error: e.message }); }
256
+ };
185
257
 
186
258
  // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
187
259
  // ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
@@ -189,8 +261,9 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
189
261
  const make = PACKS[spec.pack || 'general'];
190
262
  if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
191
263
  // 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
192
- const workspace = (spec.workspace || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
193
- const workdir = join(baseDir, 'ws', workspace); mkdirSync(workdir, { recursive: true });
264
+ // 本地模式 + workspace 是絕對路徑 就地用該真實資料夾(像 Claude Code 改你現有的檔)。
265
+ const workspace = spec.workspace || 'default';
266
+ const workdir = workspaceDir(baseDir, workspace, local); mkdirSync(workdir, { recursive: true });
194
267
  // history 仍綁 sessionId(每個成品獨立對話:無 sessionId → 全新,不續接,避免 context 暴脹/混淆)
195
268
  const sessionId = spec.sessionId || newId();
196
269
  const sess = sessions.get(sessionId) || { history: [] };
@@ -200,13 +273,13 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
200
273
  if (spec.mode === 'goal') {
201
274
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
202
275
  const o = await kernel.runOutcome(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
203
- sess.history = o.history || []; sessions.set(sessionId, sess);
276
+ sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
204
277
  try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
205
278
  // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
206
279
  return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
207
280
  }
208
281
  const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
209
- sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
282
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
210
283
  return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
211
284
  }
212
285
 
@@ -221,6 +294,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
221
294
 
222
295
  const tasks = createTaskStore({
223
296
  concurrency,
297
+ persistDir: join(baseDir, 'tasks'),
224
298
  runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
225
299
  onFinish: (task) => { log({ task: task.id, pack: task.spec.pack, mode: task.spec.mode || 'turn', status: task.status, ms: task.startedAt ? Date.parse(task.finishedAt) - Date.parse(task.startedAt) : 0 }); fireWebhook(task); },
226
300
  });
@@ -234,7 +308,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
234
308
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
235
309
  let html; try { html = webHtml(); } catch { return json(res, 500, { error: 'web UI 未找到' }); }
236
310
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
237
- return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))));
311
+ return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))).replace(/__LOCAL__/g, local ? 'true' : 'false'));
238
312
  }
239
313
 
240
314
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
@@ -262,6 +336,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
262
336
  const body = await readBody(req);
263
337
  if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
264
338
  if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
339
+ if (local && body.workspace && isAbsolute(body.workspace) && !existsSync(body.workspace)) return json(res, 400, { error: `資料夾不存在:${body.workspace}` });
265
340
  const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook, workspace: body.workspace });
266
341
  log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
267
342
  return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
@@ -298,16 +373,37 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
298
373
  const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
299
374
  if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
300
375
  const rel = url.searchParams.get('path');
301
- const full = resolveArtifact(join(baseDir, 'ws', ws), rel);
376
+ const full = resolveArtifact(workspaceDir(baseDir, ws, local), rel);
302
377
  if (!full) return json(res, 400, { error: 'path 不合法' });
303
- if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
378
+ return serveFile(res, full, rel, url.searchParams.get('download'));
379
+ }
380
+
381
+ // 資料夾瀏覽器(僅本地模式):列某路徑下的子資料夾,給網頁「用選的」挑真實資料夾
382
+ if (req.method === 'GET' && path === '/v1/fs') {
383
+ if (!local) return json(res, 403, { error: '僅本地模式可瀏覽資料夾' });
384
+ const dir = resolve(url.searchParams.get('path') || homedir());
304
385
  try {
305
- const ct = contentTypeFor(rel);
306
- const isText = /^text\/|json|xml|javascript|svg/.test(ct);
307
- const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
308
- if (url.searchParams.get('download')) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
309
- res.writeHead(200, headers); return res.end(readFileSync(full));
310
- } catch (e) { return json(res, 500, { error: e.message }); }
386
+ const dirs = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.')).map((e) => e.name).sort();
387
+ return json(res, 200, { path: dir, parent: dirname(dir), home: homedir(), dirs });
388
+ } catch (e) { return json(res, 400, { error: '無法讀取:' + e.message }); }
389
+ }
390
+
391
+ // 工作台:逐層列檔(sub=子目錄,不一次遞迴攤平整個專案;ws query 以容納本地絕對路徑)
392
+ if (req.method === 'GET' && path === '/v1/workspaces/files') {
393
+ const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
394
+ return json(res, 200, listDir(dir, url.searchParams.get('sub') || '') || { sub: '', dirs: [], files: [] });
395
+ }
396
+ // 工作台:取檔(看/下載)/ 刪檔
397
+ if (path === '/v1/workspaces/file' && (req.method === 'GET' || req.method === 'DELETE')) {
398
+ const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
399
+ const rel = url.searchParams.get('path');
400
+ const full = resolveArtifact(dir, rel);
401
+ if (!full) return json(res, 400, { error: 'path 不合法' });
402
+ if (req.method === 'DELETE') {
403
+ try { if (existsSync(full)) rmSync(full); log({ action: 'delete', path: rel }); return json(res, 200, { ok: true }); }
404
+ catch (e) { return json(res, 500, { error: e.message }); }
405
+ }
406
+ return serveFile(res, full, rel, url.searchParams.get('download'));
311
407
  }
312
408
 
313
409
  // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
@@ -8,7 +8,14 @@
8
8
  :root { --bg:#0f1115; --card:#181b22; --line:#272b34; --fg:#e7e9ee; --dim:#8b919e; --accent:#6ea8fe; --ok:#5fd38a; --warn:#f0c674; --me:#222630; }
9
9
  * { box-sizing:border-box; }
10
10
  body { margin:0; font:15px/1.6 -apple-system,"PingFang TC","Microsoft JhengHei",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
11
- .wrap { max-width:760px; margin:0 auto; padding:28px 18px 80px; }
11
+ .wrap { max-width:1180px; margin:0 auto; padding:24px 22px 80px; }
12
+ /* 雙欄佈局:許願頁=主區+歷史側欄;工作台=檔案左+預覽右。窄螢幕自動收成單欄 */
13
+ .cols { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:26px; align-items:start; }
14
+ .cols2 { display:grid; grid-template-columns:340px minmax(0,1fr); gap:26px; align-items:start; }
15
+ .side, .wbleft { position:sticky; top:14px; }
16
+ .side h3, .wbleft h3 { margin-top:0; }
17
+ .wbright { position:sticky; top:14px; min-height:120px; }
18
+ @media (max-width:860px){ .cols,.cols2{ grid-template-columns:1fr; } .side,.wbleft,.wbright{ position:static; } }
12
19
  header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
13
20
  header h1 { font-size:22px; margin:0; }
14
21
  header .sub { color:var(--dim); font-size:13px; }
@@ -65,6 +72,23 @@
65
72
  .hist:hover { border-color:var(--accent); }
66
73
  .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
67
74
  .empty { color:var(--dim); font-size:13px; }
75
+ .tabs { display:flex; gap:4px; border-bottom:1px solid var(--line); margin:14px 0 4px; }
76
+ .tab { padding:8px 16px; font-size:14px; color:var(--dim); cursor:pointer; border-bottom:2px solid transparent; }
77
+ .tab.active { color:var(--fg); border-bottom-color:var(--accent); }
78
+ .wbrow { display:flex; align-items:center; gap:10px; padding:9px 12px; background:var(--card); border:1px solid var(--line); border-radius:8px; margin:6px 0; }
79
+ .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
80
+ .wbmeta { color:var(--dim); font-size:12px; }
81
+ .wbdel { cursor:pointer; opacity:.6; } .wbdel:hover { opacity:1; }
82
+ .followup { margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
83
+ .followup textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:10px; font:inherit; resize:vertical; min-height:52px; margin-bottom:8px; }
84
+ .cont { color:var(--accent); }
85
+ .modal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:flex; align-items:center; justify-content:center; z-index:10; }
86
+ .modalbox { background:var(--card); border:1px solid var(--line); border-radius:14px; width:min(560px,92vw); max-height:80vh; display:flex; flex-direction:column; padding:14px; }
87
+ .fspath { font:12px ui-monospace,Menlo,monospace; color:var(--dim); margin-bottom:8px; word-break:break-all; }
88
+ .fslist { flex:1; overflow:auto; border:1px solid var(--line); border-radius:8px; }
89
+ .fsrow { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--line); font-size:14px; }
90
+ .fsrow:hover { background:#0c0e12; } .fsrow.up { color:var(--dim); }
91
+ .fsbar { display:flex; align-items:center; gap:8px; margin-top:10px; }
68
92
  .viewer { margin-top:10px; border:1px solid var(--line); border-radius:10px; padding:12px; background:#0c0e12; }
69
93
  .vbar { font-size:12px; color:var(--dim); margin-bottom:8px; }
70
94
  .vbar a { color:var(--accent); text-decoration:none; }
@@ -84,27 +108,63 @@
84
108
  <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
85
109
  <span class="spacer"></span>
86
110
  <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
111
+ <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選資料夾</button>
87
112
  <button class="ghost" id="newspace" title="新專案">+</button>
88
113
  </header>
89
114
 
90
- <div class="ask">
91
- <textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
92
- <div class="row">
93
- <select id="pack" title="領域"></select>
94
- <span class="spacer"></span>
95
- <button id="go">交辦 →</button>
115
+ <div id="fsmodal" class="modal" style="display:none">
116
+ <div class="modalbox">
117
+ <div style="margin-bottom:8px;font-weight:600">選一個資料夾(就地工作)</div>
118
+ <div class="fspath" id="fspath"></div>
119
+ <div class="fslist" id="fslist"></div>
120
+ <div class="fsbar">
121
+ <button class="ghost" onclick="fsHome()">🏠 家目錄</button>
122
+ <button class="ghost" onclick="closeFs()">取消</button>
123
+ <span class="spacer"></span>
124
+ <button onclick="chooseFs()">✓ 選這個資料夾</button>
125
+ </div>
96
126
  </div>
97
127
  </div>
98
128
 
99
- <div id="current"></div>
129
+ <div class="tabs">
130
+ <span class="tab active" id="tab-wish" onclick="switchTab('wish')">許願</span>
131
+ <span class="tab" id="tab-wb" onclick="switchTab('wb')">📂 工作台</span>
132
+ </div>
133
+
134
+ <div id="wishview" class="cols">
135
+ <div class="main">
136
+ <div class="ask">
137
+ <textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
138
+ <div class="row">
139
+ <select id="pack" title="領域"></select>
140
+ <span class="spacer"></span>
141
+ <button id="go">交辦 →</button>
142
+ </div>
143
+ </div>
144
+ <div id="current"></div>
145
+ </div>
146
+ <aside class="side">
147
+ <h3>歷史成品</h3>
148
+ <div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
149
+ </aside>
150
+ </div>
100
151
 
101
- <h3>歷史成品</h3>
102
- <div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
152
+ <div id="workbench" class="cols2" style="display:none">
153
+ <div class="wbleft">
154
+ <h3>📂 工作台</h3>
155
+ <div id="wbfiles"></div>
156
+ </div>
157
+ <div class="wbright">
158
+ <div id="wbhint" class="empty">← 點左邊的檔案,在這裡預覽</div>
159
+ <div class="viewer" id="wbview" style="display:none"></div>
160
+ </div>
161
+ </div>
103
162
  </div>
104
163
 
105
164
  <script>
106
165
  const TOKEN = "__SERVER_TOKEN__";
107
166
  const PACKS = __PACKS__;
167
+ const LOCAL = __LOCAL__; // 本地模式:可把專案指向真實資料夾(就地工作,像 Claude Code)
108
168
  const $ = (s) => document.querySelector(s);
109
169
  const api = (p, opts={}) => fetch(p, { ...opts, headers: { authorization:"Bearer "+TOKEN, "content-type":"application/json", ...(opts.headers||{}) } });
110
170
  const esc = (s) => String(s==null?"":s).replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
@@ -132,11 +192,36 @@ $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selec
132
192
  // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
133
193
  let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
134
194
  let curSpace = localStorage.getItem("xk_space")||"default";
135
- function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option ${s===curSpace?"selected":""}>${esc(s)}</option>`).join(""); }
136
- $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); };
137
- $("#newspace").onclick = () => { const n=(prompt("新專案名稱(英數/底線/連字號):")||"").trim().replace(/[^a-zA-Z0-9_-]/g,""); if(!n)return; if(!spaces.includes(n))spaces.push(n); curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); $("#current").innerHTML=""; loadHistory(); };
195
+ const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
196
+ function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option value="${esc(s)}" ${s===curSpace?"selected":""}>${esc(spaceLabel(s))}</option>`).join(""); }
197
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); if($("#workbench").style.display!=="none"){ wbSub=""; loadWorkbench(); } };
198
+ $("#newspace").onclick = () => {
199
+ const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
200
+ if(!raw) return;
201
+ const n = (LOCAL && raw.startsWith("/")) ? raw : raw.replace(/[^a-zA-Z0-9_-]/g,"");
202
+ if(!n) return;
203
+ if(!spaces.includes(n)) spaces.push(n);
204
+ curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
205
+ renderSpaces(); $("#current").innerHTML=""; loadHistory();
206
+ };
138
207
  renderSpaces();
139
208
 
209
+ // 資料夾瀏覽器(本地模式「用選的」):伺服器端列資料夾,網頁點進去挑一個
210
+ if(LOCAL) $("#browsebtn").style.display="";
211
+ $("#browsebtn").onclick = ()=>{ $("#fsmodal").style.display="flex"; fsGo(null); };
212
+ let fsPath=null;
213
+ async function fsGo(p){
214
+ const r=await api("/v1/fs"+(p?("?path="+encodeURIComponent(p)):"")).then(r=>r.json()).catch(()=>({error:"讀取失敗"}));
215
+ if(r.error){ alert(r.error); return; }
216
+ fsPath=r.path; window._fsHome=r.home;
217
+ $("#fspath").textContent="📂 "+r.path;
218
+ const base=r.path.endsWith("/")?r.path:r.path+"/";
219
+ $("#fslist").innerHTML=`<div class="fsrow up" onclick="fsGo('${esc(r.parent)}')">⬆ 上一層</div>`+(r.dirs.length?r.dirs.map(d=>`<div class="fsrow" onclick="fsGo('${esc(base+d)}')">📁 ${esc(d)}</div>`).join(""):`<div class="empty" style="padding:10px">(沒有子資料夾,可直接選這個)</div>`);
220
+ }
221
+ function fsHome(){ fsGo(window._fsHome||null); }
222
+ function closeFs(){ $("#fsmodal").style.display="none"; }
223
+ function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML=""; loadHistory(); }
224
+
140
225
  // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
141
226
  function mdRender(src){
142
227
  const lines=String(src).replace(/\r/g,"").split("\n"); const out=[]; let inCode=false,buf=[],inList=false;
@@ -154,7 +239,10 @@ function mdRender(src){
154
239
  return out.join("");
155
240
  }
156
241
  const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
157
- const fileUrl=(id,path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+"&token="+encodeURIComponent(TOKEN)+extra;
242
+ // 取檔 URL 產生器(任務成品走 /tasks/<id>,工作台走 /workspaces?ws=,後者 ws 可為本地絕對路徑)
243
+ const TOK="&token="+encodeURIComponent(TOKEN);
244
+ const taskFileUrl=id=>(path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+TOK+extra;
245
+ const wsFileUrl=ws=>(path,extra="")=>"/v1/workspaces/file?ws="+encodeURIComponent(ws)+"&path="+encodeURIComponent(path)+TOK+extra;
158
246
  // 彩色 diff(綠 +/紅 -):渲染 kernel 算好的 _diff
159
247
  function diffHtml(d){
160
248
  if(!d) return "";
@@ -171,13 +259,13 @@ function logHtml(p){
171
259
  let expandedLog=false;
172
260
  function toggleLog(){ expandedLog=!expandedLog; if(liveTask) renderCurrent(liveTask); }
173
261
 
174
- async function viewFile(id, encPath, name){
175
- const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
176
- const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${fileUrl(id,path)}" target="_blank">開新分頁</a> · <a href="${fileUrl(id,path,'&download=1')}">下載</a></div>`;
177
- if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${fileUrl(id,path)}">`; return; }
178
- if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${fileUrl(id,path)}"></iframe>`; return; }
262
+ async function renderFile(urlFn, encPath, name, sel){
263
+ const path=decodeURIComponent(encPath); const v=document.querySelector(sel); v.style.display="block";
264
+ const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${urlFn(path)}" target="_blank">開新分頁</a> · <a href="${urlFn(path,'&download=1')}">下載</a></div>`;
265
+ if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${urlFn(path)}">`; return; }
266
+ if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${urlFn(path)}"></iframe>`; return; }
179
267
  v.innerHTML=bar+`<div class="empty">載入中…</div>`;
180
- const txt=await fetch(fileUrl(id,path)).then(r=>r.ok?r.text():null).catch(()=>null);
268
+ const txt=await fetch(urlFn(path)).then(r=>r.ok?r.text():null).catch(()=>null);
181
269
  if(txt==null){ v.innerHTML=bar+`<div class="empty">(無法以文字呈現,請下載)</div>`; return; }
182
270
  let body;
183
271
  if(MD.test(name)) body=`<div class="md">${mdRender(txt)}</div>`;
@@ -185,6 +273,34 @@ async function viewFile(id, encPath, name){
185
273
  else body=`<pre class="viewer-pre">${esc(txt)}</pre>`;
186
274
  v.innerHTML=bar+body;
187
275
  }
276
+ function viewFile(id, encPath, name){ return renderFile(taskFileUrl(id), encPath, name, "#fview"); }
277
+ function wbView(encPath, name){ const h=$("#wbhint"); if(h) h.style.display="none"; return renderFile(wsFileUrl(curSpace), encPath, name, "#wbview"); }
278
+
279
+ // 工作台分頁:逐層瀏覽 / 看 / 刪
280
+ function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
281
+ let wbSub="";
282
+ function wbNav(s){ wbSub=decodeURIComponent(s); loadWorkbench(); }
283
+ function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadWorkbench(); }
284
+ async function loadWorkbench(){
285
+ $("#wbview").style.display="none"; { const h=$("#wbhint"); if(h) h.style.display=""; }
286
+ const r=await api("/v1/workspaces/files?ws="+encodeURIComponent(curSpace)+"&sub="+encodeURIComponent(wbSub)).then(r=>r.json()).catch(()=>({dirs:[],files:[]}));
287
+ const root=curSpace.startsWith("/")?curSpace:curSpace;
288
+ let html=`<div class="loc">📂 ${esc(root)}${wbSub?" / "+esc(wbSub):""}</div>`;
289
+ if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
290
+ html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
291
+ html+=(r.files||[]).map(f=>{const full=(wbSub?wbSub+'/':'')+f.name;return `<div class="wbrow"><span class="wbname" onclick="wbView('${encodeURIComponent(full)}','${esc(f.name)}')">📄 ${esc(f.name)}</span><span class="wbmeta">${fmtSize(f.size)}</span><span class="wbdel" title="刪除" onclick="wbDelete('${encodeURIComponent(full)}','${esc(f.name)}')">🗑</span></div>`;}).join("");
292
+ if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"這個資料夾是空的":"這個專案還沒有檔案,去「許願」交辦一件事吧"})</div>`;
293
+ $("#wbfiles").innerHTML=html;
294
+ }
295
+ async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"}); loadWorkbench(); }
296
+ function switchTab(name){
297
+ const wish=name==="wish";
298
+ $("#wishview").style.display=wish?"":"none";
299
+ $("#workbench").style.display=wish?"none":"";
300
+ $("#tab-wish").classList.toggle("active",wish);
301
+ $("#tab-wb").classList.toggle("active",!wish);
302
+ if(!wish){ wbSub=""; loadWorkbench(); }
303
+ }
188
304
 
189
305
  let activeId = null, polling = null;
190
306
 
@@ -199,8 +315,8 @@ $("#go").onclick = async () => {
199
315
  poll();
200
316
  };
201
317
 
202
- function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled")?"error":""; }
203
- function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷"})[s]||s; }
318
+ function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
319
+ function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
204
320
  const CANCELLABLE = ["queued","running","needs-input"];
205
321
  function todosHtml(p){ if(!p||!(p.todos||[]).length) return ""; const ic=s=>s==="completed"?"☑":s==="in_progress"?"◐":"☐"; return `<div class="todos">${p.todos.map(td=>`<div class="todo ${td.status}">${ic(td.status)} ${esc(td.content)}</div>`).join("")}</div>`; }
206
322
  async function cancelTask(id){ await api("/v1/tasks/"+id+"/cancel",{method:"POST"}); for(let i=0;i<10;i++){ await new Promise(r=>setTimeout(r,600)); const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(["done","error","cancelled"].includes(t.status)){loadHistory();break;} } }
@@ -233,6 +349,13 @@ function renderCurrent(t) {
233
349
  ${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${encodeURIComponent(f)}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
234
350
  ${t.result&&t.result.workspaceDir?`<div class="loc">📂 檔案位置:<code title="點擊複製" onclick="navigator.clipboard&&navigator.clipboard.writeText('${esc(t.result.workspaceDir)}')">${esc(t.result.workspaceDir)}</code></div>`:""}
235
351
  <div class="viewer" id="fview" style="display:none"></div>
352
+ ${t.status==="done"?`<div class="followup">
353
+ <button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
354
+ <div id="fubox" style="display:none;margin-top:8px">
355
+ <textarea id="fugoal" placeholder="想怎麼調整或繼續深入?(會接續這次的對話與檔案,agent 記得來龍去脈)"></textarea>
356
+ <button onclick="submitFollowup('${esc(t.sessionId||"")}','${esc(t.pack||"general")}','${esc(t.workspace||"default")}')">送出 →</button>
357
+ </div>
358
+ </div>`:""}
236
359
  </div>`;
237
360
  if (t.status==="needs-input") {
238
361
  const inp = $("#ans"); inp.focus();
@@ -244,11 +367,20 @@ function renderCurrent(t) {
244
367
  }
245
368
  }
246
369
 
370
+ // 繼續/調整:送出接續上一個任務對話(sessionId)+ 同工作區的後續任務
371
+ function toggleFollowup(){ const b=$("#fubox"); if(b){ b.style.display=b.style.display==="none"?"":"none"; if(b.style.display!=="none") $("#fugoal").focus(); } }
372
+ async function submitFollowup(sessionId, pack, workspace){
373
+ const goal=$("#fugoal").value.trim(); if(!goal) return;
374
+ const r=await api("/v1/tasks",{method:"POST",body:JSON.stringify({pack, mode:"goal", goal, workspace, sessionId})}).then(r=>r.json());
375
+ if(r.error){ alert(r.error); return; }
376
+ activeId=r.taskId; expandedLog=false; liveTask=null; poll(); window.scrollTo({top:0,behavior:"smooth"});
377
+ }
378
+
247
379
  async function loadHistory() {
248
380
  const r = await api("/v1/tasks").then(r=>r.json());
249
381
  const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
250
382
  $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
251
- <div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
383
+ <div class="g">${t.continued?'<span class="cont" title="接續前一個任務">↳</span> ':''}${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
252
384
  <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
253
385
  }
254
386
  async function openTask(id){ activeId=id; const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(t.status==="running"||t.status==="queued"){poll();} window.scrollTo({top:0,behavior:"smooth"}); }