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>
This commit is contained in:
JOJO 2026-09-11 21:20:58 +08:00
parent 66fa90d6cc
commit 5459d82637
20 changed files with 682 additions and 81 deletions

View File

@ -111,7 +111,8 @@
- 兼容启动方式:`python web_server.py`
- 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程)
- **Headless 启动CLI 自启动专用)**`python -m server.headless_app --path <cwd> --port 8091 --thinking-mode`
- 只挂运行时蓝图gateway / tasks / status / approval / usage 五件套),不含 web 站点路由面(无 /login、无静态页、无会话管理根路径 `/` 返回简单 HTML 告知页
- 只挂运行时蓝图gateway / tasks / status / approval / usage 五件套),不含 web 站点路由面(无 /login、无静态页、无会话管理根路径 `/` 返回简单 HTML 告知页;非 /api/ 的 GET 路径 404 时回落告知页(对齐 SPA fallback 语义)
- **headless 额外挂 host 设置面**`add_url_rule` 直接共享 chat_bp/conversation_bp/workflow_page_bp 的视图函数URL 与 full 一致):`/api/personalization` GET/POST、`/api/path-authorization` GET/POST、`/api/sub_agents` GET、`/api/background_commands` GET、`/api/workflows` GET、`/api/conversations/<cid>/versioning/checkpoints` GET——这些端点装饰器已双通道化`api_login_or_host_token_required`list 类端点支持 query 显式传 conversation_idBearer 装配的 terminal 无当前对话概念)
- 与 full 形态同进程模型RuntimeService 单例/同一数据目录/同一端口 8091只是启动时挂的路由面不同**绝不能与 full 形态双进程并存**MultiAgentState/门闸/审批 Map 是进程内单例)
- CLI`cli/src/gateway.ts`)探测无服务时 spawn 的就是这个入口python 解释器经依赖探测import yaml/flask从 .venv → homebrew 3.12/3.11 → python3 中选第一个可用的

View File

@ -36,6 +36,11 @@ function makeApi(setBlocks: React.Dispatch<React.SetStateAction<Block[]>>, getEl
};
const api: TimelineApi = {
reset() {
thinkingId = null;
assistantId = null;
setBlocks([]);
},
addUser(text) {
setBlocks((prev) => [...prev, { kind: 'user', id: idCounter++, text }]);
},
@ -108,6 +113,35 @@ function patchLastTool(blocks: Block[], patch: Partial<Extract<Block, { kind: 't
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[]>([]);
@ -148,6 +182,103 @@ export function App({ boot }: { boot: BootResult }) {
);
}
},
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;
@ -159,6 +290,14 @@ export function App({ boot }: { boot: BootResult }) {
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;
@ -200,12 +339,22 @@ export function App({ boot }: { boot: BootResult }) {
if (running || queue.length === 0) return;
const timer = setTimeout(() => {
const [head, ...rest] = queue;
if (head) void runtimeRef.current?.send(head);
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 = () => {
@ -249,7 +398,7 @@ export function App({ boot }: { boot: BootResult }) {
if (runningRef.current) {
setQueue((q) => [...q, text]);
} else {
void runtimeRef.current?.send(text);
sendWithSettings(text);
}
};

View File

@ -6,7 +6,45 @@ import { useEffect, useRef, useState } from 'react';
import { RGBA, TextAttributes } from '@opentui/core';
import { GatewayClient, type WorkspaceItem } from './gateway';
import { t } from './i18n';
import { WORKSPACE } from './data';
import { BOOT_STATE, INITIAL_PATH_AUTHS, MODEL_OPTIONS, SESSIONS, WORKSPACE } from './data';
import { loadCustomModels, loadPathAuths, loadPersonalizationDefaults } from './localconfig';
/** ISO 时间 → 相对时间短文案(会话列表 when 列) */
function relativeTime(iso: unknown): string {
const ts = Date.parse(String(iso ?? ''));
if (!Number.isFinite(ts)) return '';
const diffMin = Math.floor((Date.now() - ts) / 60000);
if (diffMin < 1) return t('time.justNow');
if (diffMin < 60) return `${diffMin} ${t('time.minutesAgo')}`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour} ${t('time.hoursAgo')}`;
return `${Math.floor(diffHour / 24)} ${t('time.daysAgo')}`;
}
/** //+ Gateway
* token host */
async function loadBootData(gw: GatewayClient): Promise<void> {
if (MODEL_OPTIONS.length === 0) MODEL_OPTIONS.push(...loadCustomModels());
const prefs = loadPersonalizationDefaults();
BOOT_STATE.model = prefs.model || MODEL_OPTIONS[0]?.name || '';
BOOT_STATE.thinking = prefs.thinking;
BOOT_STATE.effort = prefs.effort;
BOOT_STATE.workMode = prefs.workMode;
BOOT_STATE.permMode = prefs.permMode;
BOOT_STATE.autoDeepCompress = prefs.autoDeepCompress;
BOOT_STATE.deepCompressLimit = prefs.deepCompressLimit;
if (INITIAL_PATH_AUTHS.length === 0) INITIAL_PATH_AUTHS.push(...loadPathAuths());
try {
const list = await gw.listSessions(20);
for (const c of list) {
const id = String(c?.id ?? c?.conversation_id ?? '');
if (!id) continue;
SESSIONS.push({ id, title: String(c?.title || t('session.untitled')), when: relativeTime(c?.updated_at) });
}
} catch {
// 列表失败不阻塞启动(/session 面板将只显示当前新会话)
}
}
const FG = RGBA.defaultForeground();
const DIM = TextAttributes.DIM;
@ -45,7 +83,11 @@ export function BootFlow({ cwd, onReady, onExit }: { cwd: string; onReady: (r: B
WORKSPACE.path = workspace.path;
setStage('creating');
try {
await loadBootData(gw);
const session = await gw.createSession();
// 新会话置顶并标 currentmenuState 初值读 SESSIONS
for (const s of SESSIONS) s.current = false;
SESSIONS.unshift({ id: session.conversation_id, title: t('session.new'), when: t('time.justNow'), current: true });
onReady({ gateway: gw, conversationId: session.conversation_id, workspace });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));

View File

@ -26,6 +26,9 @@ export const BOOT_STATE = {
execEnv: 'direct',
network: 'restricted',
contextUsage: '—',
/** 自动深度压缩开关与触发阈值(上下文百分比分母,对齐 web InputComposer 算法) */
autoDeepCompress: false,
deepCompressLimit: 150000,
};
// ── 工作区(/session 面板顶部展示boot 确定后写入) ──
@ -36,10 +39,12 @@ export interface WorkspaceInfo {
}
export const WORKSPACE: WorkspaceInfo = { id: '', name: '', path: '' };
// ── 模型boot 从 /api/v1/models 加载后填充) ──
// ── 模型boot 从本地 custom_models.json 加载后填充) ──
export interface ModelOption {
name: string;
meta: string;
/** 上下文窗口上限token配置缺省为 null百分比显示回退压缩阈值 */
contextWindow: number | null;
}
export const MODEL_OPTIONS: ModelOption[] = [];
@ -77,26 +82,49 @@ export interface PathAuth {
export const PATH_ACCESS_LABEL: Record<PathAccess, string> = { rw: t('path.access.rw'), ro: t('path.access.ro') };
export const INITIAL_PATH_AUTHS: PathAuth[] = [];
// ── 上下文统计(/context 面板;后续接会话 token 统计端点 ──
// ── 上下文统计(/context 面板与状态栏共用token_update 事件/查询端点刷新,数字型原始值 ──
export interface ContextStats {
used: string;
total: string;
percent: number;
totalInput: string;
totalOutput: string;
cacheInput: string;
cacheHitRate: string;
currentTokens: number;
totalInput: number;
totalOutput: number;
cacheInput: number;
cacheExemptInput: number;
}
export const CONTEXT_STATS: ContextStats = {
used: '—',
total: '—',
percent: 0,
totalInput: '—',
totalOutput: '—',
cacheInput: '—',
cacheHitRate: '—',
export const EMPTY_CONTEXT_STATS: ContextStats = {
currentTokens: 0,
totalInput: 0,
totalOutput: 0,
cacheInput: 0,
cacheExemptInput: 0,
};
/** token 数紧凑格式(对齐 web InputComposer≥1000 转 k<100k 保留一位小数) */
export function formatCompactTokens(value: number): string {
const n = Math.max(0, Math.round(value || 0));
if (n >= 1000) {
const raw = n / 1000;
const text = raw >= 100 ? String(Math.round(raw)) : raw.toFixed(1).replace(/\.0$/, '');
return `${text}k`;
}
return String(n);
}
/** 上下文百分比分母(对齐 web开自动深度压缩用触发阈值否则模型上下文窗口 */
export function contextUsageLimitFor(modelName: string): number {
if (BOOT_STATE.autoDeepCompress) return BOOT_STATE.deepCompressLimit;
const m = MODEL_OPTIONS.find((o) => o.name === modelName);
return m?.contextWindow ?? 0;
}
/** 状态栏上下文用量文案:`45% 90.2k/200k`limit 未知时 `90.2k/—` */
export function formatContextUsage(stats: ContextStats, modelName: string): string {
const cur = formatCompactTokens(stats.currentTokens);
const limit = contextUsageLimitFor(modelName);
if (limit <= 0) return `${cur}/—`;
const pct = Math.max(0, Math.min(100, Math.round((stats.currentTokens / limit) * 100)));
return `${pct}% ${cur}/${formatCompactTokens(limit)}`;
}
// ── 边界四选项(/mode /permission /env /network 共用 Selector 面板;切换后调对应 API ──
export interface BoundaryOption {
value: string;

View File

@ -190,7 +190,8 @@ export class GatewayClient {
async listSessions(limit = 20): Promise<any[]> {
const res = await this.request<any>(`/api/runtime/sessions?limit=${limit}`, { method: 'GET' });
return res.data?.sessions ?? res.sessions ?? res.data?.items ?? res.items ?? [];
// 顶层展开风格的实际键是 conversations8093 实测),兼容 sessions/items 包装
return res.data?.sessions ?? res.sessions ?? res.conversations ?? res.data?.items ?? res.items ?? [];
}
async getSessionHistory(conversationId: string): Promise<any> {
@ -200,8 +201,72 @@ export class GatewayClient {
return res.data ?? res.conversation ?? res;
}
/** 会话 token 统计(/context 面板;顶层展开风格 res.stats */
async getTokenStats(conversationId: string): Promise<any> {
const res = await this.request<any>(`/api/runtime/sessions/${encodeURIComponent(conversationId)}/token-stats`, {
method: 'GET',
});
return res.stats ?? res.data?.stats ?? res.data ?? {};
}
// ── host 全局设置(写操作;端点为 chat_bp 共享视图函数,装饰器双通道化后 Bearer 可达) ──
/** 保存个性化默认patch 语义:服务端 sanitize fallback=existing未传字段不动 */
async savePersonalization(patch: Record<string, unknown>): Promise<void> {
await this.request<any>('/api/personalization', { method: 'POST', body: patch });
}
/** 路径授权全量保存(端点语义为两组全量提交;返回服务端落定后的两组列表) */
async savePathAuths(writable: string[], readableExtra: string[]): Promise<{ writable: string[]; readableExtra: string[] }> {
const res = await this.request<any>('/api/path-authorization', {
method: 'POST',
body: { writable_paths: writable, readable_extra_paths: readableExtra },
});
return {
writable: res.writable_paths ?? res.data?.writable_paths ?? writable,
readableExtra: res.readable_extra_paths ?? res.data?.readable_extra_paths ?? readableExtra,
};
}
/** 子智能体列表(/agents 面板conversation_id 经 query 显式指定) */
async listSubAgents(conversationId: string): Promise<any[]> {
const q = conversationId ? `?conversation_id=${encodeURIComponent(conversationId)}` : '';
const res = await this.request<any>(`/api/sub_agents${q}`, { method: 'GET' });
return res.data ?? [];
}
/** 后台指令列表(/tasks 面板conversation_id 经 query 显式指定) */
async listBackgroundCommands(conversationId: string): Promise<any[]> {
const q = conversationId ? `?conversation_id=${encodeURIComponent(conversationId)}` : '';
const res = await this.request<any>(`/api/background_commands${q}`, { method: 'GET' });
return res.data ?? [];
}
/** 工作流库列表(/workflow 面板;注意该端点顶层展开为 {workflows},无 success 包装) */
async listWorkflows(): Promise<any[]> {
const res = await this.request<any>('/api/workflows', { method: 'GET' });
return res.workflows ?? res.data?.workflows ?? [];
}
/** 版本回溯检查点(/rewind 面板;返回 data.items含 enabled 标记) */
async listCheckpoints(conversationId: string): Promise<{ items: any[]; enabled: boolean }> {
const res = await this.request<any>(
`/api/conversations/${encodeURIComponent(conversationId)}/versioning/checkpoints`,
{ method: 'GET' },
);
const data = res.data ?? {};
return { items: Array.isArray(data.items) ? data.items : [], enabled: data.enabled !== false };
}
// ── Run任务 ──
async createTask(payload: { message: string; conversation_id?: string }): Promise<{ task_id: string; conversation_id?: string }> {
async createTask(payload: {
message: string;
conversation_id?: string;
/** 会话级覆盖(/model 面板生效值);不传则服务端用对话/个性化默认 */
model_key?: string;
run_mode?: string;
thinking_mode?: boolean;
}): Promise<{ task_id: string; conversation_id?: string }> {
const res = await this.request<{ data: any }>('/api/tasks', { method: 'POST', body: payload });
return res.data;
}

View File

@ -95,4 +95,26 @@ export default {
'tool.write_file': 'Write file',
'tool.edit_file': 'Edit file',
'tool.read_file': 'Read file',
// ── Session list / loading ──
'session.new': 'New chat',
'session.untitled': 'Untitled chat',
'session.draftHint': 'New chat draft (starts on first message)',
'session.loaded': 'Loaded conversation: ',
'session.loadFailed': 'Failed to load conversation: ',
'time.justNow': 'just now',
'time.minutesAgo': 'm ago',
'time.hoursAgo': 'h ago',
'time.daysAgo': 'd ago',
// ── /context panel ──
'context.title': 'Context usage Esc to close',
'context.current': 'Current ',
'context.totalInput': 'Input ',
'context.totalOutput': 'Output ',
'context.cacheInput': 'Cached ',
'context.cacheHitRate': 'Hit rate',
// ── Settings persistence ──
'settings.saveFailed': 'Failed to save settings: ',
} as const;

View File

@ -95,4 +95,26 @@ export default {
'tool.write_file': '写入文件',
'tool.edit_file': '编辑文件',
'tool.read_file': '读取文件',
// ── 会话列表 / 加载 ──
'session.new': '新对话',
'session.untitled': '未命名对话',
'session.draftHint': '已进入新对话(发送消息后开始)',
'session.loaded': '已加载对话:',
'session.loadFailed': '加载对话失败:',
'time.justNow': '刚刚',
'time.minutesAgo': '分钟前',
'time.hoursAgo': '小时前',
'time.daysAgo': '天前',
// ── /context 面板 ──
'context.title': '上下文用量 Esc 关闭',
'context.current': '当前上下文',
'context.totalInput': '累计输入 ',
'context.totalOutput': '累计输出 ',
'context.cacheInput': '缓存输入 ',
'context.cacheHitRate': '缓存命中率',
// ── 设置保存 ──
'settings.saveFailed': '保存设置失败:',
} as const;

View File

@ -30,7 +30,7 @@ function resolveDeployConfig(name: string): any | null {
return null;
}
/** 模型清单custom_models.jsonvisible=false 的条目不上架 */
/** 模型清单custom_models.jsonvisible=false 的条目不上架context_window 透传供上下文百分比 */
export function loadCustomModels(): ModelOption[] {
const cfg = resolveDeployConfig('custom_models.json');
const items = Array.isArray(cfg?.models) ? cfg.models : [];
@ -39,6 +39,7 @@ export function loadCustomModels(): ModelOption[] {
.map((m: any) => ({
name: String(m.model_name),
meta: String(m.model_description || m.description || ''),
contextWindow: typeof m.context_window === 'number' && m.context_window > 0 ? m.context_window : null,
}));
}
@ -50,14 +51,18 @@ export interface BootPreferences {
effort: EffortLevel;
workMode: string;
permMode: string;
/** 自动深度压缩开关与触发阈值(上下文百分比分母,对齐 web 端算法) */
autoDeepCompress: boolean;
deepCompressLimit: number;
}
const EFFORT_VALUES: EffortLevel[] = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
/** 个性化默认personalization.json默认模型/运行模式/推理强度/工作模式/权限模式 */
/** 个性化默认personalization.json默认模型/运行模式/推理强度/工作模式/权限模式/压缩设置 */
export function loadPersonalizationDefaults(): BootPreferences {
const p = readJson(resolve(HOST_DATA_DIR, 'personalization.json')) ?? {};
const effortRaw = String(p.default_reasoning_effort ?? 'default');
const limitRaw = Number(p.deep_compress_trigger_tokens);
return {
model: String(p.default_model ?? ''),
runMode: String(p.default_run_mode ?? 'fast'),
@ -65,6 +70,8 @@ export function loadPersonalizationDefaults(): BootPreferences {
effort: (EFFORT_VALUES as string[]).includes(effortRaw) ? (effortRaw as EffortLevel) : 'default',
workMode: String(p.default_work_mode ?? 'ask'),
permMode: String(p.default_permission_mode ?? 'unrestricted'),
autoDeepCompress: !!p.auto_deep_compress_enabled,
deepCompressLimit: Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 150000,
};
}

View File

@ -20,15 +20,16 @@ import { padEndWidth } from './width';
import {
BOUNDARY_PANELS,
MODEL_OPTIONS,
WORKFLOWS,
CHECKPOINTS,
type AgentMock,
type CheckpointMock,
type ContextStats,
type EffortLevel,
type PathAccess,
type PathAuth,
type PendingApprovalMock,
type SessionMock,
type TaskMock,
type WorkflowMock,
} from './data';
const FG = RGBA.defaultForeground();
@ -63,6 +64,12 @@ export interface SlashMenuProps {
pathAuths: PathAuth[];
pendingApproval: PendingApprovalMock | null;
activeWorkflow: string | null;
/** 上下文统计(/context 面板token_update 事件与打开时查询双源刷新) */
contextStats: ContextStats;
/** 工作流库(/workflow打开时经 API 刷新) */
workflows: WorkflowMock[];
/** 版本回溯检查点(/rewind打开时经 API 刷新) */
checkpoints: CheckpointMock[];
/** 当前对话标题(/rename /delete /export 面板显示用) */
currentSessionTitle: string;
}
@ -164,7 +171,7 @@ export function SlashMenu(props: SlashMenuProps) {
case 'path':
return <PathsPanel sel={props.sel} width={props.width} pathAuths={props.pathAuths} group={props.pathGroup} />;
case 'context':
return <ContextPanel width={props.width} />;
return <ContextPanel width={props.width} stats={props.contextStats} model={props.model} />;
case 'rename':
return <InputPanel title="重命名对话" hint={`当前 ${props.currentSessionTitle}`} width={props.width} />;
case 'export':
@ -176,9 +183,9 @@ export function SlashMenu(props: SlashMenuProps) {
case 'approvals':
return <ApprovalsPanel sel={props.sel} width={props.width} pending={props.pendingApproval} />;
case 'workflow':
return <WorkflowPanel sel={props.sel} width={props.width} workflows={WORKFLOWS} active={props.activeWorkflow} />;
return <WorkflowPanel sel={props.sel} width={props.width} workflows={props.workflows} active={props.activeWorkflow} />;
case 'rewind':
return <RewindPanel sel={props.sel} width={props.width} checkpoints={CHECKPOINTS} />;
return <RewindPanel sel={props.sel} width={props.width} checkpoints={props.checkpoints} />;
case 'help':
return <HelpPanel sel={props.sel} width={props.width} />;
case 'agents':
@ -212,9 +219,9 @@ export function panelItemCount(props: SlashMenuProps): number {
case 'approvals':
return 0; // 单条待审批,无 ↑↓ 列表;←→ 选择操作
case 'workflow':
return WORKFLOWS.length;
return props.workflows.length;
case 'rewind':
return CHECKPOINTS.length;
return props.checkpoints.length;
case 'help':
return helpMaxOffset() + 1; // sel 在此面板作为滚动偏移
case 'context':

View File

@ -10,24 +10,28 @@ import { panelItemCount, type BoundaryKind, type SlashMenuProps } from './menu';
import type { ModelStep } from './panels/model';
import { APPROVAL_ACTIONS } from './panels/approvals';
import {
BOOT_STATE,
BOUNDARY_PANELS,
CHECKPOINTS,
EFFORT_LEVELS,
EFFORT_META,
EMPTY_CONTEXT_STATS,
INITIAL_AGENTS,
INITIAL_PATH_AUTHS,
INITIAL_TASKS,
MODEL_OPTIONS,
PATH_ACCESS_LABEL,
SESSIONS,
WORKFLOWS,
formatContextUsage,
type AgentMock,
type CheckpointMock,
type ContextStats,
type EffortLevel,
type PathAccess,
type PathAuth,
type PendingApprovalMock,
type SessionMock,
type TaskMock,
type WorkflowMock,
} from './data';
import type { TimelineApi } from './timeline';
@ -58,6 +62,15 @@ export function useSlashMenu({
width,
elapsed,
onApprovalAction,
onSessionLoad,
onContextRefresh,
onSaveModelDefaults,
onSavePathAuths,
onAgentsRefresh,
onTasksRefresh,
onWorkflowsRefresh,
onCheckpointsRefresh,
onNewSession,
}: {
textareaRef: React.RefObject<TextareaRenderable | null>;
apiRef: React.RefObject<TimelineApi | null>;
@ -66,24 +79,40 @@ export function useSlashMenu({
elapsed: number;
/** 审批裁决回调(正式版由 runtime 注入,调 Gateway decision 端点);缺省只上屏系统消息 */
onApprovalAction?: (action: 'run' | 'reject' | 'unrestricted', approval: PendingApprovalMock) => void;
/** /session Enter加载选中对话runtime.loadSession缺省只上屏系统消息 */
onSessionLoad?: (session: SessionMock) => void;
/** /context 打开:触发一次 token 统计查询gw.getTokenStats结果经 setContextStats 回流) */
onContextRefresh?: () => void;
/** /model Enter保存为默认gw.savePersonalization patch失败由注入方上屏提示 */
onSaveModelDefaults?: (v: { model: string; thinking: boolean; effort: EffortLevel }) => void;
/** /path 增删后全量保存两组授权gw.savePathAuths失败由注入方上屏提示 */
onSavePathAuths?: (auths: PathAuth[]) => void;
/** /agents /tasks 打开拉真实列表gw.listSubAgents/listBackgroundCommands结果经 setter 回流) */
onAgentsRefresh?: () => void;
onTasksRefresh?: () => void;
/** /workflow /rewind 打开拉真实列表gw.listWorkflows/listCheckpoints结果经 setter 回流) */
onWorkflowsRefresh?: () => void;
onCheckpointsRefresh?: () => void;
/** /new进入空对话草稿runtime.enterNewSession对齐 web /new 页面语义) */
onNewSession?: () => void;
}) {
// / 菜单menuStack=null 关闭;['commands'] 命令层;级联 push如 ['commands','model']
const [menuStack, setMenuStack] = useState<PanelKind[] | null>(null);
const [query, setQuery] = useState('');
const [sel, setSel] = useState(0);
// /model 两级流程
const [model, setModel] = useState('kimi-k2.6');
const [pendingModel, setPendingModel] = useState('kimi-k2.6');
// /model 两级流程(初值 = boot 加载的真实快照 BOOT_STATE
const [model, setModel] = useState(BOOT_STATE.model);
const [pendingModel, setPendingModel] = useState(BOOT_STATE.model);
const [modelStep, setModelStep] = useState<ModelStep>('model');
const [thinking, setThinking] = useState(true);
const [effort, setEffort] = useState<EffortLevel>('high');
const [thinking, setThinking] = useState(BOOT_STATE.thinking);
const [effort, setEffort] = useState<EffortLevel>(BOOT_STATE.effort);
// 面板内编辑中的强度Enter 才提交到 effortEsc 放弃不影响已生效值)
const [pendingEffort, setPendingEffort] = useState<EffortLevel>('high');
// 边界value 与 mock.BOUNDARY_PANELS 对齐)
const [workMode, setWorkMode] = useState('ask');
const [permMode, setPermMode] = useState('unrestricted');
const [execEnv, setExecEnv] = useState('direct');
const [network, setNetwork] = useState('restricted');
const [pendingEffort, setPendingEffort] = useState<EffortLevel>(BOOT_STATE.effort);
// 边界value 与 BOUNDARY_PANELS 对齐)
const [workMode, setWorkMode] = useState(BOOT_STATE.workMode);
const [permMode, setPermMode] = useState(BOOT_STATE.permMode);
const [execEnv, setExecEnv] = useState(BOOT_STATE.execEnv);
const [network, setNetwork] = useState(BOOT_STATE.network);
// 路径授权pathGroup = 当前显示的分组;←→ 切换显示组,不做行内权限切换)
const [pathAuths, setPathAuths] = useState<PathAuth[]>(INITIAL_PATH_AUTHS.map((p) => ({ ...p })));
const [pathGroup, setPathGroup] = useState<PathAccess>('rw');
@ -94,17 +123,22 @@ export function useSlashMenu({
const [sessions, setSessions] = useState<SessionMock[]>(SESSIONS.map((s) => ({ ...s })));
const [pendingApproval, setPendingApproval] = useState<PendingApprovalMock | null>(null);
const [activeWorkflow, setActiveWorkflow] = useState<string | null>(null);
// 上下文统计(/context 面板 + 状态栏token_update 事件与打开时查询双源刷新)
const [contextStats, setContextStats] = useState<ContextStats>({ ...EMPTY_CONTEXT_STATS });
// 工作流库与检查点(/workflow /rewind打开时经 API 刷新)
const [workflows, setWorkflows] = useState<WorkflowMock[]>([]);
const [checkpoints, setCheckpoints] = useState<CheckpointMock[]>([]);
// useKeyboard 回调只注册一次,全部通过 ref 读最新状态,避免过期闭包
const stateRef = useRef({
menuStack, query, sel, model, pendingModel, modelStep, thinking, effort, pendingEffort,
workMode, permMode, execEnv, network, pathAuths, pathGroup, agents, tasks,
sessions, pendingApproval, activeWorkflow,
sessions, pendingApproval, activeWorkflow, contextStats, workflows, checkpoints,
});
stateRef.current = {
menuStack, query, sel, model, pendingModel, modelStep, thinking, effort, pendingEffort,
workMode, permMode, execEnv, network, pathAuths, pathGroup, agents, tasks,
sessions, pendingApproval, activeWorkflow,
sessions, pendingApproval, activeWorkflow, contextStats, workflows, checkpoints,
};
const currentSessionTitle = sessions.find((s) => s.current)?.title ?? '';
@ -129,6 +163,9 @@ export function useSlashMenu({
pathAuths,
pendingApproval,
activeWorkflow,
contextStats,
workflows,
checkpoints,
currentSessionTitle,
}
: null;
@ -158,6 +195,9 @@ export function useSlashMenu({
pathAuths: stateRef.current.pathAuths,
pendingApproval: stateRef.current.pendingApproval,
activeWorkflow: stateRef.current.activeWorkflow,
contextStats: stateRef.current.contextStats,
workflows: stateRef.current.workflows,
checkpoints: stateRef.current.checkpoints,
currentSessionTitle: stateRef.current.sessions.find((s) => s.current)?.title ?? '',
} satisfies SlashMenuProps);
@ -194,7 +234,8 @@ export function useSlashMenu({
sys('/compact 压缩上下文CLI 尚未接入)');
break;
case 'new':
sys('已开始新对话CLI 尚未接入)');
// 对齐 web /new 页面:进入空对话草稿(不立即建会话),首条消息惰性创建
onNewSession?.();
break;
default:
sys(`/${cmd.name} ${cmd.desc}CLI 尚未接入)`);
@ -219,8 +260,23 @@ export function useSlashMenu({
case 'session':
setSel(Math.max(0, st.sessions.findIndex((s) => s.current)));
break;
case 'context':
// 打开时触发一次统计查询(结果经 setContextStats 回流;事件流为运行期另一源)
onContextRefresh?.();
break;
case 'agents':
onAgentsRefresh?.();
break;
case 'tasks':
onTasksRefresh?.();
break;
case 'workflow':
setSel(Math.max(0, WORKFLOWS.findIndex((w) => w.name === st.activeWorkflow)));
onWorkflowsRefresh?.();
setSel(Math.max(0, st.workflows.findIndex((w) => w.name === st.activeWorkflow)));
break;
case 'rewind':
onCheckpointsRefresh?.();
setSel(0);
break;
case 'approvals':
// 单条待审批:光标落在第一个操作「运行」上
@ -262,8 +318,8 @@ export function useSlashMenu({
if (top === 'session') {
const s = st.sessions[st.sel];
if (s && !s.current) {
setSessions((prev) => prev.map((x) => ({ ...x, current: x.title === s.title })));
sys(`已切换到对话「${s.title}CLI 尚未接入)`);
setSessions((prev) => prev.map((x) => ({ ...x, current: x.id === s.id })));
onSessionLoad?.(s);
}
closeMenu({ clearText: true });
return;
@ -332,7 +388,7 @@ export function useSlashMenu({
}
if (top === 'workflow') {
const w = WORKFLOWS[st.sel];
const w = st.workflows[st.sel];
if (!w) return;
if (st.activeWorkflow === w.name) {
setActiveWorkflow(null);
@ -346,7 +402,7 @@ export function useSlashMenu({
}
if (top === 'rewind') {
const c = CHECKPOINTS[st.sel];
const c = st.checkpoints[st.sel];
if (c) sys(`已回溯到检查点「${c.summary}」(${c.when}CLI 尚未接入)`);
closeMenu({ clearText: true });
return;
@ -376,6 +432,8 @@ export function useSlashMenu({
setThinking(toThinking);
setEffort(st.pendingEffort);
if (before !== after) sys(`已切换:${before}${after}`);
// 同步保存为个性化默认(影响新对话;当前会话经 createTask 覆盖参数即时生效)
onSaveModelDefaults?.({ model: st.pendingModel, thinking: toThinking, effort: st.pendingEffort });
closeMenu({ clearText: true });
return;
}
@ -402,10 +460,12 @@ export function useSlashMenu({
const text = (textareaRef.current?.plainText ?? '').trim();
if (text) {
const access = st.pathGroup;
setPathAuths((prev) => [...prev, { path: text, access }]);
const next = [...st.pathAuths, { path: text, access }];
setPathAuths(next);
setInputText('');
setSel(st.pathAuths.filter((p) => p.access === access).length); // 选中新添加的行
sys(`已添加路径授权:${text}${PATH_ACCESS_LABEL[access]}`);
onSavePathAuths?.(next);
}
return;
}
@ -530,8 +590,10 @@ export function useSlashMenu({
key.preventDefault();
// sel 是组内下标,换算成全量数组下标再删
const target = groupRows[st.sel];
setPathAuths((prev) => prev.filter((p) => p !== target));
const next = st.pathAuths.filter((p) => p !== target);
setPathAuths(next);
setSel((s) => Math.max(0, Math.min(s, groupRows.length - 2)));
onSavePathAuths?.(next);
return true;
}
return false;
@ -609,7 +671,7 @@ export function useSlashMenu({
workMode: BOUNDARY_PANELS.mode.options.find((o) => o.value === workMode)?.label ?? workMode,
permMode: BOUNDARY_PANELS.permission.options.find((o) => o.value === permMode)?.label ?? permMode,
execEnv: BOUNDARY_PANELS.env.options.find((o) => o.value === execEnv)?.label ?? execEnv,
contextUsage: '—',
contextUsage: formatContextUsage(contextStats, model),
};
return {
@ -621,5 +683,11 @@ export function useSlashMenu({
closeMenu,
openApproval,
resetMenu,
setContextStats,
setAgents,
setTasks,
setWorkflows,
setCheckpoints,
setSessions,
};
}

View File

@ -1,27 +1,41 @@
// /context 面板:交互区只读展示上下文用量统计
// 数据ContextStats 数字原始值token_update 事件 + 打开时查询双源刷新);
// 百分比算法对齐 web InputComposer开自动深度压缩用触发阈值否则模型上下文窗口
import { RGBA, TextAttributes } from '@opentui/core';
import { T } from '../components';
import { PanelFrame } from './frame';
import { CONTEXT_STATS } from '../data';
import { contextUsageLimitFor, formatCompactTokens, type ContextStats } from '../data';
import { t } from '../i18n';
const FG = RGBA.defaultForeground();
const DIM = TextAttributes.DIM;
export function ContextPanel({ width }: { width: number }) {
// 标签列对齐:最长 5 个汉字,短的补全角空格
export function ContextPanel({ width, stats, model }: { width: number; stats: ContextStats; model: string }) {
const limit = contextUsageLimitFor(model);
const pct =
limit > 0 ? Math.max(0, Math.min(100, Math.round((stats.currentTokens / limit) * 100))) : 0;
const hitRate =
stats.totalInput > 0 ? `${Math.round((stats.cacheInput / stats.totalInput) * 100)}%` : '—';
const currentText =
limit > 0
? `${formatCompactTokens(stats.currentTokens)} / ${formatCompactTokens(limit)}${pct}%`
: `${formatCompactTokens(stats.currentTokens)} / —`;
const rows: Array<[string, string]> = [
['当前上下文', `${CONTEXT_STATS.used} / ${CONTEXT_STATS.total}${CONTEXT_STATS.percent}%`],
['累计输入 ', CONTEXT_STATS.totalInput],
['累计输出 ', CONTEXT_STATS.totalOutput],
['缓存输入 ', CONTEXT_STATS.cacheInput],
['缓存命中率', CONTEXT_STATS.cacheHitRate],
[t('context.current'), currentText],
[t('context.totalInput'), formatCompactTokens(stats.totalInput)],
[t('context.totalOutput'), formatCompactTokens(stats.totalOutput)],
[t('context.cacheInput'), formatCompactTokens(stats.cacheInput)],
[t('context.cacheHitRate'), hitRate],
];
return (
<PanelFrame title="上下文用量 Esc 关闭" width={width}>
<PanelFrame title={t('context.title')} width={width}>
{rows.map(([label, value]) => (
<box key={label} flexDirection="row">
<T fg={FG}>{` ${label} `}</T>
<T fg={FG} attributes={DIM}>{value}</T>
<T fg={FG} attributes={DIM}>
{value}
</T>
</box>
))}
</PanelFrame>

View File

@ -2,7 +2,7 @@
// 协议依据 docs/runtime_protocol.mdrun.start(POST /api/tasks) → run.events(GET ?from=offset)
// 事件窗口缺口offset < window_start按 §5.2 提示并对齐续读CLI 不做快照对账)。
import type { GatewayClient, TaskPollResult } from './gateway';
import type { PendingApprovalMock } from './data';
import type { ContextStats, PendingApprovalMock } from './data';
import { CPS_INSTANT, type TimelineApi } from './timeline';
const POLL_INTERVAL_MS = 500;
@ -25,6 +25,10 @@ export interface RuntimeCallbacks {
onApprovalRequired(approval: PendingApprovalMock): void;
/** 系统消息(审批回执/错误/窗口缺口提示等),经 menuState 的 sys 通道上屏 */
onSystemMessage(text: string): void;
/** token 统计更新token_update 事件流):刷新状态栏与 /context 面板 */
onTokenUpdate?(stats: ContextStats): void;
/** 首条消息触发服务端惰性建会话后回调(/new 草稿 → 真实会话,列表插入用) */
onConversationCreated?(id: string): void;
/** 审批操作翻译函数i18n t() */
tr(key: string): string;
}
@ -46,18 +50,37 @@ export class ChatRuntime {
this.destroyed = true;
}
/** 发送一条用户消息并启动事件轮询(空闲时调用;运行中走排队由上层管理) */
async send(text: string): Promise<void> {
/** /new web /new 稿
* 线 conversationId send cid */
enterNewSession(): void {
this.conversationId = '';
this.taskId = '';
this.offset = 0;
this.cb.api.reset();
}
/**
* opts //model / */
async send(text: string, opts?: { model_key?: string; run_mode?: string; thinking_mode?: boolean }): Promise<void> {
this.cb.api.addUser(text);
const wasDraft = !this.conversationId;
let accepted: { task_id: string; conversation_id?: string };
try {
accepted = await this.gw.createTask({ message: text, conversation_id: this.conversationId || undefined });
accepted = await this.gw.createTask({
message: text,
conversation_id: this.conversationId || undefined,
model_key: opts?.model_key,
run_mode: opts?.run_mode,
thinking_mode: opts?.thinking_mode,
});
} catch (err) {
this.cb.onSystemMessage(`${this.cb.tr('runtime.sendFailed')}${err instanceof Error ? err.message : String(err)}`);
return;
}
this.taskId = accepted.task_id;
if (accepted.conversation_id) this.conversationId = accepted.conversation_id;
// /new 草稿的首条消息:服务端已建会话,通知上层把新会话插入列表
if (wasDraft && accepted.conversation_id) this.cb.onConversationCreated?.(accepted.conversation_id);
this.offset = 0;
this.setRunning(true);
void this.pollLoop();
@ -72,6 +95,50 @@ export class ChatRuntime {
}
}
/** /session Enter线 user/assistant
* / */
async loadSession(conversationId: string): Promise<void> {
this.conversationId = conversationId;
this.taskId = '';
this.offset = 0;
this.cb.api.reset();
try {
const res = await this.gw.getSessionHistory(conversationId);
const messages = Array.isArray(res?.messages) ? res.messages : Array.isArray(res) ? res : [];
for (const m of messages) {
const role = m?.role;
const text = extractText(m?.content);
if (!text) continue;
if (role === 'user') {
this.cb.api.addUser(text);
} else if (role === 'assistant') {
this.cb.api.startAssistant();
this.cb.api.appendAssistant(text);
}
}
this.cb.onSystemMessage(`${this.cb.tr('session.loaded')}${conversationId}`);
} catch (err) {
this.cb.onSystemMessage(`${this.cb.tr('session.loadFailed')}${err instanceof Error ? err.message : String(err)}`);
}
}
/** 闲时拉一次会话 token 统计(/context 面板打开时调用;失败静默,面板保留上次值) */
async refreshTokenStats(): Promise<void> {
if (!this.conversationId) return;
try {
const stats = await this.gw.getTokenStats(this.conversationId);
this.cb.onTokenUpdate?.({
currentTokens: Number(stats.current_context_tokens ?? 0),
totalInput: Number(stats.total_input_tokens ?? 0),
totalOutput: Number(stats.total_output_tokens ?? 0),
cacheInput: Number(stats.total_cached_input_tokens ?? 0),
cacheExemptInput: Number(stats.cache_exempt_input_tokens ?? 0),
});
} catch {
// 静默:新对话无统计属正常
}
}
private setRunning(v: boolean): void {
if (this.running === v) return;
this.running = v;
@ -165,6 +232,16 @@ export class ChatRuntime {
case 'task_stopped':
api.addSystem(this.cb.tr('runtime.stopped'));
break;
case 'token_update':
// 运行期 token 统计推送(任务期间 context_manager 回调切入事件流)
this.cb.onTokenUpdate?.({
currentTokens: Number(data.current_context_tokens ?? 0),
totalInput: Number(data.cumulative_input_tokens ?? 0),
totalOutput: Number(data.cumulative_output_tokens ?? 0),
cacheInput: Number(data.cumulative_cached_input_tokens ?? 0),
cacheExemptInput: Number(data.cache_exempt_input_tokens ?? 0),
});
break;
case 'error':
api.addSystem(`${this.cb.tr('runtime.error')}${String(data.message ?? data.error ?? '')}`);
break;
@ -186,6 +263,18 @@ function isTerminalStatus(status: string): boolean {
return ['succeeded', 'failed', 'stopped', 'canceled', 'completed', 'done', 'error'].includes((status || '').toLowerCase());
}
/** 历史消息 content → 纯文本string 直返;多段数组拼接 text 段;其余空) */
function extractText(content: any): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((seg) => (typeof seg === 'string' ? seg : typeof seg?.text === 'string' ? seg.text : ''))
.filter(Boolean)
.join('\n');
}
return '';
}
/** 工具参数逐行展示(审批/工具块共用):每个参数一行,多行值展开原样 */
function toolParamLines(toolName: string, args: any): string[] {
if (!args || typeof args !== 'object') return [];

View File

@ -25,6 +25,8 @@ export type Block =
| { kind: 'assistant'; id: number; full: string; revealStart: number; cps: number };
export interface TimelineApi {
/** 清空全部块(/session 切换对话时用) */
reset(): void;
addUser(text: string): void;
addGuide(text: string): void;
addSystem(text: string): void;

View File

@ -45,6 +45,7 @@ from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from server.gateway_auth import api_login_or_host_token_required
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker, get_user_resources
from server.security import rate_limited
from server.utils_common import debug_log
@ -489,7 +490,7 @@ def update_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username:
})
@chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
@ -506,7 +507,7 @@ def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, user
})
@chat_bp.route('/api/path-authorization', methods=['POST'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
@rate_limited("path_authorization_update", 20, 60, scope="user")
def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):

View File

@ -38,6 +38,7 @@ from core.tool_loading import build_registry_payload
from config.model_profiles import get_model_context_window
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from server.gateway_auth import api_login_or_host_token_required
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
from server.security import rate_limited
from server.utils_common import debug_log
@ -268,7 +269,7 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
return jsonify({"success": False, "error": str(exc), "message": str(exc)}), code
@chat_bp.route('/api/personalization', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取个性化配置"""
@ -317,7 +318,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
return jsonify({"success": False, "error": str(exc)}), 500
@chat_bp.route('/api/personalization', methods=['POST'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
@rate_limited("personalization_update", 20, 300, scope="user")
def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):

View File

@ -71,6 +71,7 @@ from utils.conversation_manager import ConversationManager
from utils.api_client import APIClient
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from .gateway_auth import api_login_or_host_token_required
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker
from .utils_common import (
build_review_lines,
@ -1307,7 +1308,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try:
@ -1820,7 +1821,7 @@ def cancel_conversation_compression(conversation_id, terminal: WebTerminal, work
@conversation_bp.route('/api/sub_agents', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""返回当前对话的子智能体任务列表。"""
@ -1833,7 +1834,8 @@ def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: s
manager._load_state()
except Exception:
pass
conversation_id = terminal.context_manager.current_conversation_id
# CLI 等 Bearer 客户端经 query 显式指定对话(其装配的 terminal 无当前对话概念)
conversation_id = (request.args.get("conversation_id") or "").strip() or terminal.context_manager.current_conversation_id
data = manager.get_overview(conversation_id=conversation_id)
# 传统模式子智能体列表必须排除多智能体任务,避免 /new 等无当前对话场景
@ -2067,7 +2069,7 @@ def terminate_sub_agent(task_id: str, terminal: WebTerminal, workspace: UserWork
@conversation_bp.route('/api/background_commands', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""返回当前对话的后台 run_command 列表。"""
@ -2075,7 +2077,8 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us
if not manager:
return jsonify({"success": True, "data": []})
try:
conversation_id = terminal.context_manager.current_conversation_id
# CLI 等 Bearer 客户端经 query 显式指定对话(其装配的 terminal 无当前对话概念)
conversation_id = (request.args.get("conversation_id") or "").strip() or terminal.context_manager.current_conversation_id
limit_raw = request.args.get("limit", "200")
try:
limit_num = max(1, min(int(limit_raw), 1000))

View File

@ -108,4 +108,24 @@ def get_runtime_session_history(conversation_id: str):
return jsonify({"success": True, "conversation": result})
@gateway_bp.route("/api/runtime/sessions/<conversation_id>/token-stats", methods=["GET"])
@api_login_or_host_token_required
def get_runtime_session_token_stats(conversation_id: str):
"""session.token_stats会话 token 统计(累计输入/输出/缓存/当前上下文)。"""
workspace_id = _resolve_workspace_id(request.args.get("workspace_id", ""))
username = str(session.get("username") or "")
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
try:
stats = runtime_service.get_session_token_stats(
username, workspace_id, conversation_id, principal
)
return jsonify({"success": True, "stats": stats})
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
__all__ = ["gateway_bp"]

View File

@ -27,7 +27,7 @@ import secrets
from datetime import timedelta
from pathlib import Path
from flask import Flask
from flask import Flask, jsonify, request
from config import (
DEFAULT_PROJECT_PATH,
@ -151,10 +151,48 @@ def create_headless_app() -> Flask:
for _name, bp in HEADLESS_BLUEPRINTS:
app.register_blueprint(bp)
# host 全局设置面personalization / path-authorization视图函数定义在 chat_bp 模块内,
# 装饰器已双通道化web session 或 host Bearer 均可);此处直接注册到 headless app
# URL 与 full 形态一致Blueprint.route 只登记不包装,函数对象可安全复用)。
from server.chat.settings import (
get_personalization_settings,
update_personalization_settings,
)
from server.chat.permission import (
get_path_authorization,
update_path_authorization,
)
from server.conversation import list_background_commands, list_sub_agents, list_conversation_versioning_checkpoints
from server.workflow_page import api_list_workflows
app.add_url_rule('/api/personalization', view_func=get_personalization_settings, methods=['GET'])
app.add_url_rule('/api/personalization', view_func=update_personalization_settings, methods=['POST'])
app.add_url_rule('/api/path-authorization', view_func=get_path_authorization, methods=['GET'])
app.add_url_rule('/api/path-authorization', view_func=update_path_authorization, methods=['POST'])
# 子智能体/后台指令列表(/agents /tasks 面板数据源conversation_id 走 query 显式指定)
app.add_url_rule('/api/sub_agents', view_func=list_sub_agents, methods=['GET'])
app.add_url_rule('/api/background_commands', view_func=list_background_commands, methods=['GET'])
# 工作流库列表(/workflow与版本回溯检查点/rewind
app.add_url_rule('/api/workflows', view_func=api_list_workflows, methods=['GET'])
app.add_url_rule(
'/api/conversations/<conversation_id>/versioning/checkpoints',
view_func=list_conversation_versioning_checkpoints,
methods=['GET'],
)
@app.route('/')
def headless_landing():
return _HEADLESS_LANDING_HTML
@app.errorhandler(404)
def headless_not_found(_err):
# 非 API 的 GET 路径(/new、/<conv_id> 等前端路由)统一回落告知页——
# 对齐 full 形态的 SPA fallback 行为,避免浏览器打开收藏链接看到裸 404
# API 路径保持 JSON 404。
if request.method == 'GET' and not request.path.startswith('/api/'):
return _HEADLESS_LANDING_HTML
return jsonify({"success": False, "error": "not found"}), 404
return app

View File

@ -240,6 +240,27 @@ class RuntimeService:
manager = cm._get_conversation_manager_for_id(conversation_id)
return manager.load_conversation(conversation_id)
def get_session_token_stats(
self,
username: str,
workspace_id: str,
conversation_id: str,
principal: Optional[TrustedPrincipal] = None,
) -> Dict[str, Any]:
"""会话 token 统计查询session.token_stats
字段由 context_manager 定义total_input_tokens/total_output_tokens/
total_cached_input_tokens/cache_exempt_input_tokens/current_context_tokens
本服务只做资源装配与转发对话不存在或无统计时返回空 dict
"""
if not str(conversation_id or "").strip():
raise ValueError("runtime_context: conversation_id 不能为空")
terminal, _workspace = self._resources_for_query(username, workspace_id, principal)
cm = getattr(terminal, "context_manager", None)
if cm is None:
raise RuntimeError(tr("tasks.system_not_initialized"))
return cm.get_conversation_token_statistics(conversation_id) or {}
def create_session(
self,
username: str,

View File

@ -15,6 +15,7 @@ from modules.workflow_manager import (
save_workflow,
)
from server.auth_helpers import api_login_required, login_required
from server.gateway_auth import api_login_or_host_token_required
from server.context import with_terminal
from modules.i18n import tr
@ -39,7 +40,7 @@ def workflow_editor_page(name: str):
@workflow_page_bp.route("/api/workflows", methods=["GET"])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def api_list_workflows(terminal, workspace, username):
"""工作流列表(内置 + 用户库双源合并,仅元信息)。"""