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.
This commit is contained in:
huangzhenting
2026-03-20 17:50:49 +08:00
parent 203de92228
commit 45f192a381
9 changed files with 694 additions and 8 deletions

154
src/config-api.ts Normal file
View File

@@ -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<string, unknown>;
// 基本类型校验
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<string, unknown> = {};
if (existsSync('config.yaml')) {
raw = (parseYaml(readFileSync('config.yaml', 'utf-8')) as Record<string, unknown>) ?? {};
}
// 记录变更
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) });
}
}

View File

@@ -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) => {

View File

@@ -3,11 +3,12 @@
<template v-if="authChecked">
<LoginPage v-if="!isLoggedIn" @loggedIn="onLogin" />
<template v-else>
<AppHeader :connected="sseConnected" />
<AppHeader :connected="sseConnected" @openConfig="configDrawerVisible = true" />
<div class="main">
<RequestList />
<DetailPanel />
</div>
<ConfigDrawer :visible="configDrawerVisible" @close="configDrawerVisible = false" />
</template>
</template>
</div>
@@ -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';

View File

@@ -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<void> {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
export function fetchConfig(): Promise<HotConfig> {
return apiFetch<HotConfig>('/api/config');
}
export async function saveConfig(cfg: Partial<HotConfig>): Promise<SaveConfigResult> {
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<SaveConfigResult>;
}
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';

View File

@@ -14,6 +14,13 @@
</div>
<div class="header-right">
<button v-if="loggedIn && authStore.token" class="hdr-btn logout-btn" @click="onLogout">退出</button>
<button class="hdr-btn config-btn" @click="emit('openConfig')" title="配置">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
配置
</button>
<button class="hdr-btn clear-btn" @click="onClear">🗑 清空</button>
<button class="hdr-btn theme-btn" @click="toggleTheme">{{ isDark ? '' : '🌙' }}</button>
<div class="conn" :class="connected ? 'on' : 'off'">
@@ -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;

View File

@@ -0,0 +1,409 @@
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="visible" class="overlay" @click.self="emit('close')">
<div class="drawer">
<div class="drawer-hdr">
<span class="drawer-title"> 配置</span>
<button class="close-btn" @click="emit('close')"></button>
</div>
<div v-if="configStore.loading" class="drawer-loading">加载中</div>
<div v-else-if="!draft" class="drawer-loading">无法加载配置</div>
<div v-else class="drawer-body">
<!-- 基础 -->
<Group title="基础">
<Field label="cursor_model" desc="代理转发时使用的 Cursor 内部模型,默认 anthropic/claude-sonnet-4.6">
<select v-model="draft.cursor_model" class="inp inp-wide">
<option v-for="m in MODELS" :key="m" :value="m">{{ m }}</option>
</select>
</Field>
<Field label="timeout" desc="等待 Cursor API 响应的最长时间,单位秒,默认 120">
<input v-model.number="draft.timeout" type="number" min="1" class="inp" />
</Field>
<Field label="max_auto_continue" desc="截断时自动续写的最大次数。默认 0禁用推荐由客户端如 Claude Code自行处理体验更好设为 1~3 可启用 proxy 内部续写">
<input v-model.number="draft.max_auto_continue" type="number" min="0" class="inp" />
</Field>
<Field label="max_history_messages" desc="输入消息条数上限,超出时删除最早的消息(保留工具 few-shot 示例)。防止超长对话导致请求体积过大、响应变慢。默认 -1不限制">
<input v-model.number="draft.max_history_messages" type="number" min="-1" class="inp" />
</Field>
</Group>
<!-- 功能 -->
<Group title="功能">
<Field label="thinking.enabled" desc="最高优先级。跟随客户端 = 不干预(推荐);强制关闭 = 即使客户端请求也不启用;强制开启 = 即使客户端未请求也注入">
<SegSelect
:modelValue="draft.thinking === null ? 'auto' : draft.thinking.enabled ? 'on' : 'off'"
@update:modelValue="v => draft.thinking = v === 'auto' ? null : { enabled: v === 'on' }"
:options="[
{ value: 'auto', label: '跟随客户端' },
{ value: 'off', label: '强制关闭' },
{ value: 'on', label: '强制开启' },
]" />
</Field>
<Field label="sanitize_response" desc="将响应中 Cursor 身份引用替换为 Claude清洗工具可用性声明等。默认关闭如无需伪装身份建议保持关闭有轻微性能开销">
<Toggle v-model="draft.sanitize_response" />
</Field>
</Group>
<!-- 压缩 -->
<Group title="历史压缩compression">
<Field label="compression.enabled" desc="默认关闭。对话过长时自动压缩早期消息,释放输出空间,防止 Cursor 上下文溢出。压缩算法会智能识别消息类型,不会破坏工具调用的 JSON 结构">
<Toggle v-model="draft.compression.enabled" />
</Field>
<template v-if="draft.compression.enabled">
<Field label="compression.level" desc="默认 1轻度。1=保留最近10条/早期4k chars适合日常2=6条/2k适合中长对话3=4条/1k适合超长对话/大工具集">
<SegSelect v-model="draft.compression.level" :options="[
{ value: 1, label: '1 轻度' },
{ value: 2, label: '2 中等' },
{ value: 3, label: '3 激进' },
]" />
</Field>
<Field label="compression.keep_recent" desc="压缩时保留最近 N 条消息不压缩,默认由 level 决定level 1=10条。手动设置后会覆盖 level 的预设值">
<input v-model.number="draft.compression.keep_recent" type="number" min="1" class="inp" />
</Field>
<Field label="compression.early_msg_max_chars" desc="早期消息压缩后保留的最大字符数,默认由 level 决定level 1=4000 chars。手动设置后会覆盖 level 的预设值">
<input v-model.number="draft.compression.early_msg_max_chars" type="number" min="100" class="inp" />
</Field>
</template>
</Group>
<!-- 工具 -->
<Group title="工具处理tools">
<Field label="tools.schema_mode" desc="compactTypeScript 风格紧凑签名体积最小适合工具多的场景full完整 JSON Schema工具调用最精确默认names_only只输出工具名和描述极致省 token">
<SegSelect v-model="draft.tools.schema_mode" :options="[
{ value: 'full', label: 'full' },
{ value: 'compact', label: 'compact' },
{ value: 'names_only', label: 'names_only' },
]" />
</Field>
<Field label="tools.description_max_length" desc="工具描述截断长度。0=不截断默认工具理解最准确50=节省上下文200=中等截断">
<input v-model.number="draft.tools.description_max_length" type="number" min="0" class="inp" />
</Field>
<Field label="tools.passthrough" desc="默认 false。推荐 Roo Code / Cline 等非 Claude Code 客户端开启。跳过 few-shot 注入,直接将工具定义以原始 JSON 嵌入系统提示词,可解决「只有 read_file/read_dir」的错误">
<Toggle v-model="draft.tools.passthrough" />
</Field>
<Field label="tools.disabled" desc="默认 false。完全不注入工具定义和 few-shot 示例,节省大量上下文。模型凭自身训练记忆处理工具调用,适合已内化工具格式的场景">
<Toggle v-model="draft.tools.disabled" />
</Field>
</Group>
<!-- 日志 -->
<Group title="日志持久化logging">
<Field label="logging.file_enabled" desc="开启后日志会写入文件,重启后自动加载历史记录。默认关闭">
<Toggle v-model="draft.logging.file_enabled" />
</Field>
<template v-if="draft.logging.file_enabled">
<Field label="logging.dir" desc="日志文件存储目录,默认 ./logs">
<input v-model="draft.logging.dir" type="text" class="inp inp-wide" />
</Field>
<Field label="logging.max_days" desc="超出天数的日志文件自动清理,默认 7 天">
<input v-model.number="draft.logging.max_days" type="number" min="1" class="inp" />
</Field>
<Field label="logging.persist_mode" desc="summary=仅保留问答摘要与少量元数据默认compact=精简调试信息保留更多排障细节full=完整持久化">
<SegSelect v-model="draft.logging.persist_mode" :options="[
{ value: 'summary', label: 'summary' },
{ value: 'compact', label: 'compact' },
{ value: 'full', label: 'full' },
]" />
</Field>
</template>
</Group>
<!-- 高级 -->
<Group title="高级">
<Field label="refusal_patterns" desc="追加到内置拒绝检测列表之后,匹配到则触发重试。每行一条正则表达式(不区分大小写),无效正则自动退化为字面量匹配。支持热重载,修改后下一次请求即生效" vertical>
<textarea
v-model="refusalPatternsText"
class="inp textarea"
rows="4"
placeholder="每行一条正则表达式…"
/>
</Field>
</Group>
</div>
<!-- 底部操作栏 -->
<div class="drawer-footer">
<Transition name="fade">
<div v-if="saveMsg" :class="['save-msg', saveMsgType]">
<template v-if="saveMsgType === 'success'">
已保存
<span v-if="lastChanges.length" class="changes">
{{ lastChanges.join(' | ') }}
</span>
<span v-else>无变更</span>
</template>
<template v-else> {{ saveError }}</template>
</div>
</Transition>
<div class="footer-btns">
<button class="btn-cancel" @click="emit('close')">取消</button>
<button class="btn-save" :disabled="configStore.saving" @click="onSave">
{{ configStore.saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, computed, defineComponent, h } from 'vue';
import { useConfigStore } from '../stores/config';
import type { HotConfig } from '../types';
const MODELS = [
'anthropic/claude-sonnet-4.6',
'openai/gpt-5.1-codex-mini',
'google/gemini-3-flash',
];
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{ close: [] }>();
const configStore = useConfigStore();
// 本地草稿,独立编辑
const draft = ref<HotConfig | null>(null);
// refusal_patterns 用 textarea 文本表示
const refusalPatternsText = computed({
get: () => draft.value?.refusal_patterns?.join('\n') ?? '',
set: (v: string) => {
if (draft.value) {
draft.value.refusal_patterns = v.split('\n').map(s => s.trim()).filter(Boolean);
}
},
});
// 打开时加载配置并初始化草稿
watch(() => props.visible, async (v) => {
if (v) {
await configStore.load();
draft.value = configStore.config ? JSON.parse(JSON.stringify(configStore.config)) : null;
saveMsg.value = false;
}
});
// 保存结果提示
const saveMsg = ref(false);
const saveMsgType = ref<'success' | 'error'>('success');
const lastChanges = ref<string[]>([]);
const saveError = ref('');
async function onSave() {
if (!draft.value) return;
saveMsg.value = false;
try {
const result = await configStore.save(draft.value);
lastChanges.value = result.changes;
saveMsgType.value = 'success';
saveMsg.value = true;
setTimeout(() => { saveMsg.value = false; }, 4000);
} catch (e) {
saveError.value = String(e);
saveMsgType.value = 'error';
saveMsg.value = true;
}
}
// 辅助子组件:分组标题
const Group = defineComponent({
props: { title: String },
setup(p, { slots }) {
return () => h('div', { class: 'cfg-group' }, [
h('div', { class: 'cfg-group-title' }, p.title),
slots.default?.(),
]);
},
});
// 辅助子组件:字段行
const Field = defineComponent({
props: { label: String, desc: String, vertical: Boolean },
setup(p, { slots }) {
return () => h('div', { class: ['cfg-field', { 'cfg-field-v': p.vertical }] }, [
h('div', { class: 'cfg-label-wrap' }, [
h('code', { class: 'cfg-key' }, p.label),
p.desc ? h('span', { class: 'cfg-desc' }, p.desc) : null,
]),
h('div', { class: 'cfg-ctrl' }, slots.default?.()),
]);
},
});
// 辅助子组件:开关
const Toggle = defineComponent({
props: { modelValue: Boolean },
emits: ['update:modelValue'],
setup(p, { emit: emitToggle }) {
return () => h('div', { class: 'toggle-wrap' }, [
h('button', {
class: ['seg-btn', { active: !p.modelValue }],
onClick: () => emitToggle('update:modelValue', false),
}, '关闭'),
h('button', {
class: ['seg-btn', { active: p.modelValue }],
onClick: () => emitToggle('update:modelValue', true),
}, '开启'),
]);
},
});
// 辅助子组件:分段选择器
const SegSelect = defineComponent({
props: { modelValue: [String, Number], options: Array as () => Array<{ value: string|number; label: string }> },
emits: ['update:modelValue'],
setup(p, { emit: emitSeg }) {
return () => h('div', { class: 'seg-wrap' },
p.options?.map(opt => h('button', {
class: ['seg-btn', { active: p.modelValue === opt.value }],
onClick: () => emitSeg('update:modelValue', opt.value),
}, opt.label))
);
},
});
</script>
<style>
/* 遮罩 */
.overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45);
display: flex; justify-content: flex-end;
}
/* 抽屉 */
.drawer {
width: 650px; height: 100%;
background: var(--bg1);
border-left: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
}
/* 动画 */
.drawer-enter-active, .drawer-leave-active { transition: transform .25s ease; }
.drawer-enter-from .drawer, .drawer-leave-to .drawer { transform: translateX(100%); }
/* Header */
.drawer-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.drawer-title { font-weight: 700; font-size: 14px; color: var(--text); }
.close-btn {
background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: 14px; padding: 2px 6px;
border-radius: 4px;
}
.close-btn:hover { color: var(--text); background: var(--hover-bg); }
/* 加载 */
.drawer-loading { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 13px; }
/* body 滚动区 */
.drawer-body { flex: 1; overflow-y: auto; padding: 0 0 16px; }
/* 分组 */
.cfg-group {
border: 1px solid var(--border);
border-radius: 8px;
margin: 10px 12px 0;
overflow: hidden;
}
.cfg-group-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; color: var(--accent);
padding: 8px 14px 7px;
background: color-mix(in srgb, var(--accent) 6%, var(--bg2));
border-bottom: 1px solid var(--border);
}
/* 字段行 */
.cfg-field {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px; gap: 10px; min-height: 46px;
border-bottom: 1px solid var(--border-faint);
}
.cfg-field:last-child { border-bottom: none; }
.cfg-field-v { flex-direction: column; align-items: stretch; min-height: unset; }
.cfg-label-wrap { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
.cfg-key {
font-family: var(--mono, monospace); font-size: 12px; font-weight: 600;
color: var(--text); background: none; padding: 0; border: none;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cfg-desc { font-size: 11px; color: var(--text-muted); line-height: 1.4; white-space: normal; }
.cfg-field-v .cfg-label-wrap { margin-bottom: 6px; }
.cfg-ctrl { display: flex; align-items: center; flex-shrink: 0; }
/* 输入控件 */
.inp {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text);
font-size: 12px; padding: 4px 8px;
outline: none; min-width: 0;
}
select.inp { cursor: pointer; }
input.inp { width: 90px; text-align: center; }
input[type="text"].inp { width: 160px; text-align: left; }
.inp-wide { width: 200px; }
input[type="text"].inp-wide { width: 200px; }
.inp:focus { border-color: var(--accent); }
.textarea { width: 100%; resize: vertical; font-family: var(--mono, monospace); box-sizing: border-box; }
/* 分段选择器 */
.seg-wrap, .toggle-wrap {
display: flex; border: 1px solid var(--border);
border-radius: 6px; overflow: hidden; flex-shrink: 0;
}
.seg-btn {
padding: 4px 10px; font-size: 11px; cursor: pointer;
background: var(--bg2); color: var(--text-muted);
border: none; border-right: 1px solid var(--border);
transition: all .15s; white-space: nowrap;
}
.seg-btn:last-child { border-right: none; }
.seg-btn.active { background: var(--accent); color: #fff; font-weight: 600; }
.seg-btn:not(.active):hover { background: var(--hover-bg); color: var(--text); }
/* Footer */
.drawer-footer {
border-top: 1px solid var(--border);
padding: 10px 16px; flex-shrink: 0;
}
.footer-btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.btn-cancel {
padding: 5px 14px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg2);
color: var(--text-muted); font-size: 12px; cursor: pointer;
}
.btn-cancel:hover { border-color: var(--text-muted); color: var(--text); }
.btn-save {
padding: 5px 14px; border-radius: 6px;
border: none; background: var(--accent);
color: #fff; font-size: 12px; cursor: pointer; font-weight: 600;
}
.btn-save:disabled { opacity: .5; cursor: not-allowed; }
.btn-save:not(:disabled):hover { filter: brightness(1.1); }
/* 保存提示 */
.save-msg {
font-size: 11px; padding: 5px 8px;
border-radius: 6px; word-break: break-all;
}
.save-msg.success { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); }
.save-msg.error { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); }
.changes { margin-left: 6px; opacity: .75; }
/* fade 过渡 */
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -57,6 +57,12 @@
</div>
</Section>
<Section v-if="logsStore.payload.question"
:title="`❓ 用户问题摘要`" :count="logsStore.payload.question.length" count-unit="chars"
border-color="var(--orange)">
<CodeBlock :content="logsStore.payload.question" :mdPreview="mdPreview" />
</Section>
<Section v-if="logsStore.payload.systemPrompt"
:title="`🧠 System Prompt`" :count="logsStore.payload.systemPrompt.length" count-unit="chars">
<CodeBlock :content="logsStore.payload.systemPrompt" :mdPreview="mdPreview" lang="markdown" />
@@ -99,6 +105,17 @@
<!-- 响应内容 tab -->
<template v-else-if="mode === 'response'">
<Section v-if="logsStore.payload.answer"
:title="logsStore.payload.answerType === 'tool_calls' ? '✅ 最终结果(工具调用摘要)' : '✅ 最终回答摘要'"
:count="logsStore.payload.answer.length" count-unit="chars">
<CodeBlock :content="logsStore.payload.answer" :mdPreview="mdPreview" lang="markdown" />
</Section>
<Section v-if="logsStore.payload.toolCallNames?.length && !logsStore.payload.toolCalls"
:title="`🔧 工具调用名称`" :count="logsStore.payload.toolCallNames.length" count-unit="">
<CodeBlock :content="logsStore.payload.toolCallNames.join(', ')" />
</Section>
<Section v-if="logsStore.payload.thinkingContent"
:title="`🧠 Thinking`" :count="logsStore.payload.thinkingContent.length" count-unit="chars">
<CodeBlock :content="logsStore.payload.thinkingContent" :mdPreview="mdPreview" />
@@ -237,15 +254,17 @@ function msgDefaultOpen(
}
const hasRequest = computed(() =>
!!(logsStore.payload?.tools?.length || logsStore.payload?.cursorRequest || logsStore.payload?.cursorMessages?.length)
!!(curReq.value || logsStore.payload?.tools?.length || logsStore.payload?.cursorRequest || logsStore.payload?.cursorMessages?.length)
);
const hasPrompts = computed(() =>
!!(logsStore.payload?.systemPrompt || logsStore.payload?.messages?.length)
!!(convSummary.value || logsStore.payload?.question || logsStore.payload?.systemPrompt ||
logsStore.payload?.messages?.length || logsStore.payload?.cursorMessages?.length)
);
const hasResponse = computed(() =>
!!(logsStore.payload?.finalResponse || logsStore.payload?.thinkingContent ||
logsStore.payload?.toolCalls?.length || logsStore.payload?.retryResponses?.length ||
logsStore.payload?.continuationResponses?.length)
!!(logsStore.payload?.answer || logsStore.payload?.toolCallNames?.length ||
logsStore.payload?.thinkingContent || logsStore.payload?.finalResponse ||
logsStore.payload?.rawResponse || logsStore.payload?.toolCalls?.length ||
logsStore.payload?.retryResponses?.length || logsStore.payload?.continuationResponses?.length)
);
// ===== 消息搜索 =====
@@ -280,10 +299,11 @@ const Section = defineComponent({
title: String,
count: Number,
countUnit: { type: String, default: '' },
borderColor: { type: String, default: '' },
},
setup(p, { slots }) {
const open = ref(true);
return () => h('div', { class: 'cs' }, [
return () => h('div', { class: 'cs', style: p.borderColor ? { borderLeft: '3px solid ' + p.borderColor, paddingLeft: '0' } : {} }, [
h('div', {
class: 'cs-hdr',
onClick: () => { open.value = !open.value; },

View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchConfig, saveConfig } from '../api';
import type { HotConfig, SaveConfigResult } from '../types';
export const useConfigStore = defineStore('config', () => {
const config = ref<HotConfig | null>(null);
const loading = ref(false);
const saving = ref(false);
const lastChanges = ref<string[]>([]);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
config.value = await fetchConfig();
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function save(draft: Partial<HotConfig>): Promise<SaveConfigResult> {
saving.value = true;
error.value = '';
try {
const result = await saveConfig(draft);
lastChanges.value = result.changes;
// 保存成功后重新加载配置
if (result.ok) await load();
return result;
} catch (e) {
error.value = String(e);
throw e;
} finally {
saving.value = false;
}
}
return { config, loading, saving, lastChanges, error, load, save };
});

View File

@@ -60,6 +60,25 @@ export interface Stats {
avgTTFT: number;
}
/** 可热重载的配置snake_case对应 yaml 键名) */
export interface HotConfig {
cursor_model: string;
timeout: number;
max_auto_continue: number;
max_history_messages: number;
thinking: { enabled: boolean } | null;
compression: { enabled: boolean; level: 1 | 2 | 3; keep_recent: number; early_msg_max_chars: number };
tools: { schema_mode: 'compact' | 'full' | 'names_only'; description_max_length: number; passthrough?: boolean; disabled?: boolean };
sanitize_response: boolean;
refusal_patterns: string[];
logging: { file_enabled: boolean; dir: string; max_days: number; persist_mode: 'compact' | 'full' | 'summary' };
}
export interface SaveConfigResult {
ok: boolean;
changes: string[];
}
/** 对应后端 RequestPayload */
export interface Payload {
// 原始请求
@@ -70,6 +89,11 @@ export interface Payload {
// 转换后请求
cursorRequest?: unknown;
cursorMessages?: Array<{ role: string; contentPreview: string; contentLength: number }>;
// 摘要字段
question?: string;
answer?: string;
answerType?: string;
toolCallNames?: string[];
// 模型响应
rawResponse?: string;
finalResponse?: string;