mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 06:08:27 +08:00
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:
154
src/config-api.ts
Normal file
154
src/config-api.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
409
vue-ui/src/components/ConfigDrawer.vue
Normal file
409
vue-ui/src/components/ConfigDrawer.vue
Normal 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="compact:TypeScript 风格紧凑签名,体积最小(适合工具多的场景);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>
|
||||
@@ -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; },
|
||||
|
||||
43
vue-ui/src/stores/config.ts
Normal file
43
vue-ui/src/stores/config.ts
Normal 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 };
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user