xitto-kernel 0.9.3 → 0.9.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 +11 -0
- package/package.json +1 -1
- package/src/app/server.js +32 -5
- package/src/app/web/index.html +21 -0
- package/src/kernel/index.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4
|
|
4
|
+
|
|
5
|
+
- **執行中可中途補充(steering)**:任務跑到一半,使用者可隨時插話調整方向/補需求,不必取消重來。
|
|
6
|
+
- 排隊注入,**不中斷正在跑的工具**——下一個邊界才生效:
|
|
7
|
+
- agent 串流中 → 即時排進 agent 的 steeringQueue(turn 邊界 drain)
|
|
8
|
+
- 回合之間(goal loop 的驗收空檔,agent 已收尾)→ 緩衝到 task,kernel 下一輪用 `drainSteer` 折進指令
|
|
9
|
+
- 兩路互斥,不重複套用
|
|
10
|
+
- 後端:`createTaskStore.steer(id,text)` + `POST /v1/tasks/:id/steer`;kernel `runGoal` 新增 `opts.drainSteer` 鉤子
|
|
11
|
+
- 許願台:進行中顯示補充輸入框(Enter 送出)+「✋ 已收到,會在下一步納入」回饋;輸入內容/游標在每 1.2s 輪詢重繪間保留,不洗掉打到一半的字
|
|
12
|
+
- 測試 +4(196/196):串流即時路徑、回合間緩衝/drain-once、非進行中擋下、drainSteer 折進指令
|
|
13
|
+
|
|
3
14
|
## 0.9.3
|
|
4
15
|
|
|
5
16
|
- **許願台佈局優化(視覺層次 + 互動細節)**:
|
package/package.json
CHANGED
package/src/app/server.js
CHANGED
|
@@ -139,6 +139,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
139
139
|
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); }
|
|
140
140
|
else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
|
|
141
141
|
else if (ev.type === 'phase') p.phase = ev.phase;
|
|
142
|
+
else if (ev.type === 'steered') { (p.steers ||= []).push(ev.text); if (p.steers.length > 8) p.steers.shift(); } // 使用者中途補充(給 UI 回饋「已收到」)
|
|
142
143
|
else if (ev.type === 'needs_input') p.phase = 'needs-input';
|
|
143
144
|
else if (ev.type === 'answered') p.phase = 'acting';
|
|
144
145
|
else if (ev.type === 'end') p.phase = ev.status;
|
|
@@ -160,7 +161,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
160
161
|
t.status = 'running'; t.startedAt = new Date().toISOString();
|
|
161
162
|
emit(t, { type: 'status', status: 'running' });
|
|
162
163
|
Promise.resolve()
|
|
163
|
-
.then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }))
|
|
164
|
+
.then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }, () => { const b = t.steerBuf || []; t.steerBuf = []; return b; }))
|
|
164
165
|
.then((result) => { t.status = 'done'; t.result = result; })
|
|
165
166
|
.catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
|
|
166
167
|
.finally(() => {
|
|
@@ -214,6 +215,19 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
214
215
|
resolve(String(text ?? ''));
|
|
215
216
|
return true;
|
|
216
217
|
},
|
|
218
|
+
// 中途補充(steering):任務進行中,使用者插話。agent 正在串流 → 即時排進 steeringQueue(下個 turn 邊界生效,不中斷當前工具);
|
|
219
|
+
// 回合之間(goal loop 的 checkGoal 空檔,agent 已收尾)→ 緩衝到 task,由 kernel 下一輪 drainSteer 折進指令。兩路互斥,不重複套用。
|
|
220
|
+
steer(id, text) {
|
|
221
|
+
const t = tasks.get(id);
|
|
222
|
+
if (!t || t.status !== 'running') return false;
|
|
223
|
+
const msg = String(text ?? '').trim();
|
|
224
|
+
if (!msg) return false;
|
|
225
|
+
const live = !!(t._agent && t._agent.state && t._agent.state.isStreaming);
|
|
226
|
+
if (live) { try { t._agent.steer({ role: 'user', content: [{ type: 'text', text: msg }] }); } catch { (t.steerBuf ||= []).push(msg); } }
|
|
227
|
+
else (t.steerBuf ||= []).push(msg);
|
|
228
|
+
emit(t, { type: 'steered', text: msg, queued: !live });
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
217
231
|
stats: () => ({ active, queued: queue.length, total: tasks.size }),
|
|
218
232
|
};
|
|
219
233
|
}
|
|
@@ -257,7 +271,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
257
271
|
|
|
258
272
|
// 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
|
|
259
273
|
// ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
|
|
260
|
-
async function runKernel(spec, onEvent, ask, onAgent) {
|
|
274
|
+
async function runKernel(spec, onEvent, ask, onAgent, drainSteer) {
|
|
261
275
|
const make = PACKS[spec.pack || 'general'];
|
|
262
276
|
if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
|
|
263
277
|
// 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
|
|
@@ -272,7 +286,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
272
286
|
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); };
|
|
273
287
|
if (spec.mode === 'goal') {
|
|
274
288
|
// 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
|
|
275
|
-
const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
|
|
289
|
+
const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, drainSteer, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
|
|
276
290
|
sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
|
|
277
291
|
try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
|
|
278
292
|
// 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
|
|
@@ -295,7 +309,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
295
309
|
const tasks = createTaskStore({
|
|
296
310
|
concurrency,
|
|
297
311
|
persistDir: join(baseDir, 'tasks'),
|
|
298
|
-
runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
|
|
312
|
+
runJob: (spec, emit, ask, onAgent, drainSteer) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent, drainSteer),
|
|
299
313
|
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); },
|
|
300
314
|
});
|
|
301
315
|
|
|
@@ -359,6 +373,19 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
359
373
|
return json(res, 200, { ok: true, taskId: mAns[1], status: 'running' });
|
|
360
374
|
}
|
|
361
375
|
|
|
376
|
+
// 中途補充(steering):任務進行中,使用者插話調整方向/補需求。排隊注入,不中斷當前工作,下個邊界生效。
|
|
377
|
+
const mSteer = path.match(/^\/v1\/tasks\/([^/]+)\/steer$/);
|
|
378
|
+
if (req.method === 'POST' && mSteer) {
|
|
379
|
+
const body = await readBody(req);
|
|
380
|
+
const t = tasks.get(mSteer[1]);
|
|
381
|
+
if (!t) return json(res, 404, { error: 'task not found' });
|
|
382
|
+
if (t.status !== 'running') return json(res, 409, { error: '只有進行中的任務可以補充', status: t.status });
|
|
383
|
+
const ok = tasks.steer(mSteer[1], body.text);
|
|
384
|
+
if (!ok) return json(res, 400, { error: '補充內容為空或無法送出' });
|
|
385
|
+
log({ task: mSteer[1], action: 'steer' });
|
|
386
|
+
return json(res, 200, { ok: true, taskId: mSteer[1] });
|
|
387
|
+
}
|
|
388
|
+
|
|
362
389
|
// 中斷任務(取消鈕):控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
|
|
363
390
|
const mCancel = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
|
|
364
391
|
if (req.method === 'POST' && mCancel) {
|
|
@@ -436,7 +463,7 @@ export function startServer() {
|
|
|
436
463
|
console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
|
|
437
464
|
console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
|
|
438
465
|
console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
|
|
439
|
-
console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
|
|
466
|
+
console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,steer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
|
|
440
467
|
});
|
|
441
468
|
return server;
|
|
442
469
|
}
|
package/src/app/web/index.html
CHANGED
|
@@ -79,6 +79,10 @@
|
|
|
79
79
|
.qbox { margin-top:12px; padding:12px; background:#1d1c12; border:1px solid var(--warn); border-radius:10px; }
|
|
80
80
|
.qbox .q { color:var(--warn); margin-bottom:8px; }
|
|
81
81
|
.qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
|
|
82
|
+
.steerbox { margin-top:12px; padding-top:10px; border-top:1px dashed var(--line); }
|
|
83
|
+
.steernote { font-size:12.5px; color:var(--ok); margin-bottom:6px; }
|
|
84
|
+
#steerin { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px 11px; font:inherit; outline:none; }
|
|
85
|
+
#steerin:focus { border-color:var(--accent); }
|
|
82
86
|
h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
|
|
83
87
|
.hist { border:1px solid transparent; border-radius:8px; padding:8px 10px; margin:3px 0; cursor:pointer; }
|
|
84
88
|
.hist:hover { background:#0c0e12; }
|
|
@@ -332,6 +336,8 @@ async function poll() {
|
|
|
332
336
|
}
|
|
333
337
|
|
|
334
338
|
function renderCurrent(t) {
|
|
339
|
+
// 保住 steer 輸入框的內容與游標:輪詢每 1.2s 重繪整張卡,別把使用者正打到一半的補充洗掉
|
|
340
|
+
const ps=(()=>{const el=document.getElementById("steerin");return el?{v:el.value,f:document.activeElement===el,s:el.selectionStart}:null;})();
|
|
335
341
|
const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
|
|
336
342
|
const p = t.progress||{}, nLog=(p.log||[]).length;
|
|
337
343
|
const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
|
|
@@ -344,6 +350,10 @@ function renderCurrent(t) {
|
|
|
344
350
|
${logSec}
|
|
345
351
|
${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
|
|
346
352
|
<input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
|
|
353
|
+
${t.status==="running"?`<div class="steerbox">
|
|
354
|
+
${(p.steers||[]).slice(-3).map(s=>`<div class="steernote">✋ 已收到補充,會在下一步納入:${esc(s)}</div>`).join("")}
|
|
355
|
+
<input id="steerin" placeholder="想補充或調整方向?打字按 Enter——會在下一步納入,不中斷目前工作">
|
|
356
|
+
</div>`:""}
|
|
347
357
|
${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
|
|
348
358
|
${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
|
|
349
359
|
${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>`:""}
|
|
@@ -364,6 +374,17 @@ function renderCurrent(t) {
|
|
|
364
374
|
poll();
|
|
365
375
|
}};
|
|
366
376
|
}
|
|
377
|
+
if (t.status==="running") {
|
|
378
|
+
const si = $("#steerin");
|
|
379
|
+
if (si) {
|
|
380
|
+
if (ps) { si.value = ps.v; if (ps.f) { si.focus(); try { si.setSelectionRange(ps.s, ps.s); } catch {} } } // 還原打到一半的內容/游標
|
|
381
|
+
si.onkeydown = async (e) => { if (e.key==="Enter" && si.value.trim()) {
|
|
382
|
+
const txt = si.value.trim(); si.value = ""; si.disabled = true;
|
|
383
|
+
await api("/v1/tasks/"+t.taskId+"/steer", { method:"POST", body: JSON.stringify({ text: txt }) });
|
|
384
|
+
si.disabled = false; si.focus();
|
|
385
|
+
}};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
367
388
|
}
|
|
368
389
|
|
|
369
390
|
// 繼續/調整:送出接續上一個任務對話(sessionId)+ 同工作區的後續任務
|
package/src/kernel/index.js
CHANGED
|
@@ -442,6 +442,11 @@ export function createKernel(pack, config = {}) {
|
|
|
442
442
|
let sameFeedback = 0;
|
|
443
443
|
for (let round = 1; round <= maxRounds; round++) {
|
|
444
444
|
opts.onRound?.({ round, maxRounds });
|
|
445
|
+
// 使用者中途補充(steering):把上一輪之間累積的補充折進這一輪指令(回合內的即時補充走 agent.steer)。
|
|
446
|
+
if (opts.drainSteer) {
|
|
447
|
+
const extra = opts.drainSteer();
|
|
448
|
+
if (extra && extra.length) instruction += '\n\n[使用者中途補充,請納入考量並據此調整]\n' + extra.map((s) => `- ${s}`).join('\n');
|
|
449
|
+
}
|
|
445
450
|
const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
|
|
446
451
|
history = r.messages;
|
|
447
452
|
if (r.aborted) return { done: false, aborted: true, rounds: round, history };
|