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:
parent
66fa90d6cc
commit
5459d82637
@ -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_id(Bearer 装配的 terminal 无当前对话概念)
|
||||
- 与 full 形态同进程模型(RuntimeService 单例/同一数据目录/同一端口 8091),只是启动时挂的路由面不同;**绝不能与 full 形态双进程并存**(MultiAgentState/门闸/审批 Map 是进程内单例)
|
||||
- CLI(`cli/src/gateway.ts`)探测无服务时 spawn 的就是这个入口;python 解释器经依赖探测(import yaml/flask)从 .venv → homebrew 3.12/3.11 → python3 中选第一个可用的
|
||||
|
||||
|
||||
153
cli/src/App.tsx
153
cli/src/App.tsx
@ -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→done;failed/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_at(epoch 秒或 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=existing);effort '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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
// 新会话置顶并标 current(menuState 初值读 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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ?? [];
|
||||
// 顶层展开风格的实际键是 conversations(8093 实测),兼容 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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -30,7 +30,7 @@ function resolveDeployConfig(name: string): any | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 模型清单(custom_models.json;visible=false 的条目不上架) */
|
||||
/** 模型清单(custom_models.json;visible=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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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 才提交到 effort;Esc 放弃不影响已生效值)
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// 协议依据 docs/runtime_protocol.md:run.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 [];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
"""工作流列表(内置 + 用户库双源合并,仅元信息)。"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user