From 45f192a381b8e0911b2888c0c392e1c4e19e5664 Mon Sep 17 00:00:00 2001 From: huangzhenting Date: Fri, 20 Mar 2026 17:50:49 +0800 Subject: [PATCH] feat: add Vue config drawer for hot-reloadable settings Add /api/config GET+POST endpoints to read and write config.yaml fields that support hot-reload. Frontend: config button in AppHeader opens a 650px side drawer with grouped fields, SegSelect/Toggle components, YAML key names as labels, and descriptions sourced from config.yaml.example. thinking.enabled supports a 3-way selector (auto/off/on) where auto deletes the yaml key so the default kicks in. --- src/config-api.ts | 154 ++++++++++ src/index.ts | 3 + vue-ui/src/App.vue | 5 +- vue-ui/src/api.ts | 21 +- vue-ui/src/components/AppHeader.vue | 11 + vue-ui/src/components/ConfigDrawer.vue | 409 +++++++++++++++++++++++++ vue-ui/src/components/PayloadView.vue | 32 +- vue-ui/src/stores/config.ts | 43 +++ vue-ui/src/types.ts | 24 ++ 9 files changed, 694 insertions(+), 8 deletions(-) create mode 100644 src/config-api.ts create mode 100644 vue-ui/src/components/ConfigDrawer.vue create mode 100644 vue-ui/src/stores/config.ts diff --git a/src/config-api.ts b/src/config-api.ts new file mode 100644 index 0000000..081fbdc --- /dev/null +++ b/src/config-api.ts @@ -0,0 +1,154 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { Request, Response } from 'express'; +import { getConfig } from './config.js'; + +/** + * GET /api/config + * 返回当前可热重载的配置字段(snake_case,过滤 port/proxy/auth_tokens/fingerprint/vision) + */ +export function apiGetConfig(_req: Request, res: Response): void { + const cfg = getConfig(); + res.json({ + cursor_model: cfg.cursorModel, + timeout: cfg.timeout, + max_auto_continue: cfg.maxAutoContinue, + max_history_messages: cfg.maxHistoryMessages, + thinking: cfg.thinking !== undefined ? { enabled: cfg.thinking.enabled } : null, + compression: { + enabled: cfg.compression?.enabled ?? false, + level: cfg.compression?.level ?? 1, + keep_recent: cfg.compression?.keepRecent ?? 10, + early_msg_max_chars: cfg.compression?.earlyMsgMaxChars ?? 4000, + }, + tools: { + schema_mode: cfg.tools?.schemaMode ?? 'full', + description_max_length: cfg.tools?.descriptionMaxLength ?? 0, + passthrough: cfg.tools?.passthrough ?? false, + disabled: cfg.tools?.disabled ?? false, + }, + sanitize_response: cfg.sanitizeEnabled, + refusal_patterns: cfg.refusalPatterns ?? [], + logging: cfg.logging ?? { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' }, + }); +} + +/** + * POST /api/config + * 接收可热重载字段,合并写入 config.yaml,热重载由 fs.watch 自动触发 + */ +export function apiSaveConfig(req: Request, res: Response): void { + const body = req.body as Record; + + // 基本类型校验 + if (body.cursor_model !== undefined && typeof body.cursor_model !== 'string') { + res.status(400).json({ error: 'cursor_model must be a string' }); return; + } + if (body.timeout !== undefined && (typeof body.timeout !== 'number' || body.timeout <= 0)) { + res.status(400).json({ error: 'timeout must be a positive number' }); return; + } + if (body.max_auto_continue !== undefined && typeof body.max_auto_continue !== 'number') { + res.status(400).json({ error: 'max_auto_continue must be a number' }); return; + } + if (body.max_history_messages !== undefined && typeof body.max_history_messages !== 'number') { + res.status(400).json({ error: 'max_history_messages must be a number' }); return; + } + + try { + // 读取现有 yaml(如不存在则从空对象开始) + let raw: Record = {}; + if (existsSync('config.yaml')) { + raw = (parseYaml(readFileSync('config.yaml', 'utf-8')) as Record) ?? {}; + } + + // 记录变更 + const changes: string[] = []; + + // 合并可热重载字段 + if (body.cursor_model !== undefined && body.cursor_model !== raw.cursor_model) { + changes.push(`cursor_model: ${raw.cursor_model ?? '(unset)'} → ${body.cursor_model}`); + raw.cursor_model = body.cursor_model; + } + if (body.timeout !== undefined && body.timeout !== raw.timeout) { + changes.push(`timeout: ${raw.timeout ?? '(unset)'} → ${body.timeout}`); + raw.timeout = body.timeout; + } + if (body.max_auto_continue !== undefined && body.max_auto_continue !== raw.max_auto_continue) { + changes.push(`max_auto_continue: ${raw.max_auto_continue ?? '(unset)'} → ${body.max_auto_continue}`); + raw.max_auto_continue = body.max_auto_continue; + } + if (body.max_history_messages !== undefined && body.max_history_messages !== raw.max_history_messages) { + changes.push(`max_history_messages: ${raw.max_history_messages ?? '(unset)'} → ${body.max_history_messages}`); + raw.max_history_messages = body.max_history_messages; + } + if (body.thinking !== undefined) { + const t = body.thinking as { enabled: boolean | null } | null; + const oldVal = JSON.stringify(raw.thinking); + if (t === null || t?.enabled === null) { + // null = 跟随客户端:从 yaml 中删除 thinking 节 + if (raw.thinking !== undefined) { + changes.push(`thinking: ${oldVal} → (跟随客户端)`); + delete raw.thinking; + } + } else { + const newVal = JSON.stringify(t); + if (oldVal !== newVal) { + changes.push(`thinking: ${oldVal ?? '(unset)'} → ${newVal}`); + raw.thinking = t; + } + } + } + if (body.compression !== undefined) { + const oldVal = JSON.stringify(raw.compression); + const newVal = JSON.stringify(body.compression); + if (oldVal !== newVal) { + changes.push(`compression: (changed)`); + raw.compression = body.compression; + } + } + if (body.tools !== undefined) { + const oldVal = JSON.stringify(raw.tools); + const newVal = JSON.stringify(body.tools); + if (oldVal !== newVal) { + changes.push(`tools: (changed)`); + raw.tools = body.tools; + } + } + if (body.sanitize_response !== undefined && body.sanitize_response !== raw.sanitize_response) { + changes.push(`sanitize_response: ${raw.sanitize_response ?? '(unset)'} → ${body.sanitize_response}`); + raw.sanitize_response = body.sanitize_response; + } + if (body.refusal_patterns !== undefined) { + const oldVal = JSON.stringify(raw.refusal_patterns); + const newVal = JSON.stringify(body.refusal_patterns); + if (oldVal !== newVal) { + changes.push(`refusal_patterns: (changed)`); + raw.refusal_patterns = body.refusal_patterns; + } + } + if (body.logging !== undefined) { + const oldVal = JSON.stringify(raw.logging); + const newVal = JSON.stringify(body.logging); + if (oldVal !== newVal) { + changes.push(`logging: (changed)`); + raw.logging = body.logging; + } + } + + if (changes.length === 0) { + res.json({ ok: true, changes: [] }); + return; + } + + // 写入 config.yaml(热重载由 fs.watch 自动触发) + writeFileSync('config.yaml', stringifyYaml(raw, { lineWidth: 0 }), 'utf-8'); + + console.log(`[Config API] ✏️ 通过 UI 更新配置,${changes.length} 项变更:`); + changes.forEach(c => console.log(` └─ ${c}`)); + + res.json({ ok: true, changes }); + } catch (e) { + console.error('[Config API] 写入 config.yaml 失败:', e); + res.status(500).json({ error: String(e) }); + } +} diff --git a/src/index.ts b/src/index.ts index 4a469f4..c5c77ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { getConfig, initConfigWatcher, stopConfigWatcher } from './config.js'; import { handleMessages, listModels, countTokens } from './handler.js'; import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js'; import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp } from './log-viewer.js'; +import { apiGetConfig, apiSaveConfig } from './config-api.js'; import { loadLogsFromFiles } from './logger.js'; // 从 package.json 读取版本号,统一来源,避免多处硬编码 @@ -72,6 +73,8 @@ app.get('/api/stats', logViewerAuth, apiGetStats); app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload); app.get('/api/logs/stream', logViewerAuth, apiLogsStream); app.post('/api/logs/clear', logViewerAuth, apiClearLogs); +app.get('/api/config', logViewerAuth, apiGetConfig); +app.post('/api/config', logViewerAuth, apiSaveConfig); // ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token app.use((req, res, next) => { diff --git a/vue-ui/src/App.vue b/vue-ui/src/App.vue index a45371e..ff48f3b 100644 --- a/vue-ui/src/App.vue +++ b/vue-ui/src/App.vue @@ -3,11 +3,12 @@ @@ -24,6 +25,7 @@ import LoginPage from './components/LoginPage.vue'; import AppHeader from './components/AppHeader.vue'; import RequestList from './components/RequestList.vue'; import DetailPanel from './components/DetailPanel.vue'; +import ConfigDrawer from './components/ConfigDrawer.vue'; const auth = useAuthStore(); const logsStore = useLogsStore(); @@ -32,6 +34,7 @@ const { loggedIn: isLoggedIn } = storeToRefs(auth); const authChecked = ref(false); const sseConnected = ref(false); +const configDrawerVisible = ref(false); // 初始化主题(避免闪烁) const savedTheme = localStorage.getItem('cursor2api_theme') ?? 'light'; diff --git a/vue-ui/src/api.ts b/vue-ui/src/api.ts index ab68511..6f74495 100644 --- a/vue-ui/src/api.ts +++ b/vue-ui/src/api.ts @@ -1,4 +1,4 @@ -import type { LogEntry, RequestSummary, Stats, Payload } from './types'; +import type { LogEntry, RequestSummary, Stats, Payload, HotConfig, SaveConfigResult } from './types'; import { useAuthStore } from './stores/auth'; import { getActivePinia } from 'pinia'; @@ -46,6 +46,25 @@ export async function clearLogs(): Promise { if (!res.ok) throw new Error(`HTTP ${res.status}`); } +export function fetchConfig(): Promise { + return apiFetch('/api/config'); +} + +export async function saveConfig(cfg: Partial): Promise { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify(cfg), + }); + if (res.status === 401) { + const pinia = getActivePinia(); + if (pinia) useAuthStore(pinia).logout(); + throw new Error('HTTP 401'); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; +} + export function createSSEConnection(onMessage: (event: string, data: unknown) => void): EventSource { const token = localStorage.getItem('cursor2api_token'); const url = token ? `/api/logs/stream?token=${encodeURIComponent(token)}` : '/api/logs/stream'; diff --git a/vue-ui/src/components/AppHeader.vue b/vue-ui/src/components/AppHeader.vue index f1dc83f..c675392 100644 --- a/vue-ui/src/components/AppHeader.vue +++ b/vue-ui/src/components/AppHeader.vue @@ -14,6 +14,13 @@
+
@@ -32,6 +39,7 @@ import { useAuthStore } from '../stores/auth'; import { storeToRefs } from 'pinia'; defineProps<{ connected: boolean }>(); +const emit = defineEmits<{ openConfig: [] }>(); const statsStore = useStatsStore(); const logsStore = useLogsStore(); @@ -136,6 +144,9 @@ h1 .ic { font-size: 17px; -webkit-text-fill-color: initial; } .clear-btn:hover { border-color: var(--red); color: var(--red); } .theme-btn:hover { border-color: var(--accent); color: var(--accent); } .logout-btn:hover { border-color: var(--orange); color: var(--orange); } +.config-btn { display: inline-flex; align-items: center; gap: 4px; } +.config-btn svg { flex-shrink: 0; } +.config-btn:hover { border-color: var(--accent); color: var(--accent); } .conn { display: flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 500; diff --git a/vue-ui/src/components/ConfigDrawer.vue b/vue-ui/src/components/ConfigDrawer.vue new file mode 100644 index 0000000..4611b6e --- /dev/null +++ b/vue-ui/src/components/ConfigDrawer.vue @@ -0,0 +1,409 @@ +