diff --git a/docker-compose.yml b/docker-compose.yml index 9704127..42dd1d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: volumes: # 挂载配置文件(可选)——先从 config.yaml.example 复制一份: cp config.yaml.example config.yaml # 修改后只需 docker compose restart 即可生效;不挂载则使用内置默认值 + 环境变量 - - ./config.yaml:/app/config.yaml:ro + - ./config.yaml:/app/config.yaml # 日志持久化目录(需要在 config.yaml 或环境变量中开启 logging.file_enabled) - ./logs:/app/logs environment: 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/cursor-client.ts b/src/cursor-client.ts index 829366a..afdd2e6 100644 --- a/src/cursor-client.ts +++ b/src/cursor-client.ts @@ -136,6 +136,12 @@ async function sendCursorRequestInner( const REPEAT_THRESHOLD = 8; // 同一 delta 连续出现 8 次 → 退化 let degenerateAborted = false; + // ★ 行级重复检测:历史消息较多时模型偶发换行重复输出 bug,连续相同行超过阈值则中止并重试 + let lineBuffer = ''; + let lastLine = ''; + let lineRepeatCount = 0; + let lineRepeatAborted = false; + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -180,19 +186,50 @@ async function sendCursorRequestInner( } } + // ★ 行级重复检测 + if (event.type === 'text-delta' && event.delta) { + lineBuffer += event.delta; + if (lineBuffer.length > 50) { lineBuffer = ''; } // 超长行不参与检测 + if (lineBuffer.indexOf('\n') !== -1) { + const nlParts = lineBuffer.split('\n'); + lineBuffer = nlParts.pop()!; + for (const completedLine of nlParts) { + const trimLine = completedLine.trim(); + if (!trimLine) continue; + if (trimLine === lastLine) { + lineRepeatCount++; + if (lineRepeatCount >= REPEAT_THRESHOLD) { + console.warn(`[Cursor] ⚠️ 检测到行级重复: "${trimLine.substring(0, 60)}" 已连续重复 ${lineRepeatCount} 次,中止流`); + lineRepeatAborted = true; + reader.cancel(); + break; + } + } else { + lastLine = trimLine; + lineRepeatCount = 1; + } + } + if (lineRepeatAborted) break; + } + } + onChunk(event); } catch { // 非 JSON 数据,忽略 } } - if (degenerateAborted) break; + if (degenerateAborted || lineRepeatAborted) break; } // ★ 退化循环中止后,抛出特殊错误让外层 sendCursorRequest 不再重试 if (degenerateAborted) { throw new Error('DEGENERATE_LOOP_ABORTED'); } + // ★ 行级重复中止后,抛出普通错误让外层 sendCursorRequest 走正常重试 + if (lineRepeatAborted) { + throw new Error('LINE_REPEAT_ABORTED'); + } // 处理剩余 buffer if (buffer.startsWith('data: ')) { 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/README.md b/vue-ui/README.md index 875e621..0ce502d 100644 --- a/vue-ui/README.md +++ b/vue-ui/README.md @@ -1,6 +1,6 @@ # cursor2api Vue3 日志 UI -基于 Vue3 + Vite + TypeScript 构建的日志查看前端,替代原有的原生 HTML 页面,挂载在 `/vuelogs` 路由下。 +基于 Vue3 + Vite + TypeScript 构建的日志查看与配置前端,挂载在 `/vuelogs` 路由下。 ## 技术栈 @@ -15,24 +15,26 @@ ``` vue-ui/ ├── src/ -│ ├── App.vue # 根组件 -│ ├── main.ts # 入口 -│ ├── api.ts # API 请求封装 -│ ├── types.ts # 类型定义 +│ ├── App.vue # 根组件 +│ ├── main.ts # 入口 +│ ├── api.ts # API 请求封装 +│ ├── types.ts # 类型定义 │ ├── components/ -│ │ ├── LoginPage.vue # 登录页 -│ │ ├── AppHeader.vue # 顶部导航 -│ │ ├── LogList.vue # 日志列表 -│ │ ├── RequestList.vue # 请求列表 -│ │ ├── DetailPanel.vue # 请求详情面板 -│ │ ├── PayloadView.vue # Payload 查看 -│ │ └── PhaseTimeline.vue# 阶段时间线 +│ │ ├── LoginPage.vue # 登录页 +│ │ ├── AppHeader.vue # 顶部导航(含配置按钮) +│ │ ├── LogList.vue # 日志列表 +│ │ ├── RequestList.vue # 请求列表 +│ │ ├── DetailPanel.vue # 请求详情面板 +│ │ ├── PayloadView.vue # Payload 查看 +│ │ ├── PhaseTimeline.vue # 阶段时间线 +│ │ └── ConfigDrawer.vue # 配置抽屉(热重载配置) │ ├── composables/ -│ │ └── useSSE.ts # SSE 实时推送 +│ │ └── useSSE.ts # SSE 实时推送 │ └── stores/ -│ ├── auth.ts # 登录状态 -│ ├── logs.ts # 日志数据 -│ └── stats.ts # 统计数据 +│ ├── auth.ts # 登录状态 +│ ├── logs.ts # 日志数据 +│ ├── stats.ts # 统计数据 +│ └── config.ts # 配置状态 ├── index.html ├── package.json ├── tsconfig.json @@ -69,6 +71,80 @@ npm run build 产物输出到项目根目录的 `public/vue/`,后端通过 `/vuelogs` 路由提供服务。 +> **重要**:Docker 镜像打包前必须先执行此构建步骤,否则容器内将缺少前端静态资源。 + +## Docker 部署注意事项 + +### 1. 先构建前端再构建镜像 + +Dockerfile 不会自动构建 Vue UI,需要先在本地生成产物: + +```bash +# 第一步:构建前端(在 vue-ui 目录) +cd vue-ui && npm install && npm run build && cd .. + +# 第二步:构建并启动容器 +docker compose up -d --build +``` + +### 2. config.yaml 不能挂载为只读 + +配置抽屉支持通过 Web UI 实时修改并写回 `config.yaml`,因此挂载时**不能**加 `:ro` 只读标志: + +```yaml +# ✅ 正确 +volumes: + - ./config.yaml:/app/config.yaml + +# ❌ 错误(UI 保存配置时会报 EROFS: read-only file system) +volumes: + - ./config.yaml:/app/config.yaml:ro +``` + +### 3. 首次部署前准备 config.yaml + +挂载前宿主机上必须已存在 `config.yaml`,否则 Docker 会将其创建为目录: + +```bash +cp config.yaml.example config.yaml +# 按需编辑 config.yaml +``` + +### 4. 完整部署流程 + +```bash +# 1. 准备配置文件 +cp config.yaml.example config.yaml + +# 2. 构建前端 +cd vue-ui && npm install && npm run build && cd .. + +# 3. 启动服务 +docker compose up -d --build + +# 4. 访问日志 UI +open http://localhost:3010/vuelogs +``` + +## 配置抽屉 + +点击顶部右侧的 **⚙ 配置** 按钮可打开配置面板,支持修改以下热重载配置项: + +| 分组 | 字段 | 说明 | +|------|------|------| +| 基础 | `cursor_model` | 使用的 Cursor 模型 | +| 基础 | `timeout` | 请求超时(秒) | +| 基础 | `max_auto_continue` | 自动续写次数 | +| 基础 | `max_history_messages` | 历史消息条数上限 | +| 功能 | `thinking.enabled` | Thinking 模式(跟随客户端/强制关闭/强制开启) | +| 功能 | `sanitize_response` | 响应内容清洗 | +| 历史压缩 | `compression.*` | 压缩开关、级别、保留条数等 | +| 工具处理 | `tools.*` | Schema 模式、透传/禁用 | +| 日志持久化 | `logging.*` | 文件持久化、目录、落盘模式 | +| 高级 | `refusal_patterns` | 自定义拒绝检测正则 | + +保存后配置立即写入 `config.yaml`,fs.watch 热重载下一次请求即生效,无需重启服务。 + ## 与原有日志页面的关系 | 路由 | 实现 | 鉴权方式 | 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 @@ +