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 web_server.py`
- 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程) - 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程)
- **Headless 启动CLI 自启动专用)**`python -m server.headless_app --path <cwd> --port 8091 --thinking-mode` - **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 是进程内单例) - 与 full 形态同进程模型RuntimeService 单例/同一数据目录/同一端口 8091只是启动时挂的路由面不同**绝不能与 full 形态双进程并存**MultiAgentState/门闸/审批 Map 是进程内单例)
- CLI`cli/src/gateway.ts`)探测无服务时 spawn 的就是这个入口python 解释器经依赖探测import yaml/flask从 .venv → homebrew 3.12/3.11 → python3 中选第一个可用的 - 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 = { const api: TimelineApi = {
reset() {
thinkingId = null;
assistantId = null;
setBlocks([]);
},
addUser(text) { addUser(text) {
setBlocks((prev) => [...prev, { kind: 'user', id: idCounter++, 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; 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 }) { export function App({ boot }: { boot: BootResult }) {
const renderer = useRenderer(); const renderer = useRenderer();
const [blocks, setBlocks] = useState<Block[]>([]); 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); const menuRef = useRef(menu);
menuRef.current = menu; menuRef.current = menu;
@ -159,6 +290,14 @@ export function App({ boot }: { boot: BootResult }) {
onRunningChange: setRunning, onRunningChange: setRunning,
onApprovalRequired: (a) => menuRef.current.openApproval(a), onApprovalRequired: (a) => menuRef.current.openApproval(a),
onSystemMessage: (text) => apiRef.current?.addSystem(text), 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, tr: t,
}); });
rt.conversationId = boot.conversationId; rt.conversationId = boot.conversationId;
@ -200,12 +339,22 @@ export function App({ boot }: { boot: BootResult }) {
if (running || queue.length === 0) return; if (running || queue.length === 0) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const [head, ...rest] = queue; const [head, ...rest] = queue;
if (head) void runtimeRef.current?.send(head); if (head) sendWithSettings(head);
setQueue(rest); setQueue(rest);
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [running, queue]); }, [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当前仅本地上屏服务端注入后续接 // TODO(gateway):接 POST /api/tasks/<id>/runtime_guidance当前仅本地上屏服务端注入后续接
const handleGuide = () => { const handleGuide = () => {
@ -249,7 +398,7 @@ export function App({ boot }: { boot: BootResult }) {
if (runningRef.current) { if (runningRef.current) {
setQueue((q) => [...q, text]); setQueue((q) => [...q, text]);
} else { } 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 { RGBA, TextAttributes } from '@opentui/core';
import { GatewayClient, type WorkspaceItem } from './gateway'; import { GatewayClient, type WorkspaceItem } from './gateway';
import { t } from './i18n'; 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 FG = RGBA.defaultForeground();
const DIM = TextAttributes.DIM; const DIM = TextAttributes.DIM;
@ -45,7 +83,11 @@ export function BootFlow({ cwd, onReady, onExit }: { cwd: string; onReady: (r: B
WORKSPACE.path = workspace.path; WORKSPACE.path = workspace.path;
setStage('creating'); setStage('creating');
try { try {
await loadBootData(gw);
const session = await gw.createSession(); 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 }); onReady({ gateway: gw, conversationId: session.conversation_id, workspace });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));

View File

@ -26,6 +26,9 @@ export const BOOT_STATE = {
execEnv: 'direct', execEnv: 'direct',
network: 'restricted', network: 'restricted',
contextUsage: '—', contextUsage: '—',
/** 自动深度压缩开关与触发阈值(上下文百分比分母,对齐 web InputComposer 算法) */
autoDeepCompress: false,
deepCompressLimit: 150000,
}; };
// ── 工作区(/session 面板顶部展示boot 确定后写入) ── // ── 工作区(/session 面板顶部展示boot 确定后写入) ──
@ -36,10 +39,12 @@ export interface WorkspaceInfo {
} }
export const WORKSPACE: WorkspaceInfo = { id: '', name: '', path: '' }; export const WORKSPACE: WorkspaceInfo = { id: '', name: '', path: '' };
// ── 模型boot 从 /api/v1/models 加载后填充) ── // ── 模型boot 从本地 custom_models.json 加载后填充) ──
export interface ModelOption { export interface ModelOption {
name: string; name: string;
meta: string; meta: string;
/** 上下文窗口上限token配置缺省为 null百分比显示回退压缩阈值 */
contextWindow: number | null;
} }
export const MODEL_OPTIONS: ModelOption[] = []; 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 PATH_ACCESS_LABEL: Record<PathAccess, string> = { rw: t('path.access.rw'), ro: t('path.access.ro') };
export const INITIAL_PATH_AUTHS: PathAuth[] = []; export const INITIAL_PATH_AUTHS: PathAuth[] = [];
// ── 上下文统计(/context 面板;后续接会话 token 统计端点 ── // ── 上下文统计(/context 面板与状态栏共用token_update 事件/查询端点刷新,数字型原始值 ──
export interface ContextStats { export interface ContextStats {
used: string; currentTokens: number;
total: string; totalInput: number;
percent: number; totalOutput: number;
totalInput: string; cacheInput: number;
totalOutput: string; cacheExemptInput: number;
cacheInput: string;
cacheHitRate: string;
} }
export const CONTEXT_STATS: ContextStats = { export const EMPTY_CONTEXT_STATS: ContextStats = {
used: '—', currentTokens: 0,
total: '—', totalInput: 0,
percent: 0, totalOutput: 0,
totalInput: '—', cacheInput: 0,
totalOutput: '—', cacheExemptInput: 0,
cacheInput: '—',
cacheHitRate: '—',
}; };
/** 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 ── // ── 边界四选项(/mode /permission /env /network 共用 Selector 面板;切换后调对应 API ──
export interface BoundaryOption { export interface BoundaryOption {
value: string; value: string;

View File

@ -190,7 +190,8 @@ export class GatewayClient {
async listSessions(limit = 20): Promise<any[]> { async listSessions(limit = 20): Promise<any[]> {
const res = await this.request<any>(`/api/runtime/sessions?limit=${limit}`, { method: 'GET' }); 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> { async getSessionHistory(conversationId: string): Promise<any> {
@ -200,8 +201,72 @@ export class GatewayClient {
return res.data ?? res.conversation ?? res; 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任务 ── // ── 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 }); const res = await this.request<{ data: any }>('/api/tasks', { method: 'POST', body: payload });
return res.data; return res.data;
} }

View File

@ -95,4 +95,26 @@ export default {
'tool.write_file': 'Write file', 'tool.write_file': 'Write file',
'tool.edit_file': 'Edit file', 'tool.edit_file': 'Edit file',
'tool.read_file': 'Read 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; } as const;

View File

@ -95,4 +95,26 @@ export default {
'tool.write_file': '写入文件', 'tool.write_file': '写入文件',
'tool.edit_file': '编辑文件', 'tool.edit_file': '编辑文件',
'tool.read_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; } as const;

View File

@ -30,7 +30,7 @@ function resolveDeployConfig(name: string): any | null {
return null; return null;
} }
/** 模型清单custom_models.jsonvisible=false 的条目不上架 */ /** 模型清单custom_models.jsonvisible=false 的条目不上架context_window 透传供上下文百分比 */
export function loadCustomModels(): ModelOption[] { export function loadCustomModels(): ModelOption[] {
const cfg = resolveDeployConfig('custom_models.json'); const cfg = resolveDeployConfig('custom_models.json');
const items = Array.isArray(cfg?.models) ? cfg.models : []; const items = Array.isArray(cfg?.models) ? cfg.models : [];
@ -39,6 +39,7 @@ export function loadCustomModels(): ModelOption[] {
.map((m: any) => ({ .map((m: any) => ({
name: String(m.model_name), name: String(m.model_name),
meta: String(m.model_description || m.description || ''), 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; effort: EffortLevel;
workMode: string; workMode: string;
permMode: string; permMode: string;
/** 自动深度压缩开关与触发阈值(上下文百分比分母,对齐 web 端算法) */
autoDeepCompress: boolean;
deepCompressLimit: number;
} }
const EFFORT_VALUES: EffortLevel[] = ['default', 'low', 'medium', 'high', 'xhigh', 'max']; const EFFORT_VALUES: EffortLevel[] = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
/** 个性化默认personalization.json默认模型/运行模式/推理强度/工作模式/权限模式 */ /** 个性化默认personalization.json默认模型/运行模式/推理强度/工作模式/权限模式/压缩设置 */
export function loadPersonalizationDefaults(): BootPreferences { export function loadPersonalizationDefaults(): BootPreferences {
const p = readJson(resolve(HOST_DATA_DIR, 'personalization.json')) ?? {}; const p = readJson(resolve(HOST_DATA_DIR, 'personalization.json')) ?? {};
const effortRaw = String(p.default_reasoning_effort ?? 'default'); const effortRaw = String(p.default_reasoning_effort ?? 'default');
const limitRaw = Number(p.deep_compress_trigger_tokens);
return { return {
model: String(p.default_model ?? ''), model: String(p.default_model ?? ''),
runMode: String(p.default_run_mode ?? 'fast'), 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', effort: (EFFORT_VALUES as string[]).includes(effortRaw) ? (effortRaw as EffortLevel) : 'default',
workMode: String(p.default_work_mode ?? 'ask'), workMode: String(p.default_work_mode ?? 'ask'),
permMode: String(p.default_permission_mode ?? 'unrestricted'), 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 { import {
BOUNDARY_PANELS, BOUNDARY_PANELS,
MODEL_OPTIONS, MODEL_OPTIONS,
WORKFLOWS,
CHECKPOINTS,
type AgentMock, type AgentMock,
type CheckpointMock,
type ContextStats,
type EffortLevel, type EffortLevel,
type PathAccess, type PathAccess,
type PathAuth, type PathAuth,
type PendingApprovalMock, type PendingApprovalMock,
type SessionMock, type SessionMock,
type TaskMock, type TaskMock,
type WorkflowMock,
} from './data'; } from './data';
const FG = RGBA.defaultForeground(); const FG = RGBA.defaultForeground();
@ -63,6 +64,12 @@ export interface SlashMenuProps {
pathAuths: PathAuth[]; pathAuths: PathAuth[];
pendingApproval: PendingApprovalMock | null; pendingApproval: PendingApprovalMock | null;
activeWorkflow: string | null; activeWorkflow: string | null;
/** 上下文统计(/context 面板token_update 事件与打开时查询双源刷新) */
contextStats: ContextStats;
/** 工作流库(/workflow打开时经 API 刷新) */
workflows: WorkflowMock[];
/** 版本回溯检查点(/rewind打开时经 API 刷新) */
checkpoints: CheckpointMock[];
/** 当前对话标题(/rename /delete /export 面板显示用) */ /** 当前对话标题(/rename /delete /export 面板显示用) */
currentSessionTitle: string; currentSessionTitle: string;
} }
@ -164,7 +171,7 @@ export function SlashMenu(props: SlashMenuProps) {
case 'path': case 'path':
return <PathsPanel sel={props.sel} width={props.width} pathAuths={props.pathAuths} group={props.pathGroup} />; return <PathsPanel sel={props.sel} width={props.width} pathAuths={props.pathAuths} group={props.pathGroup} />;
case 'context': case 'context':
return <ContextPanel width={props.width} />; return <ContextPanel width={props.width} stats={props.contextStats} model={props.model} />;
case 'rename': case 'rename':
return <InputPanel title="重命名对话" hint={`当前 ${props.currentSessionTitle}`} width={props.width} />; return <InputPanel title="重命名对话" hint={`当前 ${props.currentSessionTitle}`} width={props.width} />;
case 'export': case 'export':
@ -176,9 +183,9 @@ export function SlashMenu(props: SlashMenuProps) {
case 'approvals': case 'approvals':
return <ApprovalsPanel sel={props.sel} width={props.width} pending={props.pendingApproval} />; return <ApprovalsPanel sel={props.sel} width={props.width} pending={props.pendingApproval} />;
case 'workflow': 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': 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': case 'help':
return <HelpPanel sel={props.sel} width={props.width} />; return <HelpPanel sel={props.sel} width={props.width} />;
case 'agents': case 'agents':
@ -212,9 +219,9 @@ export function panelItemCount(props: SlashMenuProps): number {
case 'approvals': case 'approvals':
return 0; // 单条待审批,无 ↑↓ 列表;←→ 选择操作 return 0; // 单条待审批,无 ↑↓ 列表;←→ 选择操作
case 'workflow': case 'workflow':
return WORKFLOWS.length; return props.workflows.length;
case 'rewind': case 'rewind':
return CHECKPOINTS.length; return props.checkpoints.length;
case 'help': case 'help':
return helpMaxOffset() + 1; // sel 在此面板作为滚动偏移 return helpMaxOffset() + 1; // sel 在此面板作为滚动偏移
case 'context': case 'context':

View File

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

View File

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

View File

@ -2,7 +2,7 @@
// 协议依据 docs/runtime_protocol.mdrun.start(POST /api/tasks) → run.events(GET ?from=offset) // 协议依据 docs/runtime_protocol.mdrun.start(POST /api/tasks) → run.events(GET ?from=offset)
// 事件窗口缺口offset < window_start按 §5.2 提示并对齐续读CLI 不做快照对账)。 // 事件窗口缺口offset < window_start按 §5.2 提示并对齐续读CLI 不做快照对账)。
import type { GatewayClient, TaskPollResult } from './gateway'; import type { GatewayClient, TaskPollResult } from './gateway';
import type { PendingApprovalMock } from './data'; import type { ContextStats, PendingApprovalMock } from './data';
import { CPS_INSTANT, type TimelineApi } from './timeline'; import { CPS_INSTANT, type TimelineApi } from './timeline';
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
@ -25,6 +25,10 @@ export interface RuntimeCallbacks {
onApprovalRequired(approval: PendingApprovalMock): void; onApprovalRequired(approval: PendingApprovalMock): void;
/** 系统消息(审批回执/错误/窗口缺口提示等),经 menuState 的 sys 通道上屏 */ /** 系统消息(审批回执/错误/窗口缺口提示等),经 menuState 的 sys 通道上屏 */
onSystemMessage(text: string): void; onSystemMessage(text: string): void;
/** token 统计更新token_update 事件流):刷新状态栏与 /context 面板 */
onTokenUpdate?(stats: ContextStats): void;
/** 首条消息触发服务端惰性建会话后回调(/new 草稿 → 真实会话,列表插入用) */
onConversationCreated?(id: string): void;
/** 审批操作翻译函数i18n t() */ /** 审批操作翻译函数i18n t() */
tr(key: string): string; tr(key: string): string;
} }
@ -46,18 +50,37 @@ export class ChatRuntime {
this.destroyed = true; this.destroyed = true;
} }
/** 发送一条用户消息并启动事件轮询(空闲时调用;运行中走排队由上层管理) */ /** /new web /new 稿
async send(text: string): Promise<void> { * 线 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); this.cb.api.addUser(text);
const wasDraft = !this.conversationId;
let accepted: { task_id: string; conversation_id?: string }; let accepted: { task_id: string; conversation_id?: string };
try { 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) { } catch (err) {
this.cb.onSystemMessage(`${this.cb.tr('runtime.sendFailed')}${err instanceof Error ? err.message : String(err)}`); this.cb.onSystemMessage(`${this.cb.tr('runtime.sendFailed')}${err instanceof Error ? err.message : String(err)}`);
return; return;
} }
this.taskId = accepted.task_id; this.taskId = accepted.task_id;
if (accepted.conversation_id) this.conversationId = accepted.conversation_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.offset = 0;
this.setRunning(true); this.setRunning(true);
void this.pollLoop(); 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 { private setRunning(v: boolean): void {
if (this.running === v) return; if (this.running === v) return;
this.running = v; this.running = v;
@ -165,6 +232,16 @@ export class ChatRuntime {
case 'task_stopped': case 'task_stopped':
api.addSystem(this.cb.tr('runtime.stopped')); api.addSystem(this.cb.tr('runtime.stopped'));
break; 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': case 'error':
api.addSystem(`${this.cb.tr('runtime.error')}${String(data.message ?? data.error ?? '')}`); api.addSystem(`${this.cb.tr('runtime.error')}${String(data.message ?? data.error ?? '')}`);
break; break;
@ -186,6 +263,18 @@ function isTerminalStatus(status: string): boolean {
return ['succeeded', 'failed', 'stopped', 'canceled', 'completed', 'done', 'error'].includes((status || '').toLowerCase()); 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[] { function toolParamLines(toolName: string, args: any): string[] {
if (!args || typeof args !== 'object') return []; 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 }; | { kind: 'assistant'; id: number; full: string; revealStart: number; cps: number };
export interface TimelineApi { export interface TimelineApi {
/** 清空全部块(/session 切换对话时用) */
reset(): void;
addUser(text: string): void; addUser(text: string): void;
addGuide(text: string): void; addGuide(text: string): void;
addSystem(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 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.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.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.security import rate_limited
from server.utils_common import debug_log 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']) @chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str): def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)()) 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']) @chat_bp.route('/api/path-authorization', methods=['POST'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
@rate_limited("path_authorization_update", 20, 60, scope="user") @rate_limited("path_authorization_update", 20, 60, scope="user")
def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str): 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 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.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.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.security import rate_limited
from server.utils_common import debug_log 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 return jsonify({"success": False, "error": str(exc), "message": str(exc)}), code
@chat_bp.route('/api/personalization', methods=['GET']) @chat_bp.route('/api/personalization', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str): 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 return jsonify({"success": False, "error": str(exc)}), 500
@chat_bp.route('/api/personalization', methods=['POST']) @chat_bp.route('/api/personalization', methods=['POST'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
@rate_limited("personalization_update", 20, 300, scope="user") @rate_limited("personalization_update", 20, 300, scope="user")
def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str): 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 utils.api_client import APIClient
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username 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 .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 ( from .utils_common import (
build_review_lines, 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']) @conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
@ -1820,7 +1821,7 @@ def cancel_conversation_compression(conversation_id, terminal: WebTerminal, work
@conversation_bp.route('/api/sub_agents', methods=['GET']) @conversation_bp.route('/api/sub_agents', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str): 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() manager._load_state()
except Exception: except Exception:
pass 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) data = manager.get_overview(conversation_id=conversation_id)
# 传统模式子智能体列表必须排除多智能体任务,避免 /new 等无当前对话场景 # 传统模式子智能体列表必须排除多智能体任务,避免 /new 等无当前对话场景
@ -2067,7 +2069,7 @@ def terminate_sub_agent(task_id: str, terminal: WebTerminal, workspace: UserWork
@conversation_bp.route('/api/background_commands', methods=['GET']) @conversation_bp.route('/api/background_commands', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, username: str): def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""返回当前对话的后台 run_command 列表。""" """返回当前对话的后台 run_command 列表。"""
@ -2075,7 +2077,8 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us
if not manager: if not manager:
return jsonify({"success": True, "data": []}) return jsonify({"success": True, "data": []})
try: 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") limit_raw = request.args.get("limit", "200")
try: try:
limit_num = max(1, min(int(limit_raw), 1000)) 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}) 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"] __all__ = ["gateway_bp"]

View File

@ -27,7 +27,7 @@ import secrets
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from flask import Flask from flask import Flask, jsonify, request
from config import ( from config import (
DEFAULT_PROJECT_PATH, DEFAULT_PROJECT_PATH,
@ -151,10 +151,48 @@ def create_headless_app() -> Flask:
for _name, bp in HEADLESS_BLUEPRINTS: for _name, bp in HEADLESS_BLUEPRINTS:
app.register_blueprint(bp) 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('/') @app.route('/')
def headless_landing(): def headless_landing():
return _HEADLESS_LANDING_HTML 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 return app

View File

@ -240,6 +240,27 @@ class RuntimeService:
manager = cm._get_conversation_manager_for_id(conversation_id) manager = cm._get_conversation_manager_for_id(conversation_id)
return manager.load_conversation(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( def create_session(
self, self,
username: str, username: str,

View File

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