xitto-kernel 0.4.0 → 0.6.1
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 +98 -0
- package/README.md +34 -0
- package/package.json +1 -1
- package/src/app/cli.js +14 -0
- package/src/app/main.js +11 -3
- package/src/app/server.js +141 -21
- package/src/app/web/index.html +231 -0
- package/src/kernel/index.js +56 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
- **成品溯源/位置**:分邏輯與實體兩層。
|
|
6
|
+
- **邏輯位置(workspace)**:成品卡永遠標出 `📁 所屬空間`,一眼知道每份成品屬於哪個專案
|
|
7
|
+
- **實體路徑**:預設**不外露**(託管不洩漏伺服器絕對路徑);僅**本地模式**(`XITTO_SERVER_LOCAL=1` 或 `createServerApp({local:true})`)在 result 附 `workspaceDir`,網頁顯示「📂 檔案位置」(點擊複製,供到 Finder/Explorer 找檔)
|
|
8
|
+
- 真實 live 驗證:本地模式回絕對路徑 `/…/ws/<workspace>`、託管模式 `workspaceDir` 為 undefined。測試 174/174。
|
|
9
|
+
|
|
10
|
+
## 0.6.0
|
|
11
|
+
|
|
12
|
+
成品管理 + 類型感知呈現 + 專案空間(一次補上三組優化)。
|
|
13
|
+
|
|
14
|
+
- **成品/過程檔管理**:
|
|
15
|
+
- 系統提示引導 agent:成品放工作目錄根用好檔名,暫存/草稿放 `tmp/`
|
|
16
|
+
- `runOutcome` 的成品掃描排除 `tmp/`(過程檔不污染交付清單);job 完成後 server 自動清 `tmp/`
|
|
17
|
+
- **類型感知的成品呈現**:
|
|
18
|
+
- file 端點按副檔名給對的 `content-type`(圖片能顯示、md/html 能渲染),支援 `?download=1`、二進位正確回傳
|
|
19
|
+
- 網頁類型感知檢視:markdown **排版渲染**(零依賴內嵌渲染器)、圖片 `<img>`、HTML 沙箱 iframe、JSON 美化、其餘下載;每個檔有「開新分頁/下載」
|
|
20
|
+
- `?token=` 查詢參數認證(img/iframe/下載這類無法帶 header 的瀏覽器 GET)
|
|
21
|
+
- **專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設)**:
|
|
22
|
+
- 網頁加「專案」下拉 + 新專案;不同空間的**檔案與五層沉澱各自獨立**;歷史按空間過濾
|
|
23
|
+
- 任務 view 帶 `workspace`;POST `/v1/tasks` 接受 `workspace`(修:原本被 enqueue 丟棄)
|
|
24
|
+
- 3 個新測試(content-type / view.workspace / tmp 不算成品)+ 真實 server 端到端
|
|
25
|
+
(markdown 成品渲染、tmp 清理、download header、query token、workspace 隔離)。測試 174/174。
|
|
26
|
+
|
|
27
|
+
## 0.5.0
|
|
28
|
+
|
|
29
|
+
- **持久工作空間(許願台成品間的關係)**:每個成品仍是獨立對話,但共用一個持久工作空間。
|
|
30
|
+
- server workdir 改綁 `workspace`(`.xitto-server/ws/<workspace>`,預設 `default`)而非每 job 丟棄式 sessionId
|
|
31
|
+
- 效果:① 檔案留存,後續任務能接續前面成果;② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——越用越懂你
|
|
32
|
+
- history 仍每 job 獨立(不續接,避免 context 暴脹);`workspace` 可在 POST 指定(多使用者隔離)
|
|
33
|
+
- 交付檔案端點與 webhook 改用 workspace 解析;result 帶 `workspace`
|
|
34
|
+
- **待辦打勾**:`todo_write` 的清單進 `progress.todos`,UI 顯示 ☐/◐/☑(把「未知時長」變「可見剩餘步數」,對標 Claude Code)
|
|
35
|
+
- **可中斷(取消鈕)**:`POST /v1/tasks/:id/cancel` → abort 進行中 agent / 移除排隊 / 解除待答;UI「停止」鈕;狀態 `cancelled`
|
|
36
|
+
- 5 個新測試(取消 running/queued/已結束/待答 + todo 進度)+ 真實 server 端到端
|
|
37
|
+
(Job2 接續 Job1 的檔案與記憶、todo 打勾、長任務中途取消)
|
|
38
|
+
- 緣由:對標 Claude Code 處理「等待焦慮」——liveness(心跳)+ transparency(進度/待辦)+ control(可中斷)
|
|
39
|
+
|
|
40
|
+
## 0.4.6
|
|
41
|
+
|
|
42
|
+
- **許願台「活著的證明」**:解決「只顯示進行中、不知道是否真的在跑」。
|
|
43
|
+
- **每秒心跳時鐘**「已進行 Ns」:UI 端 1 秒 ticker(不靠 poll),即使沒有新事件也持續跳動 → 看得到它活著
|
|
44
|
+
- **思考文字可見**:progress 新增 `thinking`——累積 agent 當下串流的文字,在「思考中」階段顯示 💭 它在想什麼
|
|
45
|
+
(tool/round 後清空;不存進 view 的 buffer 用 `t._textbuf`)
|
|
46
|
+
- phase 新增 `thinking`;poll 由 1500ms 縮到 1200ms
|
|
47
|
+
- 真實 live 驗證:時鐘 0→16s 連續跳動,階段 starting→thinking(💭)→acting(建檔→讀檔)→done
|
|
48
|
+
- 1 個新測試(text 事件累積 thinking、tool/round 清空)。測試 166/166。
|
|
49
|
+
|
|
50
|
+
## 0.4.5
|
|
51
|
+
|
|
52
|
+
- **修:CLI 澄清提問被 spinner 蓋住,導致回答疑似沒被採用**。
|
|
53
|
+
- `askUserQuestion` / `askConfirm` 提問前先 `stopSpin()`——否則「思考中…」spinner 每 100ms 覆蓋掉
|
|
54
|
+
`❓ 問題` 與你的輸入列,使用者根本看不到 agent 在問,打的字也對不上 → 看起來像回答被忽略
|
|
55
|
+
- ask_user 提問加上「agent 想問你:」更醒目
|
|
56
|
+
- `ask_user` 工具結果改為自帶 `{ question, answer, note }`:把回答標為權威依據,長對話也不脫鉤
|
|
57
|
+
- 驗證:kernel/server 的回答鏈本來就正確(單輪 + 多輪 live 測試:round-1 回答在 round-2 仍被採用);
|
|
58
|
+
本修針對互動 CLI 的顯示遮蔽問題
|
|
59
|
+
|
|
60
|
+
## 0.4.4
|
|
61
|
+
|
|
62
|
+
- **即時進度(許願台不再只顯示「進行中」)**:讓非技術使用者看得到 agent 在做什麼。
|
|
63
|
+
- 任務 view 新增 `progress`:`{ phase, round, steps, recent[] }`——從事件流累積(排除 text 雜訊)
|
|
64
|
+
- `mapEvent` 補 `round` / `verify`→`phase` 事件;goal 模式 wire `onRound` → 進度有輪數
|
|
65
|
+
- 網頁把工具動作翻成人話(讀取檔案/執行指令/搜尋網路…)+ 第幾輪 + 動作數 + 動畫指示,取代靜態轉圈
|
|
66
|
+
- `recent` 只留最近 6 個動作避免膨脹
|
|
67
|
+
- 3 個測試(mapEvent 新事件 + progress 累積/上限)+ 真實端到端(進度快照逐步演進)
|
|
68
|
+
|
|
69
|
+
## 0.4.3
|
|
70
|
+
|
|
71
|
+
- **許願台網頁(結果導向第三刀)**:給非技術使用者的瀏覽器介面,以結果為中心、不是聊天。
|
|
72
|
+
- server 服務 `GET /` → 單一 HTML(`src/app/web/index.html`,零依賴 vanilla,polling 不靠 SSE)
|
|
73
|
+
- 介面:許願(送目標)→ 進行中狀態 → needs-input 時跳問題+回答框 → 收成品(摘要+產出檔案)→ 歷史清單
|
|
74
|
+
- 新增 `GET /v1/tasks/:id/file?path=`:取交付檔案內容(點檔名看成品);`resolveArtifact` 防路徑穿越
|
|
75
|
+
- 任務 view 補 `goal`(顯示願望);token 注入頁面供同源呼叫(本地自用零設定,正式部署需前置認證)
|
|
76
|
+
- 2 個測試(resolveArtifact 穿越防護 + GET / 服務頁面/token 注入/API 仍需 auth)+ 真實端到端
|
|
77
|
+
(許願→交付 hello.txt→點開看內容→穿越攻擊擋下)
|
|
78
|
+
- 「許願→交付」三刀完成:交付抽象 + 澄清通道 + Job 介面
|
|
79
|
+
|
|
80
|
+
## 0.4.2
|
|
81
|
+
|
|
82
|
+
- **澄清通道(結果導向第二刀)**:agent 只在非問不可時暫停提問,而非盲猜或頻繁打擾。
|
|
83
|
+
- 新增 `ask_user` 工具(app 提供 `config.askUser` 才注入;readOnly);prompt 引導節制使用(能合理推斷就別問)
|
|
84
|
+
- **CLI**:`askUser` 內嵌提問,使用者打字回答,agent 續跑
|
|
85
|
+
- **背景任務 pause/resume**:`createTaskStore` 的 `runJob` 多收 `ask`;呼叫即轉 `needs-input` 並掛起問題;
|
|
86
|
+
新增 `POST /v1/tasks/:id/answer` 回答後解除暫停、續跑(完全非同步,可隔很久才答)
|
|
87
|
+
- 任務 view 帶 `pending`(待答問題);事件流發 `needs_input` / `answered`
|
|
88
|
+
- 4 個測試(工具有無/回空提示 + 佇列 pause/answer/resume + 無待答回 false)+ 真實 server 端到端
|
|
89
|
+
(under-spec 目標 → agent 暫停問檔名/內容 → 答完交付正確檔案)
|
|
90
|
+
|
|
91
|
+
## 0.4.1
|
|
92
|
+
|
|
93
|
+
- **結果導向:交付物為一等公民(「對話只是過程」)**:第一刀朝非技術使用者的「許願→交付」模型。
|
|
94
|
+
- 新增 `api.runOutcome(goal, opts)`:跑 goal loop,回傳**交付物** `{ done, summary, artifacts:{created,modified}, rounds, history }`
|
|
95
|
+
- 交付物偵測:掃工作目錄前後 diff(pack 無關,連 bash 寫的檔也抓;排除 .xitto-kernel/node_modules/.git)
|
|
96
|
+
- `--goal` 改印交付物(📦 產出/改動檔案 + 📝 摘要),不再只報達成輪數
|
|
97
|
+
- server `POST /v1/tasks`(mode=goal)回 `artifacts`;背景任務 webhook payload 也帶 `artifacts`
|
|
98
|
+
- 3 個測試(created/modified/無變動 + 內部沉澱檔不算交付物)+ 真實 model 端到端(產出 greet.js/example.js)
|
|
99
|
+
- 後續規劃:澄清通道(ask_user 暫停/續跑)、Job 介面(成品歷史)
|
|
100
|
+
|
|
3
101
|
## 0.4.0
|
|
4
102
|
|
|
5
103
|
**「執行中沉澱經驗」五層完整**(反射 / 事實 / 程序 / 情節 / 結晶)——里程碑。
|
package/README.md
CHANGED
|
@@ -78,6 +78,40 @@ xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫
|
|
|
78
78
|
```
|
|
79
79
|
`general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
|
|
80
80
|
|
|
81
|
+
**結果導向:對話只是過程,交付物才是產品**
|
|
82
|
+
|
|
83
|
+
對非技術使用者,真正要的不是「跟 AI 聊天」,是「把事做完、給我結果」。`api.runOutcome(goal)` 跑 goal loop,回傳的不是對話而是**交付物**:
|
|
84
|
+
```js
|
|
85
|
+
const o = await kernel.runOutcome('建立 greet.js 並寫個範例驗證');
|
|
86
|
+
// → { done, summary(做了什麼), artifacts: { created:[...], modified:[...] }, rounds }
|
|
87
|
+
```
|
|
88
|
+
`--goal` 與 server 的 `POST /v1/tasks`(mode=goal)都會回交付物——**產出/改動的檔案**(掃工作目錄前後 diff,連 bash 寫的也抓)+ 摘要 + 是否達成。對話被降格成過程,結果(檔案/達成)被擺到最前面。背景任務的 webhook 也帶 `artifacts`。
|
|
89
|
+
|
|
90
|
+
**澄清通道(只在非問不可時才打斷你)**:自主交付的風險是「自主走錯」。`ask_user` 工具讓 agent 在**缺少關鍵資訊、無法合理推斷**時暫停提問——而非盲猜或頻繁打擾(prompt 明確引導:能用合理預設就別問)。由 app 注入 `config.askUser` 決定「問」的形態:
|
|
91
|
+
- **CLI**:內嵌提問,你打字回答,agent 續跑
|
|
92
|
+
- **背景任務**:任務轉 `needs-input` 狀態並掛起問題 → 你 `POST /v1/tasks/:id/answer` 回答 → 解除暫停、續跑(可隔數小時才答,完全非同步)
|
|
93
|
+
|
|
94
|
+
實測:給「建個設定檔但檔名/內容我還沒決定」→ agent 不亂猜,暫停問你檔名與內容 → 答完才交付正確的 `app.config.json`。這讓「許願→交付」既自主又不失控。
|
|
95
|
+
|
|
96
|
+
**🪄 許願台網頁(給非技術使用者:瀏覽器打開就用)**
|
|
97
|
+
```bash
|
|
98
|
+
XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:8787/
|
|
99
|
+
```
|
|
100
|
+
不用終端機、不用碰金鑰(伺服器端管)。介面以**結果**為中心,不是聊天:
|
|
101
|
+
- **許願**:打一句「你想完成什麼」→ 交辦(背景跑 goal loop)
|
|
102
|
+
- **進行中**:**即時進度 + 活著的證明**——每秒跳動的「已進行 Ns」心跳時鐘、目前階段(思考中/執行中/驗收中)、agent 當下的**思考文字**(💭)、工具動作翻成人話、第幾輪 + 動作數。看得到它在想什麼、做什麼
|
|
103
|
+
- **待辦打勾**:agent 用 `todo_write` 規劃多步任務時,顯示 ☐/◐/☑ 清單,把「未知時長」變成「看得到的剩餘步數」(對標 Claude Code)
|
|
104
|
+
- **隨時可停**:每個進行中任務有「停止」鈕 → `POST /v1/tasks/:id/cancel`(abort 正在跑的 agent)。控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
|
|
105
|
+
- **需要你回答**:agent 暫停提問時,跳出問題 + 回答框(澄清通道)
|
|
106
|
+
- **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
|
|
107
|
+
- **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
|
|
108
|
+
|
|
109
|
+
**持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
|
|
110
|
+
|
|
111
|
+
**溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
|
|
112
|
+
|
|
113
|
+
零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
|
|
114
|
+
|
|
81
115
|
## 當成服務跑(不只 CLI)
|
|
82
116
|
|
|
83
117
|
kernel 是 UI 無關的,CLI 只是其中一個 app。`src/app/server.js` 是把它包成 **HTTP 服務**的 PoC
|
package/package.json
CHANGED
package/src/app/cli.js
CHANGED
|
@@ -37,6 +37,8 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
37
37
|
getPlanMode: () => planMode, // 計劃模式:守衛擋 mutating 工具
|
|
38
38
|
autoExtractMemory: true, // 事實層:每輪後自動萃取持久事實進記憶(非阻塞)
|
|
39
39
|
confirm: askConfirm, // 互動權限確認(mutating/危險工具執行前)
|
|
40
|
+
askUser: askUserQuestion, // 澄清通道:agent 非問不可時向使用者提問並等待
|
|
41
|
+
|
|
40
42
|
onTrusted: ({ name, signature, scope }) => { // 漸進放權:自動放行時標示「已信任」(維持可理解)
|
|
41
43
|
endStream();
|
|
42
44
|
out(c.gray(` ✓ 已信任 ${scope === 'command' ? `「${signature}」類` : name},自動放行\n`));
|
|
@@ -71,6 +73,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
71
73
|
// 回 'yes'(允許一次)/ 'command'(信任此命令簽章類,跨 session)/ 'always'(信任此工具全部)/ 'no'(拒絕)。
|
|
72
74
|
async function askConfirm(name, args, danger, meta = {}) {
|
|
73
75
|
if (autoApprove && !danger) return 'yes'; // 自動模式仍對危險命令把關
|
|
76
|
+
stopSpin(); // 停 spinner,否則它每 100ms 蓋掉提問/輸入列
|
|
74
77
|
endStream();
|
|
75
78
|
const sig = meta.signature; // 有簽章(bash 類)才提供細粒度「信任這類命令」
|
|
76
79
|
return new Promise((res) => {
|
|
@@ -92,6 +95,17 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
92
95
|
});
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
// 澄清通道:agent 呼叫 ask_user 時,內嵌問使用者並等待回答(自由文字;options 只當提示)
|
|
99
|
+
async function askUserQuestion({ question, options }) {
|
|
100
|
+
stopSpin(); // 關鍵:停 spinner,否則它會蓋掉問題與你的輸入,讓你看不到 agent 在問
|
|
101
|
+
endStream();
|
|
102
|
+
out('\n' + c.cyan(' ❓ agent 想問你:') + c.bold(String(question || '')) + '\n');
|
|
103
|
+
if (Array.isArray(options) && options.length) out(c.gray(' 選項:' + options.map((o, i) => `${i + 1}) ${o}`).join(' ')) + '\n');
|
|
104
|
+
return new Promise((res) => {
|
|
105
|
+
try { rl.question(c.cyan(' 你的回答 › '), (ans) => res((ans || '').trim())); } catch { res(''); }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
// session:續接(--resume [id])或開新;每輪結束自動存檔
|
|
96
110
|
let sessionId = kernel.session.newId();
|
|
97
111
|
let resumedNote = '';
|
package/src/app/main.js
CHANGED
|
@@ -87,7 +87,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
87
87
|
confirm: opts.yes ? (async () => 'yes') : undefined, // headless:--yes 才自動核准 mutating
|
|
88
88
|
});
|
|
89
89
|
console.log(cyan('🎯 目標:') + opts.goal + gray(` · ${opts.pack} pack · ${model.id}`));
|
|
90
|
-
const res = await kernel.
|
|
90
|
+
const res = await kernel.runOutcome(opts.goal, {
|
|
91
91
|
onRound: ({ round, maxRounds }) => console.log(yellow(`\n🔁 第 ${round}/${maxRounds} 輪`)),
|
|
92
92
|
onCheck: ({ done, remaining }) => console.log(done ? green(' ✓ 驗收:已達成') : gray(` ↻ 驗收:${remaining}`)),
|
|
93
93
|
onEvent: (ev) => {
|
|
@@ -95,8 +95,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
95
95
|
if (ev.type === 'tool_execution_end') console.log(ev.isError ? red(' ⎿ ✗') : gray(' ⎿ ✓'));
|
|
96
96
|
},
|
|
97
97
|
});
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// 交付物(對話只是過程,這才是產品)
|
|
99
|
+
const why = res.stalled ? '無進展停止' : res.aborted ? '中斷' : '到上限';
|
|
100
|
+
console.log('\n' + (res.done ? green(`✅ 已交付(${res.rounds} 輪)`) : yellow(`⚠ 未完成(${why},${res.rounds} 輪)`)));
|
|
101
|
+
const { created, modified } = res.artifacts;
|
|
102
|
+
if (created.length || modified.length) {
|
|
103
|
+
console.log(cyan('📦 產出檔案:'));
|
|
104
|
+
created.forEach((f) => console.log(green(` + ${f}`)));
|
|
105
|
+
modified.forEach((f) => console.log(yellow(` ~ ${f}`)));
|
|
106
|
+
} else console.log(gray(' (沒有檔案變動)'));
|
|
107
|
+
if (res.summary) console.log(cyan('📝 摘要:') + gray(res.summary.slice(0, 400)));
|
|
100
108
|
try { await mcp.close(); } catch { /* 略 */ }
|
|
101
109
|
process.exit(res.done ? 0 : 1);
|
|
102
110
|
}
|
package/src/app/server.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
|
|
4
4
|
// 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
|
-
import { mkdirSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
6
|
+
import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
7
|
+
import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { createKernel } from '../kernel/index.js';
|
|
10
10
|
import { loadModel } from './providers.js';
|
|
@@ -23,11 +23,29 @@ const PACKS = {
|
|
|
23
23
|
const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
|
|
24
24
|
const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
25
25
|
|
|
26
|
+
// 交付檔案的 content-type(讓圖片能顯示、md/html 能渲染、其餘可下載)。
|
|
27
|
+
const MIME = { md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', log: 'text/plain', json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html', js: 'text/javascript', mjs: 'text/javascript', ts: 'text/plain', py: 'text/plain', sh: 'text/plain', css: 'text/css', xml: 'application/xml', yaml: 'text/plain', yml: 'text/plain', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
|
|
28
|
+
export function contentTypeFor(name) { const ext = (String(name).split('.').pop() || '').toLowerCase(); return MIME[ext] || 'application/octet-stream'; }
|
|
29
|
+
|
|
30
|
+
// 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
|
|
31
|
+
export function resolveArtifact(workdir, rel) {
|
|
32
|
+
if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
|
|
33
|
+
const full = join(workdir, rel);
|
|
34
|
+
const r = relative(workdir, full);
|
|
35
|
+
return (r.startsWith('..') || isAbsolute(r)) ? null : full;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let _webHtml;
|
|
39
|
+
const webHtml = () => (_webHtml ??= readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'web', 'index.html'), 'utf8'));
|
|
40
|
+
|
|
26
41
|
// 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
|
|
27
42
|
export const mapEvent = (ev) => {
|
|
28
43
|
if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
|
|
29
44
|
if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
|
|
30
45
|
if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
|
|
46
|
+
if (ev.type === 'round') return { type: 'round', round: ev.round, maxRounds: ev.maxRounds };
|
|
47
|
+
if (ev.type === 'verify_start') return { type: 'phase', phase: 'verifying' };
|
|
48
|
+
if (ev.type === 'verify_end') return { type: 'phase', phase: ev.ok ? 'verified' : 'fixing' };
|
|
31
49
|
return null;
|
|
32
50
|
};
|
|
33
51
|
|
|
@@ -46,14 +64,31 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
46
64
|
const subs = new Map(); // id -> Set<(ev)=>void>
|
|
47
65
|
let active = 0;
|
|
48
66
|
|
|
49
|
-
const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
|
|
67
|
+
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 });
|
|
50
68
|
|
|
51
69
|
const emit = (t, ev) => {
|
|
52
70
|
t.events.push(ev);
|
|
53
71
|
if (t.events.length > maxEvents) t.events.shift();
|
|
72
|
+
// 進度追蹤(給 UI 顯示「正在做什麼」,不要只顯示進行中;排除 text 雜訊)
|
|
73
|
+
const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [] });
|
|
74
|
+
if (ev.type === 'tool' && ev.name === 'todo_write') { if (Array.isArray(ev.args?.todos)) p.todos = ev.args.todos; } // 待辦清單(給 UI 打勾)
|
|
75
|
+
else if (ev.type === 'tool') { p.steps++; p.phase = 'acting'; p.thinking = ''; t._textbuf = ''; p.recent.push({ name: ev.name, args: ev.args }); if (p.recent.length > 6) p.recent.shift(); }
|
|
76
|
+
else if (ev.type === 'text') { p.phase = 'thinking'; t._textbuf = ((t._textbuf || '') + (ev.delta || '')).slice(-400); p.thinking = t._textbuf.replace(/\s+/g, ' ').trim().slice(-150); }
|
|
77
|
+
else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
|
|
78
|
+
else if (ev.type === 'phase') p.phase = ev.phase;
|
|
79
|
+
else if (ev.type === 'needs_input') p.phase = 'needs-input';
|
|
80
|
+
else if (ev.type === 'answered') p.phase = 'acting';
|
|
81
|
+
else if (ev.type === 'end') p.phase = ev.status;
|
|
54
82
|
const s = subs.get(t.id); if (s) for (const fn of s) { try { fn(ev); } catch { /* 訂閱端錯不影響任務 */ } }
|
|
55
83
|
};
|
|
56
84
|
|
|
85
|
+
// 澄清通道:job 呼叫 ask({question,options}) → 任務轉 needs-input、暫停,直到有人 answer()
|
|
86
|
+
const makeAsk = (t) => ({ question, options }) => {
|
|
87
|
+
t.status = 'needs-input'; t.pending = { question: String(question || ''), options: options || null };
|
|
88
|
+
emit(t, { type: 'needs_input', question: t.pending.question, options: t.pending.options });
|
|
89
|
+
return new Promise((resolve) => { t._answer = resolve; });
|
|
90
|
+
};
|
|
91
|
+
|
|
57
92
|
function pump() {
|
|
58
93
|
while (active < concurrency && queue.length) {
|
|
59
94
|
const t = queue.shift();
|
|
@@ -61,10 +96,11 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
61
96
|
t.status = 'running'; t.startedAt = new Date().toISOString();
|
|
62
97
|
emit(t, { type: 'status', status: 'running' });
|
|
63
98
|
Promise.resolve()
|
|
64
|
-
.then(() => runJob(t.spec, (ev) => emit(t, ev)))
|
|
99
|
+
.then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }))
|
|
65
100
|
.then((result) => { t.status = 'done'; t.result = result; })
|
|
66
101
|
.catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
|
|
67
102
|
.finally(() => {
|
|
103
|
+
if (t._cancelling && t.status !== 'error') t.status = 'cancelled'; // 使用者中斷
|
|
68
104
|
t.finishedAt = new Date().toISOString();
|
|
69
105
|
emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
|
|
70
106
|
active--;
|
|
@@ -87,6 +123,31 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
87
123
|
result: (id) => { const t = tasks.get(id); return t ? { ...view(t), result: t.result } : null; },
|
|
88
124
|
list: () => [...tasks.values()].map(view),
|
|
89
125
|
subscribe(id, fn) { let s = subs.get(id); if (!s) { s = new Set(); subs.set(id, s); } s.add(fn); return () => s.delete(fn); },
|
|
126
|
+
// 中斷任務(取消鈕):排隊中 → 直接移除;進行中 → abort agent;待答中 → 解除阻塞後 abort。
|
|
127
|
+
cancel(id) {
|
|
128
|
+
const t = tasks.get(id);
|
|
129
|
+
if (!t || ['done', 'error', 'cancelled'].includes(t.status)) return false;
|
|
130
|
+
t._cancelling = true;
|
|
131
|
+
if (t.status === 'queued') {
|
|
132
|
+
const i = queue.indexOf(t); if (i >= 0) queue.splice(i, 1);
|
|
133
|
+
t.status = 'cancelled'; t.finishedAt = new Date().toISOString();
|
|
134
|
+
emit(t, { type: 'end', status: 'cancelled' });
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (typeof t._answer === 'function') { const r = t._answer; t._answer = null; t.pending = null; r(''); } // 解除待答阻塞
|
|
138
|
+
if (t._agent && typeof t._agent.abort === 'function') { try { t._agent.abort(); } catch { /* 略 */ } }
|
|
139
|
+
emit(t, { type: 'cancelling' });
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
// 回答一個待答任務 → 解除暫停、續跑。回 true 表示有對應的待答問題。
|
|
143
|
+
answer(id, text) {
|
|
144
|
+
const t = tasks.get(id);
|
|
145
|
+
if (!t || typeof t._answer !== 'function') return false;
|
|
146
|
+
const resolve = t._answer; t._answer = null; t.pending = null; t.status = 'running';
|
|
147
|
+
emit(t, { type: 'answered', answer: String(text ?? '') });
|
|
148
|
+
resolve(String(text ?? ''));
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
90
151
|
stats: () => ({ active, queued: queue.length, total: tasks.size }),
|
|
91
152
|
};
|
|
92
153
|
}
|
|
@@ -101,45 +162,56 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
101
162
|
* @param {number} [o.concurrency] 背景任務同時數(預設 2)
|
|
102
163
|
* @returns {import('node:http').Server}
|
|
103
164
|
*/
|
|
104
|
-
export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
|
|
165
|
+
export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
|
|
105
166
|
const sessions = new Map(); // sessionId -> { pack, history }
|
|
106
167
|
mkdirSync(baseDir, { recursive: true });
|
|
107
168
|
|
|
108
169
|
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
|
|
109
|
-
|
|
170
|
+
// header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
|
|
171
|
+
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; } };
|
|
110
172
|
const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
|
|
111
173
|
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({}); } }); });
|
|
112
174
|
const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
113
175
|
|
|
114
|
-
// 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel
|
|
115
|
-
|
|
176
|
+
// 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
|
|
177
|
+
// ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
|
|
178
|
+
async function runKernel(spec, onEvent, ask, onAgent) {
|
|
116
179
|
const make = PACKS[spec.pack || 'general'];
|
|
117
180
|
if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
|
|
181
|
+
// 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
|
|
182
|
+
const workspace = (spec.workspace || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
|
|
183
|
+
const workdir = join(baseDir, 'ws', workspace); mkdirSync(workdir, { recursive: true });
|
|
184
|
+
// history 仍綁 sessionId(每個成品獨立對話:無 sessionId → 全新,不續接,避免 context 暴脹/混淆)
|
|
118
185
|
const sessionId = spec.sessionId || newId();
|
|
119
|
-
const sess = sessions.get(sessionId) || {
|
|
120
|
-
const
|
|
121
|
-
const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
|
|
186
|
+
const sess = sessions.get(sessionId) || { history: [] };
|
|
187
|
+
const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes', autoExtractMemory: true, ...(ask ? { askUser: ask } : {}) });
|
|
122
188
|
const usage = { input: 0, output: 0 };
|
|
123
189
|
const wrapped = (ev) => { if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; } onEvent?.(ev); };
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
190
|
+
if (spec.mode === 'goal') {
|
|
191
|
+
// 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
|
|
192
|
+
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 }) });
|
|
193
|
+
sess.history = o.history || []; sessions.set(sessionId, sess);
|
|
194
|
+
try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
|
|
195
|
+
// 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
|
|
196
|
+
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 };
|
|
197
|
+
}
|
|
198
|
+
const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
|
|
127
199
|
sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
|
|
128
|
-
return { sessionId, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
|
|
200
|
+
return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
|
|
129
201
|
}
|
|
130
202
|
|
|
131
203
|
// 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
|
|
132
204
|
async function fireWebhook(task) {
|
|
133
205
|
const url = task.spec.webhook; if (!url || !/^https?:\/\//.test(url)) return;
|
|
134
206
|
const r = task.result || {};
|
|
135
|
-
const body = JSON.stringify({ taskId: task.id, status: task.status, error: task.error, sessionId: r.sessionId, text: r.text, usage: r.usage, rounds: r.rounds, done: r.done, finishedAt: task.finishedAt });
|
|
207
|
+
const body = JSON.stringify({ taskId: task.id, status: task.status, error: task.error, sessionId: r.sessionId, text: r.text, usage: r.usage, rounds: r.rounds, done: r.done, artifacts: r.artifacts, finishedAt: task.finishedAt });
|
|
136
208
|
try { const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }); log({ webhook: url, task: task.id, status: task.status, code: resp.status }); }
|
|
137
209
|
catch (e) { log({ webhook: url, task: task.id, error: e.message }); }
|
|
138
210
|
}
|
|
139
211
|
|
|
140
212
|
const tasks = createTaskStore({
|
|
141
213
|
concurrency,
|
|
142
|
-
runJob: (spec, emit) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }),
|
|
214
|
+
runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
|
|
143
215
|
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); },
|
|
144
216
|
});
|
|
145
217
|
|
|
@@ -147,6 +219,14 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
147
219
|
const url = new URL(req.url, 'http://localhost');
|
|
148
220
|
const path = url.pathname;
|
|
149
221
|
if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id, tasks: tasks.stats() });
|
|
222
|
+
|
|
223
|
+
// 「許願台」網頁(公開可載入;token 注入頁面供同源 API 呼叫——PoC/本地自用,正式部署請前置真實認證)
|
|
224
|
+
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
225
|
+
let html; try { html = webHtml(); } catch { return json(res, 500, { error: 'web UI 未找到' }); }
|
|
226
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
227
|
+
return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))));
|
|
228
|
+
}
|
|
229
|
+
|
|
150
230
|
if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
|
|
151
231
|
|
|
152
232
|
// 同步:跑完才回(JSON 或 SSE 串流)
|
|
@@ -172,7 +252,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
172
252
|
const body = await readBody(req);
|
|
173
253
|
if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
|
|
174
254
|
if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
|
|
175
|
-
const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook });
|
|
255
|
+
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 });
|
|
176
256
|
log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
|
|
177
257
|
return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
|
|
178
258
|
}
|
|
@@ -182,6 +262,44 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
182
262
|
const mTask = path.match(/^\/v1\/tasks\/([^/]+)$/);
|
|
183
263
|
if (req.method === 'GET' && mTask) { const v = tasks.result(mTask[1]); return v ? json(res, 200, v) : json(res, 404, { error: 'task not found' }); }
|
|
184
264
|
|
|
265
|
+
// 回答待答任務(澄清通道):背景任務問了問題,使用者把答案送回 → 解除暫停、續跑
|
|
266
|
+
const mAns = path.match(/^\/v1\/tasks\/([^/]+)\/answer$/);
|
|
267
|
+
if (req.method === 'POST' && mAns) {
|
|
268
|
+
const body = await readBody(req);
|
|
269
|
+
const t = tasks.get(mAns[1]);
|
|
270
|
+
if (!t) return json(res, 404, { error: 'task not found' });
|
|
271
|
+
if (t.status !== 'needs-input') return json(res, 409, { error: '此任務目前沒有待答問題', status: t.status });
|
|
272
|
+
tasks.answer(mAns[1], body.answer);
|
|
273
|
+
log({ task: mAns[1], action: 'answer' });
|
|
274
|
+
return json(res, 200, { ok: true, taskId: mAns[1], status: 'running' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 中斷任務(取消鈕):控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
|
|
278
|
+
const mCancel = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
|
|
279
|
+
if (req.method === 'POST' && mCancel) {
|
|
280
|
+
const ok = tasks.cancel(mCancel[1]);
|
|
281
|
+
log({ task: mCancel[1], action: 'cancel', ok });
|
|
282
|
+
return ok ? json(res, 200, { ok: true, taskId: mCancel[1] }) : json(res, 409, { error: '無法中斷(任務不存在或已結束)' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 取交付物檔案內容(讓「成品」可被瀏覽/下載)
|
|
286
|
+
const mFile = path.match(/^\/v1\/tasks\/([^/]+)\/file$/);
|
|
287
|
+
if (req.method === 'GET' && mFile) {
|
|
288
|
+
const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
|
|
289
|
+
if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
|
|
290
|
+
const rel = url.searchParams.get('path');
|
|
291
|
+
const full = resolveArtifact(join(baseDir, 'ws', ws), rel);
|
|
292
|
+
if (!full) return json(res, 400, { error: 'path 不合法' });
|
|
293
|
+
if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
|
|
294
|
+
try {
|
|
295
|
+
const ct = contentTypeFor(rel);
|
|
296
|
+
const isText = /^text\/|json|xml|javascript|svg/.test(ct);
|
|
297
|
+
const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
|
|
298
|
+
if (url.searchParams.get('download')) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
|
|
299
|
+
res.writeHead(200, headers); return res.end(readFileSync(full));
|
|
300
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
301
|
+
}
|
|
302
|
+
|
|
185
303
|
// 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
|
|
186
304
|
const mEv = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
|
|
187
305
|
if (req.method === 'GET' && mEv) {
|
|
@@ -205,12 +323,14 @@ export function startServer() {
|
|
|
205
323
|
const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
|
|
206
324
|
const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
|
|
207
325
|
const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
|
|
326
|
+
const local = process.env.XITTO_SERVER_LOCAL === '1' || process.env.XITTO_SERVER_LOCAL === 'true';
|
|
208
327
|
const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
|
|
209
|
-
const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
|
|
328
|
+
const server = createServerApp({ model, getApiKey, token, sandbox, concurrency, local });
|
|
210
329
|
server.listen(port, () => {
|
|
211
|
-
console.log(
|
|
330
|
+
console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
|
|
331
|
+
console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
|
|
212
332
|
console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
|
|
213
|
-
console.log('
|
|
333
|
+
console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
|
|
214
334
|
});
|
|
215
335
|
return server;
|
|
216
336
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>xitto · 許願台</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#0f1115; --card:#181b22; --line:#272b34; --fg:#e7e9ee; --dim:#8b919e; --accent:#6ea8fe; --ok:#5fd38a; --warn:#f0c674; --me:#222630; }
|
|
9
|
+
* { box-sizing:border-box; }
|
|
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; }
|
|
12
|
+
header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
|
|
13
|
+
header h1 { font-size:22px; margin:0; }
|
|
14
|
+
header .sub { color:var(--dim); font-size:13px; }
|
|
15
|
+
.ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; }
|
|
16
|
+
.ask textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:10px; padding:12px; font:inherit; resize:vertical; min-height:64px; }
|
|
17
|
+
.row { display:flex; gap:10px; align-items:center; margin-top:10px; }
|
|
18
|
+
select, button { font:inherit; }
|
|
19
|
+
select { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; }
|
|
20
|
+
button { background:var(--accent); color:#08101f; border:0; border-radius:8px; padding:9px 16px; font-weight:600; cursor:pointer; }
|
|
21
|
+
button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
|
|
22
|
+
button:disabled { opacity:.5; cursor:default; }
|
|
23
|
+
.spacer { flex:1; }
|
|
24
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; margin:14px 0; }
|
|
25
|
+
.goal { font-weight:600; margin-bottom:8px; }
|
|
26
|
+
.status { display:inline-block; font-size:12px; padding:2px 10px; border-radius:20px; border:1px solid var(--line); color:var(--dim); }
|
|
27
|
+
.status.running { color:var(--accent); border-color:var(--accent); }
|
|
28
|
+
.status.needs { color:var(--warn); border-color:var(--warn); }
|
|
29
|
+
.status.done { color:var(--ok); border-color:var(--ok); }
|
|
30
|
+
.status.error { color:#f08a8a; border-color:#f08a8a; }
|
|
31
|
+
.activity { color:var(--dim); font-size:13px; margin-top:8px; min-height:20px; }
|
|
32
|
+
.phase { color:var(--accent); font-size:13px; margin-bottom:5px; }
|
|
33
|
+
.step { font-size:13px; color:var(--dim); padding:1px 0; }
|
|
34
|
+
.step.cur { color:var(--fg); }
|
|
35
|
+
.step.think { color:var(--warn); font-style:italic; }
|
|
36
|
+
.todos { margin-top:10px; padding-top:8px; border-top:1px solid var(--line); }
|
|
37
|
+
.todo { font-size:13px; padding:2px 0; color:var(--dim); }
|
|
38
|
+
.todo.in_progress { color:var(--accent); font-weight:600; }
|
|
39
|
+
.todo.completed { color:var(--ok); }
|
|
40
|
+
button.cancel { margin-left:10px; padding:3px 12px; font-size:12px; background:transparent; color:#f08a8a; border:1px solid #5b3030; }
|
|
41
|
+
button.cancel:hover { background:#2a1818; }
|
|
42
|
+
.wsbadge { display:inline-block; font-size:12px; color:var(--dim); margin-left:8px; }
|
|
43
|
+
.loc { margin-top:10px; font-size:12px; color:var(--dim); }
|
|
44
|
+
.loc code { background:#0c0e12; border:1px solid var(--line); border-radius:6px; padding:2px 7px; color:var(--fg); cursor:pointer; }
|
|
45
|
+
.dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
|
|
46
|
+
@keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
|
|
47
|
+
.summary { margin-top:10px; white-space:pre-wrap; }
|
|
48
|
+
.files { margin-top:10px; }
|
|
49
|
+
.file { display:inline-block; margin:3px 6px 0 0; padding:4px 10px; background:#0c0e12; border:1px solid var(--line); border-radius:8px; font-size:13px; cursor:pointer; color:var(--accent); }
|
|
50
|
+
.file.mod { color:var(--warn); }
|
|
51
|
+
.qbox { margin-top:12px; padding:12px; background:#1d1c12; border:1px solid var(--warn); border-radius:10px; }
|
|
52
|
+
.qbox .q { color:var(--warn); margin-bottom:8px; }
|
|
53
|
+
.qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
|
|
54
|
+
h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
|
|
55
|
+
.hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
|
|
56
|
+
.hist:hover { border-color:var(--accent); }
|
|
57
|
+
.hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
|
|
58
|
+
.empty { color:var(--dim); font-size:13px; }
|
|
59
|
+
.viewer { margin-top:10px; border:1px solid var(--line); border-radius:10px; padding:12px; background:#0c0e12; }
|
|
60
|
+
.vbar { font-size:12px; color:var(--dim); margin-bottom:8px; }
|
|
61
|
+
.vbar a { color:var(--accent); text-decoration:none; }
|
|
62
|
+
.vimg { max-width:100%; border-radius:8px; }
|
|
63
|
+
.vframe { width:100%; height:420px; border:0; background:#fff; border-radius:8px; }
|
|
64
|
+
.viewer-pre { white-space:pre-wrap; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; color:var(--fg); overflow:auto; max-height:420px; }
|
|
65
|
+
.md { line-height:1.65; } .md h1,.md h2,.md h3,.md h4 { margin:.7em 0 .3em; line-height:1.3; }
|
|
66
|
+
.md code { background:#1a1d24; padding:1px 5px; border-radius:4px; font-size:.9em; }
|
|
67
|
+
.md pre.code { background:#1a1d24; padding:10px; border-radius:8px; overflow:auto; }
|
|
68
|
+
.md ul { margin:.3em 0 .3em 1.2em; } .md a { color:var(--accent); } .md p { margin:.5em 0; }
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div class="wrap">
|
|
73
|
+
<header>
|
|
74
|
+
<h1>🪄 xitto 許願台</h1>
|
|
75
|
+
<span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
|
|
76
|
+
<span class="spacer"></span>
|
|
77
|
+
<select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
|
|
78
|
+
<button class="ghost" id="newspace" title="新專案">+</button>
|
|
79
|
+
</header>
|
|
80
|
+
|
|
81
|
+
<div class="ask">
|
|
82
|
+
<textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
|
|
83
|
+
<div class="row">
|
|
84
|
+
<select id="pack" title="領域"></select>
|
|
85
|
+
<span class="spacer"></span>
|
|
86
|
+
<button id="go">交辦 →</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div id="current"></div>
|
|
91
|
+
|
|
92
|
+
<h3>歷史成品</h3>
|
|
93
|
+
<div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<script>
|
|
97
|
+
const TOKEN = "__SERVER_TOKEN__";
|
|
98
|
+
const PACKS = __PACKS__;
|
|
99
|
+
const $ = (s) => document.querySelector(s);
|
|
100
|
+
const api = (p, opts={}) => fetch(p, { ...opts, headers: { authorization:"Bearer "+TOKEN, "content-type":"application/json", ...(opts.headers||{}) } });
|
|
101
|
+
const esc = (s) => String(s==null?"":s).replace(/[&<>]/g, c=>({"&":"&","<":"<",">":">"}[c]));
|
|
102
|
+
|
|
103
|
+
// 把工具動作翻成人話,讓「進行中」變成看得懂的進度
|
|
104
|
+
const TOOL_ZH = { read:"讀取檔案", ls:"查看目錄", glob:"尋找檔案", grep:"搜尋內容", write:"建立檔案", edit:"修改檔案", bash:"執行指令", web_search:"搜尋網路", web_fetch:"讀取網頁", http:"呼叫 API", read_image:"看圖片", skill:"載入技能", skill_save:"記錄技能", skills_check:"複查技能", playbook_update:"記下做法", episode_record:"記下經驗", episode_recall:"回想經驗", memory_save:"記住重點", todo_write:"規劃步驟", spawn_agent:"派出子助手", ask_user:"來問你" };
|
|
105
|
+
const PHASE_ZH = { starting:"啟動中", thinking:"思考中", acting:"執行中", verifying:"驗收中", verified:"驗收通過", fixing:"修正中", "needs-input":"等你回答" };
|
|
106
|
+
const friendlyStep = (a) => { const z = TOOL_ZH[a.name] || a.name; const d = a.args && (a.args.command||a.args.path||a.args.query||a.args.url||a.args.topic||a.args.name) || ""; return z + (d ? ` ${String(d).slice(0,44)}` : ""); };
|
|
107
|
+
const elapsedOf = (t) => t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime())/1000)) : 0;
|
|
108
|
+
function progressHtml(t) {
|
|
109
|
+
const p = t.progress || {};
|
|
110
|
+
const head = `${PHASE_ZH[p.phase]||"進行中"}${p.round?` · 第 ${p.round} 輪`:""}${p.steps?` · ${p.steps} 個動作`:""} · 已進行 <span id="elapsed">${elapsedOf(t)}s</span>`;
|
|
111
|
+
const think = p.thinking ? `<div class="step think">💭 ${esc(p.thinking)}…</div>` : "";
|
|
112
|
+
const steps = (p.recent||[]).slice().reverse().map((a,i)=>`<div class="step ${i===0?"cur":""}">${i===0?"▸":"·"} ${esc(friendlyStep(a))}</div>`).join("");
|
|
113
|
+
return `<div class="activity"><div class="phase dots">${head}</div>${think}${steps}</div>`;
|
|
114
|
+
}
|
|
115
|
+
// 心跳:每秒更新「已進行 Ns」,即使沒有新事件也讓使用者看到它活著
|
|
116
|
+
let liveTask = null;
|
|
117
|
+
setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.status==="queued")) { const el=document.getElementById("elapsed"); if (el) el.textContent = elapsedOf(liveTask)+"s"; } }, 1000);
|
|
118
|
+
|
|
119
|
+
// 領域選單(非技術使用者預設「通用」)
|
|
120
|
+
const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
|
|
121
|
+
$("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selected":""}>${LABELS[p]||p}</option>`).join("");
|
|
122
|
+
|
|
123
|
+
// 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
|
|
124
|
+
let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
|
|
125
|
+
let curSpace = localStorage.getItem("xk_space")||"default";
|
|
126
|
+
function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option ${s===curSpace?"selected":""}>${esc(s)}</option>`).join(""); }
|
|
127
|
+
$("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); };
|
|
128
|
+
$("#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(); };
|
|
129
|
+
renderSpaces();
|
|
130
|
+
|
|
131
|
+
// 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
|
|
132
|
+
function mdRender(src){
|
|
133
|
+
const lines=String(src).replace(/\r/g,"").split("\n"); const out=[]; let inCode=false,buf=[],inList=false;
|
|
134
|
+
const inline=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>').replace(/\*([^*]+)\*/g,'<em>$1</em>').replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g,'<a href="$2" target="_blank">$1</a>');
|
|
135
|
+
const closeL=()=>{ if(inList){out.push("</ul>");inList=false;} };
|
|
136
|
+
for(const ln of lines){
|
|
137
|
+
if(/^```/.test(ln)){ if(inCode){out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");buf=[];inCode=false;}else{closeL();inCode=true;} continue; }
|
|
138
|
+
if(inCode){ buf.push(ln); continue; }
|
|
139
|
+
const h=ln.match(/^(#{1,4})\s+(.*)/); if(h){ closeL(); out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
|
|
140
|
+
const li=ln.match(/^\s*(?:[-*]|\d+\.)\s+(.*)/); if(li){ if(!inList){out.push("<ul>");inList=true;} out.push("<li>"+inline(li[1])+"</li>"); continue; }
|
|
141
|
+
if(ln.trim()===""){ closeL(); continue; }
|
|
142
|
+
closeL(); out.push("<p>"+inline(ln)+"</p>");
|
|
143
|
+
}
|
|
144
|
+
closeL(); if(inCode)out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");
|
|
145
|
+
return out.join("");
|
|
146
|
+
}
|
|
147
|
+
const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
|
|
148
|
+
const fileUrl=(id,path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+"&token="+encodeURIComponent(TOKEN)+extra;
|
|
149
|
+
async function viewFile(id, encPath, name){
|
|
150
|
+
const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
|
|
151
|
+
const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${fileUrl(id,path)}" target="_blank">開新分頁</a> · <a href="${fileUrl(id,path,'&download=1')}">下載</a></div>`;
|
|
152
|
+
if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${fileUrl(id,path)}">`; return; }
|
|
153
|
+
if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${fileUrl(id,path)}"></iframe>`; return; }
|
|
154
|
+
v.innerHTML=bar+`<div class="empty">載入中…</div>`;
|
|
155
|
+
const txt=await fetch(fileUrl(id,path)).then(r=>r.ok?r.text():null).catch(()=>null);
|
|
156
|
+
if(txt==null){ v.innerHTML=bar+`<div class="empty">(無法以文字呈現,請下載)</div>`; return; }
|
|
157
|
+
let body;
|
|
158
|
+
if(MD.test(name)) body=`<div class="md">${mdRender(txt)}</div>`;
|
|
159
|
+
else if(JSONF.test(name)){ try{ body=`<pre class="viewer-pre">${esc(JSON.stringify(JSON.parse(txt),null,2))}</pre>`; }catch{ body=`<pre class="viewer-pre">${esc(txt)}</pre>`; } }
|
|
160
|
+
else body=`<pre class="viewer-pre">${esc(txt)}</pre>`;
|
|
161
|
+
v.innerHTML=bar+body;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let activeId = null, polling = null;
|
|
165
|
+
|
|
166
|
+
$("#go").onclick = async () => {
|
|
167
|
+
const goal = $("#goal").value.trim(); if (!goal) return;
|
|
168
|
+
$("#go").disabled = true;
|
|
169
|
+
const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal, workspace: curSpace }) }).then(r=>r.json());
|
|
170
|
+
$("#go").disabled = false;
|
|
171
|
+
if (r.error) { alert(r.error); return; }
|
|
172
|
+
$("#goal").value = "";
|
|
173
|
+
activeId = r.taskId;
|
|
174
|
+
poll();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled")?"error":""; }
|
|
178
|
+
function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷"})[s]||s; }
|
|
179
|
+
const CANCELLABLE = ["queued","running","needs-input"];
|
|
180
|
+
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>`; }
|
|
181
|
+
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;} } }
|
|
182
|
+
|
|
183
|
+
async function poll() {
|
|
184
|
+
clearTimeout(polling);
|
|
185
|
+
const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
|
|
186
|
+
liveTask = t;
|
|
187
|
+
renderCurrent(t);
|
|
188
|
+
if (t.status==="done" || t.status==="error") { loadHistory(); return; }
|
|
189
|
+
if (t.status==="needs-input") return; // 等使用者回答
|
|
190
|
+
polling = setTimeout(poll, 1200);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderCurrent(t) {
|
|
194
|
+
const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
|
|
195
|
+
$("#current").innerHTML = `<div class="card">
|
|
196
|
+
<div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
|
|
197
|
+
<span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
|
|
198
|
+
${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
|
|
199
|
+
${t.status==="running"||t.status==="queued"?progressHtml(t):""}
|
|
200
|
+
${todosHtml(t.progress)}
|
|
201
|
+
${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
|
|
202
|
+
<input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
|
|
203
|
+
${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
|
|
204
|
+
${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
|
|
205
|
+
${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>`:""}
|
|
206
|
+
${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>`:""}
|
|
207
|
+
<div class="viewer" id="fview" style="display:none"></div>
|
|
208
|
+
</div>`;
|
|
209
|
+
if (t.status==="needs-input") {
|
|
210
|
+
const inp = $("#ans"); inp.focus();
|
|
211
|
+
inp.onkeydown = async (e) => { if (e.key==="Enter" && inp.value.trim()) {
|
|
212
|
+
const ans = inp.value.trim(); inp.disabled = true;
|
|
213
|
+
await api("/v1/tasks/"+t.taskId+"/answer", { method:"POST", body: JSON.stringify({ answer: ans }) });
|
|
214
|
+
poll();
|
|
215
|
+
}};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function loadHistory() {
|
|
220
|
+
const r = await api("/v1/tasks").then(r=>r.json());
|
|
221
|
+
const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
|
|
222
|
+
$("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
|
|
223
|
+
<div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
|
|
224
|
+
<div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
|
|
225
|
+
}
|
|
226
|
+
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"}); }
|
|
227
|
+
|
|
228
|
+
loadHistory();
|
|
229
|
+
</script>
|
|
230
|
+
</body>
|
|
231
|
+
</html>
|
package/src/kernel/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// 確定性那半部:pack 載入、工具註冊、mutatingTools 推導、固定順序守衛鏈、systemPrompt 組裝、
|
|
3
3
|
// 單一工具呼叫(runTool)。LLM 那半部:runTurn 驅動移植自 xitto-code 的 Agent loop
|
|
4
4
|
// (串流 + 多步工具循環 + 守衛接線)。壓縮/TUI 仍為後續接縫。
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
6
|
-
import { join, dirname, isAbsolute } from 'node:path';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, dirname, isAbsolute, relative } from 'node:path';
|
|
7
7
|
import { loadPack } from './pack-loader.js';
|
|
8
8
|
import { createToolRegistry, deriveMutatingTools, isSandboxable } from './tool-registry.js';
|
|
9
9
|
import { composeGuards } from './guard-chain.js';
|
|
@@ -44,6 +44,25 @@ function loadContextFiles(cwd, names) {
|
|
|
44
44
|
found.map((f) => `## ${f.name}\n${f.text.trim()}`).join('\n\n');
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// 交付物偵測:掃工作目錄前後快照,diff 出「產出/改動的檔案」(pack 無關,連 bash 寫的也抓得到)。
|
|
48
|
+
const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server', 'tmp']);
|
|
49
|
+
function scanWorkdir(dir, base = dir, acc = new Map(), depth = 0) {
|
|
50
|
+
if (depth > 8 || acc.size > 5000) return acc;
|
|
51
|
+
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
if (SKIP_SCAN.has(e.name)) continue;
|
|
54
|
+
const full = join(dir, e.name);
|
|
55
|
+
if (e.isDirectory()) scanWorkdir(full, base, acc, depth + 1);
|
|
56
|
+
else if (e.isFile()) { try { const s = statSync(full); acc.set(relative(base, full), `${s.mtimeMs}:${s.size}`); } catch { /* 略 */ } }
|
|
57
|
+
}
|
|
58
|
+
return acc;
|
|
59
|
+
}
|
|
60
|
+
function diffWorkdir(before, after) {
|
|
61
|
+
const created = [], modified = [];
|
|
62
|
+
for (const [rel, sig] of after) { if (!before.has(rel)) created.push(rel); else if (before.get(rel) !== sig) modified.push(rel); }
|
|
63
|
+
return { created: created.sort(), modified: modified.sort() };
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
const DEFAULT_MEMORY_GUIDE =
|
|
48
67
|
'遇到值得跨 session 記住的事實(使用者偏好、踩過的坑、專案決策)時,當下就存一條。';
|
|
49
68
|
|
|
@@ -53,6 +72,9 @@ const DEFAULT_PLAYBOOK_GUIDE =
|
|
|
53
72
|
const DEFAULT_EPISODE_GUIDE =
|
|
54
73
|
'完成有價值的任務後,用 episode_record 記一筆情節(做了什麼+結果+tags);遇到相似任務時系統會自動召回最相關的幾筆供參考,也可主動用 episode_recall 查。';
|
|
55
74
|
|
|
75
|
+
const DEFAULT_OUTPUT_GUIDE =
|
|
76
|
+
'產出檔案時:最終成品放工作目錄根、用清楚好懂的檔名(如 report.md、budget.csv,別用 tmp_3.txt);中間/暫存檔(下載、草稿、解壓內容、爬到的原始資料)一律放 tmp/ 目錄——那是過程檔,不算成品也可能被清掉。';
|
|
77
|
+
|
|
56
78
|
// 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
|
|
57
79
|
// 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
|
|
58
80
|
function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
|
|
@@ -153,6 +175,19 @@ export function createKernel(pack, config = {}) {
|
|
|
153
175
|
};
|
|
154
176
|
const skills = createSkills(join(dataDir, 'skills'), { verifyRunner: runVerify }); // 漸進揭露 + 結晶(須驗證)
|
|
155
177
|
|
|
178
|
+
// 澄清通道:app 提供 askUser 才有 ask_user 工具(結果導向:自主完成,只在非問不可時才打斷使用者)。
|
|
179
|
+
const askUserTool = typeof config.askUser === 'function' ? {
|
|
180
|
+
name: 'ask_user', label: '詢問使用者', readOnly: true,
|
|
181
|
+
description: '只在「缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果」時,才向使用者提問並等待回答。能自己判斷、能用合理預設就別問——盡量自主完成,把打斷降到最低。回傳使用者的回答。',
|
|
182
|
+
parameters: { type: 'object', properties: { question: { type: 'string', description: '簡短、具體的問題' }, options: { type: 'array', items: { type: 'string' }, description: '可選:提供幾個選項供使用者挑' } }, required: ['question'] },
|
|
183
|
+
execute: async (_id, { question, options }) => {
|
|
184
|
+
let answer; try { answer = await config.askUser({ question, options }); } catch { answer = null; }
|
|
185
|
+
const text = (answer == null || answer === '') ? '(使用者未回答;請用合理預設繼續,不要再追問)' : String(answer);
|
|
186
|
+
// 同時回傳 question + 明確指示,讓模型把回答當權威依據(即使對話很長也不會脫鉤)
|
|
187
|
+
return { content: [{ type: 'text', text: JSON.stringify({ question: String(question || ''), answer: text, note: '這是使用者對你提問的回答,請以此為準繼續,不要忽略或再問同一件事。' }) }] };
|
|
188
|
+
},
|
|
189
|
+
} : null;
|
|
190
|
+
|
|
156
191
|
// 工具:pack 工具(sandboxable 包 Seatbelt、mutating+path 加 undo 快照)+ kernel 內建記憶工具 + spawn_agent。
|
|
157
192
|
const undoStack = [];
|
|
158
193
|
const baseTools = [
|
|
@@ -160,6 +195,7 @@ export function createKernel(pack, config = {}) {
|
|
|
160
195
|
...memory.tools,
|
|
161
196
|
...playbook.tools,
|
|
162
197
|
...episodes.tools,
|
|
198
|
+
...(askUserTool ? [askUserTool] : []),
|
|
163
199
|
todo.tool,
|
|
164
200
|
...skills.tools,
|
|
165
201
|
...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
|
|
@@ -188,8 +224,10 @@ export function createKernel(pack, config = {}) {
|
|
|
188
224
|
pack.systemPrompt +
|
|
189
225
|
loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
|
|
190
226
|
'\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
|
|
227
|
+
'\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
|
|
191
228
|
(memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
|
|
192
229
|
(pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
|
|
230
|
+
(askUserTool ? '\n\n# 詢問\n盡量自主完成目標。只在缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果時,才用 ask_user 問使用者;能用合理預設就別問。' : '') +
|
|
193
231
|
skills.promptSection();
|
|
194
232
|
|
|
195
233
|
const getPlanMode = config.getPlanMode || (() => false);
|
|
@@ -415,6 +453,22 @@ export function createKernel(pack, config = {}) {
|
|
|
415
453
|
}
|
|
416
454
|
return { done: false, maxedOut: true, rounds: maxRounds, history };
|
|
417
455
|
},
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 結果導向:給目標 → 跑 goal loop → 回「交付物」(做了什麼 + 產出/改動的檔案 + 是否達成)。
|
|
459
|
+
* 對非技術使用者:對話只是過程,這回傳的才是產品。
|
|
460
|
+
* @param {string} goal
|
|
461
|
+
* @param {object} [opts] 同 runGoal
|
|
462
|
+
* @returns {Promise<{ goal, done, rounds, aborted, stalled, summary, artifacts: {created:string[], modified:string[]}, history }>}
|
|
463
|
+
*/
|
|
464
|
+
runOutcome: async (goal, opts = {}) => {
|
|
465
|
+
const before = scanWorkdir(cwd);
|
|
466
|
+
const g = await api.runGoal(goal, opts);
|
|
467
|
+
const artifacts = diffWorkdir(before, scanWorkdir(cwd));
|
|
468
|
+
const lastAssistant = [...(g.history || [])].reverse().find((m) => m.role === 'assistant');
|
|
469
|
+
const summary = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('').trim();
|
|
470
|
+
return { goal, done: !!g.done, rounds: g.rounds, aborted: !!g.aborted, stalled: !!g.stalled, summary, artifacts, history: g.history };
|
|
471
|
+
},
|
|
418
472
|
};
|
|
419
473
|
return api;
|
|
420
474
|
}
|