xitto-kernel 0.6.4 → 0.8.4
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 +56 -0
- package/README.md +9 -0
- package/package.json +2 -1
- package/src/app/server.js +86 -13
- package/src/app/web/index.html +153 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.4
|
|
4
|
+
|
|
5
|
+
- **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
|
|
6
|
+
- **許願頁** = 主區(許願輸入 + 當前任務/成品)+ **歷史側欄**(sticky,捲動主區時歷史保持可見)
|
|
7
|
+
- **工作台** = 檔案瀏覽器在左、**檔案預覽在右**(並排,不必上下捲)
|
|
8
|
+
- 容器加寬到 1180px;` (max-width:860px)` 自動收成單欄(窄螢幕/手機不變)
|
|
9
|
+
- 純 CSS/HTML 結構調整;測試 188/188,內嵌 JS 語法檢查 + 服務頁面結構驗證通過
|
|
10
|
+
|
|
11
|
+
## 0.8.3
|
|
12
|
+
|
|
13
|
+
- **工作台改逐層瀏覽(不一次攤平整個專案)**:原本 `listWorkspaceFiles` 會遞迴把所有檔案攤成一長串,對真實專案太雜。改成像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,有「上一層」。
|
|
14
|
+
- 新增 `listDir(wsDir, sub)`(列單層,排除內部目錄,防穿越);`/v1/workspaces/files` 改吃 `sub=` 逐層
|
|
15
|
+
- 網頁工作台改可導航(麵包屑 + 進子資料夾 + 上一層);切換空間/分頁時重置到根
|
|
16
|
+
- 1 個新測試(listDir 不遞迴/子目錄分開/防穿越)+ 真實 server 端到端(根→src→src/utils)。測試 188/188
|
|
17
|
+
|
|
18
|
+
## 0.8.2
|
|
19
|
+
|
|
20
|
+
- **`npm run serve:local`**:一行啟動本地就地模式(= `XITTO_SERVER_LOCAL=1 XITTO_SERVER_SANDBOX=off`,token 預設 `secret`、可用 `XITTO_SERVER_TOKEN` 覆寫)。不用每次手打那串環境變數。
|
|
21
|
+
|
|
22
|
+
## 0.8.1
|
|
23
|
+
|
|
24
|
+
- **資料夾用「選」的(本地模式)**:不用打路徑。
|
|
25
|
+
- 瀏覽器原生選資料夾拿不到絕對路徑(安全限制),改由 **local server 端列資料夾** → 網頁「📁 選資料夾」鈕,從家目錄點進去挑一個
|
|
26
|
+
- 新增 `GET /v1/fs?path=`(**僅本地模式**;列子資料夾,排除 . 開頭/node_modules;託管模式回 403,不洩漏主機結構)
|
|
27
|
+
- 網頁資料夾瀏覽器 modal(上一層/家目錄/選定);空間下拉以 📁 標真實資料夾
|
|
28
|
+
- 1 個新測試(/v1/fs 本地列檔 / 託管 403)+ 真實 server 端到端(家目錄→xiza→選 xitto*)。測試 187/187
|
|
29
|
+
|
|
30
|
+
## 0.8.0
|
|
31
|
+
|
|
32
|
+
- **本地就地模式(許願台像 Claude Code 改你選的真實資料夾)**:打通「隔離(許願台)」與「就地(Claude Code)」兩個檔案模型。
|
|
33
|
+
- `XITTO_SERVER_LOCAL=1` 時,workspace 可為**真實資料夾的絕對路徑** → 任務**就地改該資料夾的檔**(無隔離副本);網頁「新專案」可貼路徑
|
|
34
|
+
- 新增 `workspaceDir(baseDir, ws, local)`:local + 絕對路徑 → 就地;**否則(含託管收到絕對路徑)→ 消毒成管理空間,不逃逸**
|
|
35
|
+
- 工作台端點改 query `?ws=`(容納絕對路徑);in-place 資料夾不存在 → 400
|
|
36
|
+
- 網頁注入 `__LOCAL__`;空間下拉以 `📁` 標真實資料夾
|
|
37
|
+
- 4 個新測試(workspaceDir 就地/不逃逸/管理)+ 真實 server 端到端
|
|
38
|
+
(local:就地改 /tmp/myproj/calc.js 的 a-b→a+b、無副本;hosted:絕對路徑被消毒不逃逸)。測試 186/186
|
|
39
|
+
|
|
40
|
+
## 0.7.1
|
|
41
|
+
|
|
42
|
+
- **成品迭代「繼續/調整」(迭代有脈絡)**:在許願台補上迭代閉環。
|
|
43
|
+
- 完成的成品上加「↳ 繼續/調整這個成果」→ 送出**後續任務**,接續這次的對話(`sessionId`)+ 同工作區
|
|
44
|
+
→ agent 同時有「**檔案 + 當時的討論與理由**」,不只是檔案
|
|
45
|
+
- 預設每個許願是乾淨新對話(不暴脹);按「繼續」才接續那條線(像 ChatGPT 新對話 vs 接著聊)
|
|
46
|
+
- 任務 view 加 `continued`(帶 sessionId = 接續);歷史以 `↳` 標出接續鏈
|
|
47
|
+
- 底層 sessionId 接續 kernel 本已支援,本版只是接到 UI + 標記
|
|
48
|
+
- 1 個測試(view.continued)+ 真實 server 端到端:任務1 私下說「最不喜歡 Go」(只在對話、不在檔案)→ 後續任務「刪掉我最不喜歡的」→ 正確刪掉 Go(langs.txt 剩 Rust/Zig)。測試 185/185
|
|
49
|
+
|
|
50
|
+
## 0.7.0
|
|
51
|
+
|
|
52
|
+
- **工作台分頁(看見並管理持久工作區)**:許願台加「許願 | 📂 工作台」**同頁分頁**(不是另開頁面,共用空間/檢視器/認證)。
|
|
53
|
+
- **許願**=交辦任務的主流程(不變);**工作台**=列出當前專案累積的**所有檔案**(看/下載/刪)
|
|
54
|
+
- server 新增 `GET /v1/workspaces/:ws/files`(列檔,排除 .xitto-kernel/tmp/node_modules)、`GET/DELETE /v1/workspaces/:ws/file`(取/刪,防穿越)
|
|
55
|
+
- 檔案檢視器抽為通用 `renderFile(base,…)`,任務成品與工作台共用;`serveFile` 抽出(content-type/下載)
|
|
56
|
+
- 讓持久工作空間從「半成品(檔案累積但看不見)」變成可見可管理;刻意保持輕量(檔案清單,非 IDE)
|
|
57
|
+
- 4 個新測試(safeWs 防穿越 / listWorkspaceFiles 排除內部目錄)+ 真實 server 端到端(兩任務累積→列/取/刪/防穿越)。測試 184/184
|
|
58
|
+
|
|
3
59
|
## 0.6.4
|
|
4
60
|
|
|
5
61
|
- **許願台「展開過程」+ 彩色 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,17 @@ 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
|
+
|
|
112
121
|
**溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
|
|
113
122
|
|
|
114
123
|
零依賴單一 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.
|
|
3
|
+
"version": "0.8.4",
|
|
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, 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;
|
|
@@ -67,7 +106,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
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
|
+
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
110
|
|
|
72
111
|
const emit = (t, ev) => {
|
|
73
112
|
t.events.push(ev);
|
|
@@ -182,6 +221,17 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
182
221
|
const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
|
|
183
222
|
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
223
|
const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
224
|
+
// 依副檔名給 content-type 回傳檔案(圖片顯示/md 渲染/下載皆走這)。
|
|
225
|
+
const serveFile = (res, full, rel, download) => {
|
|
226
|
+
if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
|
|
227
|
+
try {
|
|
228
|
+
const ct = contentTypeFor(rel);
|
|
229
|
+
const isText = /^text\/|json|xml|javascript|svg/.test(ct);
|
|
230
|
+
const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
|
|
231
|
+
if (download) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
|
|
232
|
+
res.writeHead(200, headers); return res.end(readFileSync(full));
|
|
233
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
234
|
+
};
|
|
185
235
|
|
|
186
236
|
// 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
|
|
187
237
|
// ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
|
|
@@ -189,8 +239,9 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
189
239
|
const make = PACKS[spec.pack || 'general'];
|
|
190
240
|
if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
|
|
191
241
|
// 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
|
|
192
|
-
|
|
193
|
-
const
|
|
242
|
+
// 本地模式 + workspace 是絕對路徑 → 就地用該真實資料夾(像 Claude Code 改你現有的檔)。
|
|
243
|
+
const workspace = spec.workspace || 'default';
|
|
244
|
+
const workdir = workspaceDir(baseDir, workspace, local); mkdirSync(workdir, { recursive: true });
|
|
194
245
|
// history 仍綁 sessionId(每個成品獨立對話:無 sessionId → 全新,不續接,避免 context 暴脹/混淆)
|
|
195
246
|
const sessionId = spec.sessionId || newId();
|
|
196
247
|
const sess = sessions.get(sessionId) || { history: [] };
|
|
@@ -234,7 +285,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
234
285
|
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
235
286
|
let html; try { html = webHtml(); } catch { return json(res, 500, { error: 'web UI 未找到' }); }
|
|
236
287
|
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))));
|
|
288
|
+
return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))).replace(/__LOCAL__/g, local ? 'true' : 'false'));
|
|
238
289
|
}
|
|
239
290
|
|
|
240
291
|
if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
|
|
@@ -262,6 +313,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
262
313
|
const body = await readBody(req);
|
|
263
314
|
if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
|
|
264
315
|
if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
|
|
316
|
+
if (local && body.workspace && isAbsolute(body.workspace) && !existsSync(body.workspace)) return json(res, 400, { error: `資料夾不存在:${body.workspace}` });
|
|
265
317
|
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
318
|
log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
|
|
267
319
|
return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
|
|
@@ -298,16 +350,37 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
298
350
|
const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
|
|
299
351
|
if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
|
|
300
352
|
const rel = url.searchParams.get('path');
|
|
301
|
-
const full = resolveArtifact(
|
|
353
|
+
const full = resolveArtifact(workspaceDir(baseDir, ws, local), rel);
|
|
302
354
|
if (!full) return json(res, 400, { error: 'path 不合法' });
|
|
303
|
-
|
|
355
|
+
return serveFile(res, full, rel, url.searchParams.get('download'));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 資料夾瀏覽器(僅本地模式):列某路徑下的子資料夾,給網頁「用選的」挑真實資料夾
|
|
359
|
+
if (req.method === 'GET' && path === '/v1/fs') {
|
|
360
|
+
if (!local) return json(res, 403, { error: '僅本地模式可瀏覽資料夾' });
|
|
361
|
+
const dir = resolve(url.searchParams.get('path') || homedir());
|
|
304
362
|
try {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
363
|
+
const dirs = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.')).map((e) => e.name).sort();
|
|
364
|
+
return json(res, 200, { path: dir, parent: dirname(dir), home: homedir(), dirs });
|
|
365
|
+
} catch (e) { return json(res, 400, { error: '無法讀取:' + e.message }); }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 工作台:逐層列檔(sub=子目錄,不一次遞迴攤平整個專案;ws 走 query 以容納本地絕對路徑)
|
|
369
|
+
if (req.method === 'GET' && path === '/v1/workspaces/files') {
|
|
370
|
+
const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
|
|
371
|
+
return json(res, 200, listDir(dir, url.searchParams.get('sub') || '') || { sub: '', dirs: [], files: [] });
|
|
372
|
+
}
|
|
373
|
+
// 工作台:取檔(看/下載)/ 刪檔
|
|
374
|
+
if (path === '/v1/workspaces/file' && (req.method === 'GET' || req.method === 'DELETE')) {
|
|
375
|
+
const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
|
|
376
|
+
const rel = url.searchParams.get('path');
|
|
377
|
+
const full = resolveArtifact(dir, rel);
|
|
378
|
+
if (!full) return json(res, 400, { error: 'path 不合法' });
|
|
379
|
+
if (req.method === 'DELETE') {
|
|
380
|
+
try { if (existsSync(full)) rmSync(full); log({ action: 'delete', path: rel }); return json(res, 200, { ok: true }); }
|
|
381
|
+
catch (e) { return json(res, 500, { error: e.message }); }
|
|
382
|
+
}
|
|
383
|
+
return serveFile(res, full, rel, url.searchParams.get('download'));
|
|
311
384
|
}
|
|
312
385
|
|
|
313
386
|
// 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
|
package/src/app/web/index.html
CHANGED
|
@@ -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:
|
|
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="
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
<
|
|
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
|
|
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
|
-
<
|
|
102
|
-
|
|
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=>({"&":"&","<":"<",">":">"}[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
|
-
|
|
136
|
-
$("#space").
|
|
137
|
-
$("#
|
|
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
|
-
|
|
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
|
|
175
|
-
const path=decodeURIComponent(encPath); const v
|
|
176
|
-
const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${
|
|
177
|
-
if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${
|
|
178
|
-
if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${
|
|
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(
|
|
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
|
|
|
@@ -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"}); }
|