agent-Specialization/cli/src/App.tsx
JOJO 5459d82637 feat(cli): 面板与状态栏接真实数据,/new 对齐草稿语义;host 设置面双通道化
CLI(bun 跑源码即生效):
- boot:loadBootData 一次性加载模型清单(含 context_window)/个性化默认
  (含压缩设置)/path 授权/会话列表;新会话 unshift 标 current
- /session:Enter 真实加载(timeline.reset + getSessionHistory 渲染历史文本)
- /new:对齐 web /new 页面语义——空对话草稿不立即建会话,首条消息
  createTask 不带 cid 由服务端惰性创建,响应回 id 后插入列表标 current
- /context + 状态栏:token_update 事件(运行期事件流)+ 打开面板时查询
  新端点 GET /api/runtime/sessions/<cid>/token-stats;百分比算法对齐 web
  InputComposer(自动压缩阈值优先于模型窗口)
- /model:Enter = 本地生效 + createTask 带 model_key/run_mode/thinking_mode
  覆盖(会话级即时)+ savePersonalization patch 存默认(effort default 存 null)
- /path:增删后 savePathAuths 全量提交两组(服务端 deny 校验保留)
- /agents /tasks /workflow /rewind:打开时拉真实列表(sub_agents/
  background_commands/workflows/versioning-checkpoints)
- 发送链路统一 sendWithSettings 携带 /model 生效值(含排队队列)

后端:
- 8 端点装饰器 api_login_required → api_login_or_host_token_required
  (personalization×2 / path-authorization×2 / sub_agents / background_commands
  / workflows / versioning-checkpoints),web session 行为不变
- headless_app 用 add_url_rule 共享上述视图函数(URL 与 full 一致);
  sub_agents/background_commands 支持 query 显式传 conversation_id
  (Bearer 装配的 terminal 无当前对话概念)
- RuntimeService 新增 get_session_token_stats;gateway_api 挂 token-stats 路由
- headless 404 兜底:非 /api/ 的 GET 路径回落告知页(对齐 SPA fallback)

验证:tsc  py_compile  冒烟 6/6  headless 装配 54 路由 
localconfig 无头实测读到真实配置 ;真实服务端到端待用户重启 8091 实机验收

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-11 21:20:58 +08:00

438 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 主界面:时间线(真实事件流)+ 交互区(/ 菜单与面板)+ 输入栏 + 状态栏
// 与 demo 的差异:无演示播放(消息来自 Gateway 任务事件流);审批事件自动弹出;
// 运行中 Enter=排队(本地队列,运行结束后自动发送,对齐 Web tryAutoSendRuntimeQueuedMessages
import { useKeyboard, useRenderer } from '@opentui/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { TextareaRenderable } from '@opentui/core';
import {
BlockView,
Composer,
HintBar,
QueueList,
StatusBar,
adaptiveColors,
resolveAdaptiveColors,
} from './components';
import { SlashMenu } from './menu';
import { useSlashMenu } from './menuState';
import { EFFORT_META } from './data';
import { t } from './i18n';
import { ChatRuntime } from './runtime';
import { CPS_INSTANT, THINK_COLLAPSE_DELAY, type Block, type TimelineApi } from './timeline';
import type { BootResult } from './boot';
const TICK_MS = 100;
/** 与 menuState 中一致的 / token 检测(提交拦截用) */
const SLASH_TOKEN_RE = /(^|[ \n])\/([^\s/]*)$/;
let idCounter = 0;
function makeApi(setBlocks: React.Dispatch<React.SetStateAction<Block[]>>, getElapsed: () => number): TimelineApi {
let thinkingId: number | null = null;
let assistantId: number | null = null;
const patchById = (id: number, fn: (b: Block) => Block) => {
setBlocks((prev) => prev.map((b) => (b.id === id ? fn(b) : b)));
};
const api: TimelineApi = {
reset() {
thinkingId = null;
assistantId = null;
setBlocks([]);
},
addUser(text) {
setBlocks((prev) => [...prev, { kind: 'user', id: idCounter++, text }]);
},
addGuide(text) {
setBlocks((prev) => [...prev, { kind: 'guide', id: idCounter++, text }]);
},
addSystem(text) {
setBlocks((prev) => [...prev, { kind: 'system', id: idCounter++, text }]);
},
startThinking() {
const id = idCounter++;
thinkingId = id;
setBlocks((prev) => [
...prev,
{ kind: 'thinking', id, full: '', revealStart: 0, cps: CPS_INSTANT, collapseAt: Number.POSITIVE_INFINITY },
]);
return id;
},
appendThinking(chunk) {
if (thinkingId == null) api.startThinking(); // 容错:无 start 直接来 chunk 时隐式建块
const id = thinkingId!;
patchById(id, (b) => (b.kind === 'thinking' ? { ...b, full: b.full + chunk } : b));
},
endThinking() {
if (thinkingId == null) return;
const id = thinkingId;
thinkingId = null;
const collapseAt = getElapsed() + THINK_COLLAPSE_DELAY;
patchById(id, (b) => (b.kind === 'thinking' ? { ...b, collapseAt } : b));
},
startAssistant() {
const id = idCounter++;
assistantId = id;
setBlocks((prev) => [...prev, { kind: 'assistant', id, full: '', revealStart: 0, cps: CPS_INSTANT }]);
return id;
},
appendAssistant(chunk) {
if (assistantId == null) api.startAssistant();
const id = assistantId!;
patchById(id, (b) => (b.kind === 'assistant' ? { ...b, full: b.full + chunk } : b));
},
startTool(init) {
const id = idCounter++;
setBlocks((prev) => [
...prev,
{ kind: 'tool', id, title: init.title, params: init.params ?? [], status: 'running', resultLines: [] },
]);
return id;
},
finishTool(result, resultLines = []) {
setBlocks((prev) => patchLastTool(prev, { status: 'done', result, resultLines }));
},
failTool(result, resultLines = []) {
setBlocks((prev) => patchLastTool(prev, { status: 'error', result, resultLines }));
},
};
return api;
}
/** 落定最近一个运行中的工具块(单线程模型顺序执行;按 tool_call_id 精确对应留待后续) */
function patchLastTool(blocks: Block[], patch: Partial<Extract<Block, { kind: 'tool' }>>): Block[] {
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i]!;
if (b.kind === 'tool' && b.status === 'running') {
const next = blocks.slice();
next[i] = { ...b, ...patch } as Block;
return next;
}
}
return blocks;
}
/** 子智能体端点状态 → 面板状态running/idle 直通,失败类归 error其余终态归 terminated */
function mapAgentStatus(status: unknown): 'running' | 'idle' | 'terminated' | 'error' {
const s = String(status ?? '').toLowerCase();
if (s === 'running' || s === 'idle') return s;
if (s === 'failed' || s === 'error') return 'error';
return 'terminated';
}
/** 后台指令端点状态 → 面板状态completed→donefailed/timeout/cancelled→error */
function mapCommandStatus(status: unknown): 'running' | 'done' | 'error' {
const s = String(status ?? '').toLowerCase();
if (s === 'completed' || s === 'done' || s === 'succeeded') return 'done';
if (s === 'failed' || s === 'timeout' || s === 'cancelled' || s === 'error') return 'error';
return 'running';
}
/** created_atepoch 秒或 ISO→ 运行时长粗文案mm:ss / hh:mm */
function relativeElapsed(createdAt: unknown): string {
let ts = 0;
if (typeof createdAt === 'number') ts = createdAt > 1e12 ? createdAt : createdAt * 1000;
else ts = Date.parse(String(createdAt ?? ''));
if (!Number.isFinite(ts) || ts <= 0) return '';
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
const mm = Math.floor(sec / 60);
const ss = sec % 60;
if (mm < 60) return `${mm}:${String(ss).padStart(2, '0')}`;
return `${Math.floor(mm / 60)}:${String(mm % 60).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}
export function App({ boot }: { boot: BootResult }) {
const renderer = useRenderer();
const [blocks, setBlocks] = useState<Block[]>([]);
const [elapsed, setElapsed] = useState(0);
const [expanded, setExpanded] = useState(false);
const [queue, setQueue] = useState<string[]>([]);
const [running, setRunning] = useState(false);
const elapsedRef = useRef(0);
elapsedRef.current = elapsed;
const apiRef = useRef<TimelineApi | null>(null);
apiRef.current = useMemo(() => makeApi(setBlocks, () => elapsedRef.current), []);
const textareaRef = useRef<TextareaRenderable>(null);
const width = (renderer as unknown as { width?: number }).width ?? 78;
// 审批裁决 → Gatewayruntime 在下方创建,经 ref 互指解开循环依赖)
const runtimeRef = useRef<ChatRuntime | null>(null);
const menu = useSlashMenu({
textareaRef,
apiRef,
destroy: () => {
runtimeRef.current?.destroy();
renderer.destroy();
process.exit(0);
},
width,
elapsed,
onApprovalAction: (action, approval) => {
if (action === 'reject') {
void runtimeRef.current?.decideApproval(approval.id, 'rejected');
apiRef.current?.addSystem(`${t('approval.rejected')}${approval.toolLabel}`);
} else {
void runtimeRef.current?.decideApproval(approval.id, 'approved');
apiRef.current?.addSystem(
action === 'unrestricted'
? `${t('approval.switched')}${approval.toolLabel}`
: `${t('approval.approved')}${approval.toolLabel}`,
);
}
},
onSessionLoad: (s) => {
void runtimeRef.current?.loadSession(s.id);
},
onContextRefresh: () => {
void runtimeRef.current?.refreshTokenStats();
},
onSaveModelDefaults: (v) => {
// patch 语义(服务端 sanitize fallback=existingeffort 'default' = null不指定
void boot.gateway
.savePersonalization({
default_model: v.model,
default_run_mode: v.thinking ? 'deep' : 'fast',
default_reasoning_effort: v.effort === 'default' ? null : v.effort,
})
.catch((err) =>
apiRef.current?.addSystem(`${t('settings.saveFailed')}${err instanceof Error ? err.message : String(err)}`),
);
},
onSavePathAuths: (auths) => {
void boot.gateway
.savePathAuths(
auths.filter((a) => a.access === 'rw').map((a) => a.path),
auths.filter((a) => a.access === 'ro').map((a) => a.path),
)
.catch((err) =>
apiRef.current?.addSystem(`${t('settings.saveFailed')}${err instanceof Error ? err.message : String(err)}`),
);
},
onAgentsRefresh: () => {
const cid = runtimeRef.current?.conversationId ?? '';
void boot.gateway
.listSubAgents(cid)
.then((list) => {
menuRef.current.setAgents(
list.map((a: any) => ({
name: String(a.display_name ?? a.agent_name ?? a.task_id ?? '?'),
task: String(a.task ?? a.description ?? ''),
status: mapAgentStatus(a.status),
elapsed: relativeElapsed(a.created_at),
})),
);
})
.catch(() => {});
},
onTasksRefresh: () => {
const cid = runtimeRef.current?.conversationId ?? '';
void boot.gateway
.listBackgroundCommands(cid)
.then((list) => {
menuRef.current.setTasks(
list.map((c: any) => ({
id: String(c.command_id ?? ''),
command: String(c.command ?? ''),
status: mapCommandStatus(c.status),
lastLine: typeof c.return_code === 'number' ? `rc=${c.return_code}` : '',
})),
);
})
.catch(() => {});
},
onWorkflowsRefresh: () => {
void boot.gateway
.listWorkflows()
.then((list) => {
menuRef.current.setWorkflows(
list.map((w: any) => ({
name: String(w.name ?? '?'),
desc: String(w.description ?? w.desc ?? ''),
stages: Number(w.stage_count ?? w.stages ?? 0) || 0,
})),
);
})
.catch(() => {});
},
onCheckpointsRefresh: () => {
const cid = runtimeRef.current?.conversationId ?? '';
if (!cid) return;
void boot.gateway
.listCheckpoints(cid)
.then(({ items }) => {
menuRef.current.setCheckpoints(
items.map((c: any) => ({
id: String(c.seq ?? c.id ?? ''),
when: relativeElapsed(c.created_at ?? c.ts),
summary: String(c.summary ?? c.label ?? ''),
files: Number(c.files ?? c.file_count ?? 0) || 0,
})),
);
})
.catch(() => {});
},
onNewSession: () => {
// /new = web /new 页面语义:空对话草稿,不创建会话;列表全部取消 current
runtimeRef.current?.enterNewSession();
menuRef.current.setSessions((prev) => prev.map((s) => ({ ...s, current: false })));
apiRef.current?.addSystem(t('session.draftHint'));
},
});
const menuRef = useRef(menu);
menuRef.current = menu;
// 会话运行时:发消息/事件轮询/审批自动弹出(只创建一次)
if (!runtimeRef.current) {
const rt = new ChatRuntime(boot.gateway, {
api: apiRef.current,
onRunningChange: setRunning,
onApprovalRequired: (a) => menuRef.current.openApproval(a),
onSystemMessage: (text) => apiRef.current?.addSystem(text),
onTokenUpdate: (stats) => menuRef.current.setContextStats(stats),
onConversationCreated: (id) => {
// /new 草稿首发后:真实会话插入列表并标 current
menuRef.current.setSessions((prev) => [
{ id, title: t('session.new'), when: t('time.justNow'), current: true },
...prev.map((s) => ({ ...s, current: false })),
]);
},
tr: t,
});
rt.conversationId = boot.conversationId;
runtimeRef.current = rt;
}
const runningRef = useRef(running);
runningRef.current = running;
const queueRef = useRef(queue);
queueRef.current = queue;
// 启动时亮暗检测:结果写入 adaptiveColorsmodule 级setState 触发一次重渲染
const [, setColorsTick] = useState(0);
useEffect(() => {
void resolveAdaptiveColors(renderer).then((c) => {
adaptiveColors.cursor = c.cursor;
setColorsTick((v) => v + 1);
});
}, [renderer]);
// 光标常驻输入栏(点击/拖选其他区域会切走焦点,主循环强制恢复)
useEffect(() => {
const timer = setInterval(() => {
const ta = textareaRef.current;
if (ta && !ta.focused) ta.focus();
}, TICK_MS);
return () => clearInterval(timer);
}, []);
// app 时钟:驱动思考折叠/闪烁点/revealedText无演示播放
useEffect(() => {
const startedAt = Date.now();
const timer = setInterval(() => setElapsed(Date.now() - startedAt), TICK_MS);
return () => clearInterval(timer);
}, []);
// 运行结束后,提前输入队列逐条自动发送
useEffect(() => {
if (running || queue.length === 0) return;
const timer = setTimeout(() => {
const [head, ...rest] = queue;
if (head) sendWithSettings(head);
setQueue(rest);
}, 300);
return () => clearTimeout(timer);
}, [running, queue]);
// 发送统一入口:携带 /model 面板的生效值(会话级覆盖,服务端优先级最高)
const sendWithSettings = (text: string) => {
const sf = menuRef.current.statusFields;
void runtimeRef.current?.send(text, {
model_key: sf.model || undefined,
thinking_mode: sf.thinking,
run_mode: sf.thinking ? 'deep' : 'fast',
});
};
// 引导:输入框有字=直接引导注入当前轮;无字=提升队首为引导
// TODO(gateway):接 POST /api/tasks/<id>/runtime_guidance当前仅本地上屏服务端注入后续接
const handleGuide = () => {
if (!runningRef.current) return;
const text = (textareaRef.current?.plainText ?? '').trim();
if (text) {
textareaRef.current?.setText('');
apiRef.current?.addGuide(text);
return;
}
const [head, ...rest] = queueRef.current;
if (head) {
setQueue(rest);
apiRef.current?.addGuide(head);
}
};
useKeyboard((key) => {
// 菜单打开时 ↑↓/←→/Enter/Esc/删除键 优先给交互区面板
if (menu.handleKey(key)) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
runtimeRef.current?.destroy();
renderer.destroy();
process.exit(0);
}
if (key.ctrl && key.name === 'e') {
key.preventDefault();
setExpanded((v) => !v);
}
if (key.ctrl && key.name === 'g') {
key.preventDefault();
handleGuide();
}
});
// Enter菜单关闭时运行中=提前输入排队;空闲=直接发送
const handleSubmit = () => {
const text = (textareaRef.current?.plainText ?? '').trim();
if (!text || SLASH_TOKEN_RE.test(text)) return;
textareaRef.current?.setText('');
if (runningRef.current) {
setQueue((q) => [...q, text]);
} else {
sendWithSettings(text);
}
};
const sf = menu.statusFields;
return (
<box flexDirection="column" width="100%" height="100%">
<HintBar />
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom" verticalScrollbarOptions={{ visible: false }}>
<box flexDirection="column" paddingX={1}>
{blocks.map((block) => (
<BlockView key={block.id} block={block} elapsed={elapsed} thinkingExpanded={expanded} />
))}
</box>
</scrollbox>
{queue.length > 0 ? <QueueList queue={queue} /> : null}
{menu.slashMenuProps ? <SlashMenu {...menu.slashMenuProps} /> : null}
<Composer
finished={!running}
textareaRef={textareaRef}
onSubmitMessage={handleSubmit}
onContentChange={menu.handleContentChange}
placeholderOverride={menu.inputPlaceholder}
/>
<StatusBar
model={sf.model}
thinking={sf.thinking}
effortLabel={EFFORT_META[sf.effort].label}
workMode={sf.workMode}
permMode={sf.permMode}
execEnv={sf.execEnv}
contextUsage={sf.contextUsage}
/>
</box>
);
}