diff --git a/AGENTS.md b/AGENTS.md index cb215f28..42dd31b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,8 @@ - 兼容启动方式:`python web_server.py` - 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程) - **Headless 启动(CLI 自启动专用)**:`python -m server.headless_app --path --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//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 中选第一个可用的 diff --git a/cli/src/App.tsx b/cli/src/App.tsx index 3189e582..2dff3ab6 100644 --- a/cli/src/App.tsx +++ b/cli/src/App.tsx @@ -36,6 +36,11 @@ function makeApi(setBlocks: React.Dispatch>, 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 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([]); @@ -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//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); } }; diff --git a/cli/src/boot.tsx b/cli/src/boot.tsx index 1ded02c5..8b3b878c 100644 --- a/cli/src/boot.tsx +++ b/cli/src/boot.tsx @@ -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 { + 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)); diff --git a/cli/src/data.ts b/cli/src/data.ts index dd78be8e..6ec02f0b 100644 --- a/cli/src/data.ts +++ b/cli/src/data.ts @@ -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 = { 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; diff --git a/cli/src/gateway.ts b/cli/src/gateway.ts index 2b007078..a82ef40a 100644 --- a/cli/src/gateway.ts +++ b/cli/src/gateway.ts @@ -190,7 +190,8 @@ export class GatewayClient { async listSessions(limit = 20): Promise { const res = await this.request(`/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 { @@ -200,8 +201,72 @@ export class GatewayClient { return res.data ?? res.conversation ?? res; } + /** 会话 token 统计(/context 面板;顶层展开风格 res.stats) */ + async getTokenStats(conversationId: string): Promise { + const res = await this.request(`/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): Promise { + await this.request('/api/personalization', { method: 'POST', body: patch }); + } + + /** 路径授权全量保存(端点语义为两组全量提交;返回服务端落定后的两组列表) */ + async savePathAuths(writable: string[], readableExtra: string[]): Promise<{ writable: string[]; readableExtra: string[] }> { + const res = await this.request('/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 { + const q = conversationId ? `?conversation_id=${encodeURIComponent(conversationId)}` : ''; + const res = await this.request(`/api/sub_agents${q}`, { method: 'GET' }); + return res.data ?? []; + } + + /** 后台指令列表(/tasks 面板;conversation_id 经 query 显式指定) */ + async listBackgroundCommands(conversationId: string): Promise { + const q = conversationId ? `?conversation_id=${encodeURIComponent(conversationId)}` : ''; + const res = await this.request(`/api/background_commands${q}`, { method: 'GET' }); + return res.data ?? []; + } + + /** 工作流库列表(/workflow 面板;注意该端点顶层展开为 {workflows},无 success 包装) */ + async listWorkflows(): Promise { + const res = await this.request('/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( + `/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; } diff --git a/cli/src/i18n/en-US.ts b/cli/src/i18n/en-US.ts index 3a72f90f..a8dd7afc 100644 --- a/cli/src/i18n/en-US.ts +++ b/cli/src/i18n/en-US.ts @@ -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; diff --git a/cli/src/i18n/zh-CN.ts b/cli/src/i18n/zh-CN.ts index 1be22ad3..f29aabe1 100644 --- a/cli/src/i18n/zh-CN.ts +++ b/cli/src/i18n/zh-CN.ts @@ -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; diff --git a/cli/src/localconfig.ts b/cli/src/localconfig.ts index c8717d5c..275f9bfe 100644 --- a/cli/src/localconfig.ts +++ b/cli/src/localconfig.ts @@ -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, }; } diff --git a/cli/src/menu.tsx b/cli/src/menu.tsx index b03225fd..03a61c94 100644 --- a/cli/src/menu.tsx +++ b/cli/src/menu.tsx @@ -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 ; case 'context': - return ; + return ; case 'rename': return ; case 'export': @@ -176,9 +183,9 @@ export function SlashMenu(props: SlashMenuProps) { case 'approvals': return ; case 'workflow': - return ; + return ; case 'rewind': - return ; + return ; case 'help': return ; 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': diff --git a/cli/src/menuState.ts b/cli/src/menuState.ts index c07b1abd..21b81bc7 100644 --- a/cli/src/menuState.ts +++ b/cli/src/menuState.ts @@ -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; apiRef: React.RefObject; @@ -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(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('model'); - const [thinking, setThinking] = useState(true); - const [effort, setEffort] = useState('high'); + const [thinking, setThinking] = useState(BOOT_STATE.thinking); + const [effort, setEffort] = useState(BOOT_STATE.effort); // 面板内编辑中的强度(Enter 才提交到 effort;Esc 放弃不影响已生效值) - const [pendingEffort, setPendingEffort] = useState('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(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(INITIAL_PATH_AUTHS.map((p) => ({ ...p }))); const [pathGroup, setPathGroup] = useState('rw'); @@ -94,17 +123,22 @@ export function useSlashMenu({ const [sessions, setSessions] = useState(SESSIONS.map((s) => ({ ...s }))); const [pendingApproval, setPendingApproval] = useState(null); const [activeWorkflow, setActiveWorkflow] = useState(null); + // 上下文统计(/context 面板 + 状态栏;token_update 事件与打开时查询双源刷新) + const [contextStats, setContextStats] = useState({ ...EMPTY_CONTEXT_STATS }); + // 工作流库与检查点(/workflow /rewind;打开时经 API 刷新) + const [workflows, setWorkflows] = useState([]); + const [checkpoints, setCheckpoints] = useState([]); // 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, }; } diff --git a/cli/src/panels/context.tsx b/cli/src/panels/context.tsx index 0c4843f9..14993ef6 100644 --- a/cli/src/panels/context.tsx +++ b/cli/src/panels/context.tsx @@ -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 ( - + {rows.map(([label, value]) => ( {` ${label} `} - {value} + + {value} + ))} diff --git a/cli/src/runtime.ts b/cli/src/runtime.ts index 1b7f31b1..f66e1ac4 100644 --- a/cli/src/runtime.ts +++ b/cli/src/runtime.ts @@ -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 { + /** /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 { 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 { + 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 { + 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 []; diff --git a/cli/src/timeline.ts b/cli/src/timeline.ts index 53608300..35df1e79 100644 --- a/cli/src/timeline.ts +++ b/cli/src/timeline.ts @@ -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; diff --git a/server/chat/permission.py b/server/chat/permission.py index 50abf4e9..84e8c747 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -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): diff --git a/server/chat/settings.py b/server/chat/settings.py index 86289568..29e9a257 100644 --- a/server/chat/settings.py +++ b/server/chat/settings.py @@ -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): diff --git a/server/conversation.py b/server/conversation.py index 730a53b0..e0e81e89 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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//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)) diff --git a/server/gateway_api.py b/server/gateway_api.py index cee88bec..c4081ced 100644 --- a/server/gateway_api.py +++ b/server/gateway_api.py @@ -108,4 +108,24 @@ def get_runtime_session_history(conversation_id: str): return jsonify({"success": True, "conversation": result}) +@gateway_bp.route("/api/runtime/sessions//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"] diff --git a/server/headless_app.py b/server/headless_app.py index 5cf0d5d0..eed08866 100644 --- a/server/headless_app.py +++ b/server/headless_app.py @@ -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//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、/ 等前端路由)统一回落告知页—— + # 对齐 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 diff --git a/server/runtime/service.py b/server/runtime/service.py index 2d84d9e2..6327d593 100644 --- a/server/runtime/service.py +++ b/server/runtime/service.py @@ -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, diff --git a/server/workflow_page.py b/server/workflow_page.py index 36b9c18e..7209cd25 100644 --- a/server/workflow_page.py +++ b/server/workflow_page.py @@ -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): """工作流列表(内置 + 用户库双源合并,仅元信息)。"""