From b5c5f31e5afec97a1e6b112ef6547093b45afa71 Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Tue, 3 Mar 2026 04:59:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20HTTP=20API=20?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E3=80=81=E8=AE=BE=E7=BD=AE=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=92=8C=E9=87=8D=E5=90=AF=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 55 ++++ electron/preload.ts | 7 + electron/services/config.ts | 8 + electron/services/httpApiService.ts | 398 ++++++++++++++++++++++++++++ src/pages/SettingsPage.scss | 244 ++++++++++++++++- src/pages/SettingsPage.tsx | 314 +++++++++++++++++++++- src/services/config.ts | 39 +++ src/types/electron.d.ts | 53 ++++ 8 files changed, 1115 insertions(+), 3 deletions(-) create mode 100644 electron/services/httpApiService.ts diff --git a/electron/main.ts b/electron/main.ts index 9b570db..cea90fe 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -25,6 +25,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeServiceWhisper } from './services/voiceTranscribeServiceWhisper' import { windowsHelloService, WindowsHelloResult } from './services/windowsHelloService' import { shortcutService } from './services/shortcutService' +import { httpApiService } from './services/httpApiService' // 注册自定义协议为特权协议(必须在 app ready 之前) protocol.registerSchemesAsPrivileged([ @@ -1056,6 +1057,42 @@ function registerIpcHandlers() { return configService?.setTldCache(tlds) }) + // HTTP API 管理 + ipcMain.handle('httpApi:getStatus', async () => { + return { success: true, status: httpApiService.getUiStatus() } + }) + + ipcMain.handle('httpApi:applySettings', async (_, payload: { enabled: boolean; port: number; token: string }) => { + try { + const enabled = Boolean(payload?.enabled) + const portRaw = Number(payload?.port) + const port = Number.isFinite(portRaw) ? Math.max(1, Math.min(65535, Math.floor(portRaw))) : 5031 + const token = (payload?.token || '').trim() + + configService?.set('httpApiEnabled', enabled) + configService?.set('httpApiPort', port) + configService?.set('httpApiToken', token) + + httpApiService.applySettings({ enabled, port, token, host: '127.0.0.1' }) + const restartResult = await httpApiService.restart() + if (!restartResult.success) { + return { success: false, error: restartResult.error || 'HTTP API 重启失败' } + } + + return { success: true, status: httpApiService.getUiStatus() } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + ipcMain.handle('httpApi:restart', async () => { + const result = await httpApiService.restart() + if (!result.success) { + return { success: false, error: result.error || 'HTTP API 重启失败' } + } + return { success: true, status: httpApiService.getUiStatus() } + }) + // 数据库相关 ipcMain.handle('db:open', async (_, dbPath: string) => { return dbService?.open(dbPath) @@ -3626,6 +3663,21 @@ app.whenReady().then(async () => { // 检查是否需要显示启动屏并连接数据库 const shouldShowSplash = await checkAndConnectOnStartup() + // 启动本地 HTTP API(默认 127.0.0.1:5031) + const httpApiEnabled = configService?.get('httpApiEnabled') ?? false + const httpApiPort = configService?.get('httpApiPort') || 5031 + const httpApiToken = (configService?.get('httpApiToken') || '').toString() + httpApiService.applySettings({ + enabled: Boolean(httpApiEnabled), + host: '127.0.0.1', + port: Number(httpApiPort) || 5031, + token: httpApiToken + }) + const httpApiStartResult = await httpApiService.start() + if (!httpApiStartResult.success) { + console.error('[HttpApi] 启动失败:', httpApiStartResult.error) + } + // 只有在配置完整时才创建主窗口 // 如果配置不完整,checkAndConnectOnStartup 会创建引导窗口 if (shouldShowSplash !== false || configService?.get('myWxid')) { @@ -3653,6 +3705,9 @@ app.on('window-all-closed', () => { }) app.on('before-quit', () => { + httpApiService.stop().catch((e) => { + console.error('[HttpApi] 停止失败:', e) + }) // 关闭配置数据库连接 configService?.close() }) diff --git a/electron/preload.ts b/electron/preload.ts index 136978d..eb49dcf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -61,6 +61,13 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, + // HTTP API + httpApi: { + getStatus: () => ipcRenderer.invoke('httpApi:getStatus'), + applySettings: (payload: { enabled: boolean; port: number; token: string }) => ipcRenderer.invoke('httpApi:applySettings', payload), + restart: () => ipcRenderer.invoke('httpApi:restart') + }, + // 窗口控制 window: { minimize: () => ipcRenderer.send('window:minimize'), diff --git a/electron/services/config.ts b/electron/services/config.ts index 96e2564..f5a5f30 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -54,6 +54,11 @@ interface ConfigSchema { autoUpdateMinInterval: number // 最小更新间隔(毫秒) autoUpdateDebounceTime: number // 防抖时间(毫秒) + // HTTP API 相关 + httpApiEnabled: boolean + httpApiPort: number + httpApiToken: string + // AI 相关 aiCurrentProvider: string // 当前选中的提供商 aiProviderConfigs: { // 每个提供商的独立配置 @@ -99,6 +104,9 @@ const defaults: ConfigSchema = { autoUpdateCheckInterval: 60, // 默认 60 秒检查一次 autoUpdateMinInterval: 1000, // 默认最小更新间隔 1 秒 autoUpdateDebounceTime: 500, // 默认防抖时间 0.5 秒 + httpApiEnabled: false, + httpApiPort: 5031, + httpApiToken: '', // AI 默认配置 aiCurrentProvider: 'zhipu', aiProviderConfigs: {}, // 空对象,用户配置后填充 diff --git a/electron/services/httpApiService.ts b/electron/services/httpApiService.ts new file mode 100644 index 0000000..7eddcc7 --- /dev/null +++ b/electron/services/httpApiService.ts @@ -0,0 +1,398 @@ +import * as http from 'http' +import { URL } from 'url' +import { app } from 'electron' +import { ConfigService } from './config' + +interface ApiEnvelopeSuccess { + success: true + data: T + meta: { + ts: number + requestId: string + } +} + +interface ApiEnvelopeError { + success: false + error: { + code: string + message: string + hint?: string + } + meta: { + ts: number + requestId: string + } +} + +interface HttpApiSettings { + enabled: boolean + host: string + port: number + token: string +} + +class HttpApiService { + private server: http.Server | null = null + private readonly connections: Set = new Set() + private settings: HttpApiSettings = { + enabled: false, + host: '127.0.0.1', + port: 5031, + token: '' + } + private startedAt = 0 + private startError = '' + + applySettings(next: Partial): void { + this.settings = { + ...this.settings, + ...next, + host: '127.0.0.1' + } + } + + async start(): Promise<{ success: boolean; error?: string }> { + if (!this.settings.enabled) { + return { success: true } + } + + if (this.server) { + return { success: true } + } + + return new Promise((resolve) => { + const server = http.createServer((req, res) => this.handleRequest(req, res)) + + server.on('connection', (socket) => { + this.connections.add(socket) + socket.on('close', () => this.connections.delete(socket)) + }) + + server.on('error', (err: NodeJS.ErrnoException) => { + this.startError = err.message + if (err.code === 'EADDRINUSE') { + resolve({ success: false, error: `端口 ${this.settings.port} 已被占用` }) + return + } + resolve({ success: false, error: err.message }) + }) + + server.listen(this.settings.port, this.settings.host, () => { + this.server = server + this.startedAt = Date.now() + this.startError = '' + resolve({ success: true }) + }) + }) + } + + async stop(): Promise { + if (!this.server) return + + const currentServer = this.server + this.server = null + + const sockets = Array.from(this.connections) + this.connections.clear() + sockets.forEach((socket) => { + try { + socket.destroy() + } catch { + // ignore + } + }) + + await new Promise((resolve) => { + currentServer.close(() => resolve()) + }) + } + + async restart(): Promise<{ success: boolean; error?: string }> { + await this.stop() + if (!this.settings.enabled) return { success: true } + return this.start() + } + + isRunning(): boolean { + return Boolean(this.server) + } + + getUiStatus() { + const uptimeMs = this.server && this.startedAt ? Date.now() - this.startedAt : 0 + return { + running: this.isRunning(), + host: this.settings.host, + port: this.settings.port, + enabled: this.settings.enabled, + startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : '', + uptimeMs, + tokenConfigured: Boolean(this.settings.token), + tokenPreview: this.getTokenPreview(), + baseUrl: this.getBaseUrl(), + endpoints: [ + { method: 'GET', path: '/v1', desc: '接口详情' }, + { method: 'GET', path: '/v1/health', desc: '健康检查' }, + { method: 'GET', path: '/v1/status', desc: '服务状态' } + ], + lastError: this.startError + } + } + + private getBaseUrl(): string { + return `http://${this.settings.host}:${this.settings.port}/v1` + } + + private getTokenPreview(): string { + if (!this.settings.token) return '' + if (this.settings.token.length <= 6) return '******' + return `${this.settings.token.slice(0, 3)}***${this.settings.token.slice(-3)}` + } + + private isAuthRequired(pathname: string): boolean { + if (!this.settings.token) return false + return pathname !== '/v1' && pathname !== '/v1/' && pathname !== '/v1/health' + } + + private createRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` + } + + private sendJson( + res: http.ServerResponse, + statusCode: number, + payload: ApiEnvelopeSuccess | ApiEnvelopeError + ): void { + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store' + }) + res.end(JSON.stringify(payload)) + } + + private sendRedirect(res: http.ServerResponse, to: string): void { + res.writeHead(307, { + Location: to, + 'Cache-Control': 'no-store' + }) + res.end() + } + + private success(requestId: string, data: T): ApiEnvelopeSuccess { + return { + success: true, + data, + meta: { + ts: Date.now(), + requestId + } + } + } + + private failure(requestId: string, code: string, message: string, hint?: string): ApiEnvelopeError { + return { + success: false, + error: { code, message, hint }, + meta: { + ts: Date.now(), + requestId + } + } + } + + private handleCors(res: http.ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + } + + private extractAuthToken(req: http.IncomingMessage): string { + const authHeader = req.headers.authorization + const authValue = Array.isArray(authHeader) ? authHeader[0] : authHeader + if (authValue) { + const match = authValue.match(/^Bearer\s+(.+)$/i) + if (match?.[1]) { + return match[1].trim() + } + } + return '' + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + this.handleCors(res) + + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + const requestId = this.createRequestId() + const method = req.method || 'GET' + + if (method !== 'GET') { + this.sendJson(res, 405, this.failure(requestId, 'METHOD_NOT_ALLOWED', 'Only GET is supported')) + return + } + + const url = new URL(req.url || '/', `http://${this.settings.host}:${this.settings.port}`) + const pathname = url.pathname + + // 兼容旧路径:无版本前缀时重定向到 /v1 + if (pathname === '/health') { + this.sendRedirect(res, '/v1/health') + return + } + if (pathname === '/status') { + this.sendRedirect(res, '/v1/status') + return + } + if (pathname === '/api/v1' || pathname === '/api/v1/') { + this.sendRedirect(res, '/v1') + return + } + if (pathname === '/api/v1/health') { + this.sendRedirect(res, '/v1/health') + return + } + if (pathname === '/api/v1/status') { + this.sendRedirect(res, '/v1/status') + return + } + if (pathname === '/') { + this.sendRedirect(res, '/v1') + return + } + + if (this.isAuthRequired(pathname)) { + const provided = this.extractAuthToken(req) + if (!provided || provided !== this.settings.token) { + this.sendJson( + res, + 401, + this.failure( + requestId, + 'UNAUTHORIZED', + 'Invalid or missing Authorization Bearer token', + 'Use header: Authorization: Bearer ' + ) + ) + return + } + } + + if (pathname === '/v1' || pathname === '/v1/') { + this.sendJson(res, 200, this.success(requestId, { + name: 'CipherTalk Embedded HTTP API', + version: '1.0.0', + baseUrl: this.getBaseUrl(), + authHeader: 'Authorization: Bearer ', + endpoints: this.getUiStatus().endpoints, + status: this.getUiStatus() + })) + return + } + + if (pathname === '/v1/health') { + this.sendJson(res, 200, this.success(requestId, { + status: 'ok' + })) + return + } + + if (pathname === '/v1/status') { + const configService = new ConfigService() + const hasDbPath = Boolean(configService.get('dbPath')) + const hasWxid = Boolean(configService.get('myWxid')) + const hasDecryptKey = Boolean(configService.get('decryptKey')) + configService.close() + const verbose = url.searchParams.get('verbose') === '1' + + const isApiEnabled = this.settings.enabled + const isApiRunning = this.isRunning() + const isDbConfigReady = hasDbPath && hasWxid && hasDecryptKey + + let state: 'ready' | 'disabled' | 'starting_or_error' | 'needs_config' = 'ready' + let message = 'HTTP API is ready for external calls.' + + if (!isApiEnabled) { + state = 'disabled' + message = 'HTTP API is disabled. Enable it in Settings > Open API.' + } else if (!isApiRunning) { + state = 'starting_or_error' + message = this.startError || 'HTTP API is enabled but not running. Try restart in settings.' + } else if (!isDbConfigReady) { + state = 'needs_config' + message = 'API is running, but database-related features need dbPath/decryptKey/wxid configuration.' + } + + const basePayload = { + summary: { + state, + usable: isApiEnabled && isApiRunning, + message + }, + server: { + running: isApiRunning, + enabled: isApiEnabled, + host: this.settings.host, + port: this.settings.port, + uptimeMs: this.server && this.startedAt ? Date.now() - this.startedAt : 0 + }, + auth: { + required: Boolean(this.settings.token), + scheme: 'Authorization: Bearer ' + }, + config: { + dbConfigReady: isDbConfigReady + } + } + + if (!verbose) { + this.sendJson(res, 200, this.success(requestId, basePayload)) + return + } + + this.sendJson(res, 200, this.success(requestId, { + ...basePayload, + usage: { + baseUrl: this.getBaseUrl(), + health: '/v1/health', + status: '/v1/status', + auth: this.settings.token ? 'Authorization: Bearer ' : 'No auth token required' + }, + app: { + version: app.getVersion(), + electronVersion: process.versions.electron, + nodeVersion: process.versions.node, + platform: process.platform + }, + debug: { + checks: { + apiEnabled: isApiEnabled, + apiRunning: isApiRunning, + dbConfigReady: isDbConfigReady, + authRequired: Boolean(this.settings.token) + }, + tokenPreview: this.getTokenPreview(), + startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : '', + lastError: this.startError + } + })) + return + } + + this.sendJson( + res, + 404, + this.failure( + requestId, + 'NOT_FOUND', + 'Route not found', + 'Try GET /v1 for API overview, or use /v1/health and /v1/status' + ) + ) + } +} + +export const httpApiService = new HttpApiService() diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 4de3ecc..4f4e70c 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2694,4 +2694,246 @@ } } } -} \ No newline at end of file +} + +.api-settings { + .api-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; + } + + .api-status-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 12px; + } + + .api-status-label { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; + } + + .api-status-value { + font-size: 15px; + color: var(--text-primary); + font-weight: 600; + + &.ok { + color: #16a34a; + } + + &.error { + color: #dc2626; + } + + &.mono { + font-family: var(--font-mono); + font-size: 13px; + } + } + + .api-endpoint-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .api-endpoint-item { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 10px; + padding: 10px 12px; + } + + .api-endpoint-main { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + code { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 2px 6px; + font-size: 12px; + } + } + + .api-method { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + color: #047857; + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.35); + } + + .api-desc { + font-size: 12px; + color: var(--text-tertiary); + } +} + +.input-inline-actions { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--border-color); + border-radius: 9999px; + background: var(--bg-primary); + padding: 0 6px 0 14px; + min-height: 40px; + + &:focus-within { + border-color: var(--primary); + } + + input { + flex: 1; + min-width: 0; + margin-bottom: 0; + border: none; + background: transparent; + padding: 8px 0; + + &:focus { + border: none; + } + } + + .inline-actions { + display: flex; + align-items: center; + gap: 6px; + + .inline-icon-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + + &:hover { + color: var(--text-primary); + } + + &:focus-visible { + outline: none; + color: var(--primary); + } + } + } +} + +.switch-setting { + padding: 14px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + + .switch-setting-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .switch-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .switch-desc { + margin-top: 4px; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.api-title-row { + display: flex; + align-items: center; + gap: 8px; + + .section-title { + margin: 0; + } +} + +.api-doc-link-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + + &:hover { + color: var(--text-primary); + } + + &:focus-visible { + outline: none; + color: var(--primary); + } +} + +.api-switch { + width: 44px; + height: 26px; + border: none; + border-radius: 999px; + padding: 2px; + cursor: pointer; + position: relative; + transition: background-color 0.2s ease; + flex-shrink: 0; + + &.off { + background: var(--border-color); + } + + &.on { + background: var(--primary); + } + + .api-switch-thumb { + display: block; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease; + transform: translateX(0); + } + + &.on .api-switch-thumb { + transform: translateX(18px); + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index db37f58..ed46374 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -10,16 +10,17 @@ import { Eye, EyeOff, Key, FolderSearch, FolderOpen, Search, RotateCcw, Trash2, Save, Plug, X, Check, Sun, Moon, Monitor, Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, FileText, Mic, - Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile + Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile, Network, Copy } from 'lucide-react' import { useAuthStore } from '../stores/authStore' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'database' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about' +type SettingsTab = 'appearance' | 'database' | 'api' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'database', label: '数据解密', icon: Database }, + { id: 'api', label: '开放接口', icon: Network }, { id: 'security', label: '安全设置', icon: Lock }, { id: 'stt', label: '语音转文字', icon: Mic }, { id: 'ai', label: 'AI 摘要', icon: Sparkles }, @@ -128,6 +129,57 @@ function SettingsPage() { const [autoUpdateMinInterval, setAutoUpdateMinInterval] = useState(1000) // 最小更新间隔(毫秒) const [autoUpdateDebounceTime, setAutoUpdateDebounceTime] = useState(500) // 防抖时间(毫秒) + // HTTP API 配置 + const [httpApiEnabled, setHttpApiEnabled] = useState(false) + const [httpApiPort, setHttpApiPort] = useState(5031) + const [httpApiToken, setHttpApiToken] = useState('') + const [showHttpApiToken, setShowHttpApiToken] = useState(false) + const [httpApiStatus, setHttpApiStatus] = useState<{ + running: boolean + host: string + port: number + enabled: boolean + startedAt: string + uptimeMs: number + tokenConfigured: boolean + tokenPreview: string + baseUrl: string + endpoints: Array<{ method: string; path: string; desc: string }> + lastError: string + } | null>(null) + const [isSavingHttpApi, setIsSavingHttpApi] = useState(false) + const [isRefreshingHttpApi, setIsRefreshingHttpApi] = useState(false) + const [nowTs, setNowTs] = useState(Date.now()) + + const copyText = async (text: string, successText: string) => { + try { + await navigator.clipboard.writeText(text) + showMessage(successText, true) + } catch (e) { + showMessage('复制失败,请手动复制', false) + } + } + + const createRandomToken = () => { + const randomPart = Math.random().toString(36).slice(2) + const randomPart2 = Math.random().toString(36).slice(2) + return `ct_${Date.now().toString(36)}_${randomPart}${randomPart2}` + } + + useEffect(() => { + if (activeTab !== 'api' || !httpApiStatus?.running) { + return + } + + const timer = window.setInterval(() => { + setNowTs(Date.now()) + }, 1000) + + return () => { + window.clearInterval(timer) + } + }, [activeTab, httpApiStatus?.running]) + // AI 相关配置状态 const [aiProvider, setAiProviderState] = useState('zhipu') const [aiApiKey, setAiApiKeyState] = useState('') @@ -190,9 +242,15 @@ function SettingsPage() { const savedCheckInterval = await configService.getAutoUpdateCheckInterval() const savedMinInterval = await configService.getAutoUpdateMinInterval() const savedDebounceTime = await configService.getAutoUpdateDebounceTime() + const savedHttpApiEnabled = await configService.getHttpApiEnabled() + const savedHttpApiPort = await configService.getHttpApiPort() + const savedHttpApiToken = await configService.getHttpApiToken() setAutoUpdateCheckInterval(savedCheckInterval) setAutoUpdateMinInterval(savedMinInterval) setAutoUpdateDebounceTime(savedDebounceTime) + setHttpApiEnabled(savedHttpApiEnabled) + setHttpApiPort(savedHttpApiPort) + setHttpApiToken(savedHttpApiToken) const savedQuoteStyle = await configService.getQuoteStyle() setQuoteStyle(savedQuoteStyle) @@ -223,6 +281,11 @@ function SettingsPage() { setAiCustomSystemPromptState(savedAiCustomSystemPrompt) setAiEnableThinkingState(savedAiEnableThinking) setAiMessageLimitState(savedAiMessageLimit) + + const statusResult = await window.electronAPI.httpApi.getStatus() + if (statusResult.success && statusResult.status) { + setHttpApiStatus(statusResult.status) + } } catch (e) { console.error('加载配置失败:', e) } @@ -393,6 +456,67 @@ function SettingsPage() { setTimeout(() => setMessage(null), 3000) } + const refreshHttpApiStatus = async () => { + setIsRefreshingHttpApi(true) + try { + const result = await window.electronAPI.httpApi.getStatus() + if (result.success && result.status) { + setHttpApiStatus(result.status) + setHttpApiEnabled(result.status.enabled) + setHttpApiPort(result.status.port) + } else { + showMessage(result.error || '获取接口状态失败', false) + } + } catch (e) { + showMessage(`获取接口状态失败: ${e}`, false) + } finally { + setIsRefreshingHttpApi(false) + } + } + + const handleSaveHttpApiSettings = async () => { + setIsSavingHttpApi(true) + try { + const result = await window.electronAPI.httpApi.applySettings({ + enabled: httpApiEnabled, + port: httpApiPort, + token: httpApiToken + }) + + if (result.success && result.status) { + setHttpApiStatus(result.status) + setHttpApiPort(result.status.port) + await configService.setHttpApiEnabled(httpApiEnabled) + await configService.setHttpApiPort(result.status.port) + await configService.setHttpApiToken(httpApiToken) + showMessage('开放接口配置已保存并生效', true) + } else { + showMessage(result.error || '保存开放接口配置失败', false) + } + } catch (e) { + showMessage(`保存开放接口配置失败: ${e}`, false) + } finally { + setIsSavingHttpApi(false) + } + } + + const handleRestartHttpApi = async () => { + setIsRefreshingHttpApi(true) + try { + const result = await window.electronAPI.httpApi.restart() + if (result.success && result.status) { + setHttpApiStatus(result.status) + showMessage('接口服务已重启', true) + } else { + showMessage(result.error || '接口服务重启失败', false) + } + } catch (e) { + showMessage(`接口服务重启失败: ${e}`, false) + } finally { + setIsRefreshingHttpApi(false) + } + } + const handleClearImages = () => { setShowClearDialog({ type: 'images', @@ -1195,6 +1319,191 @@ function SettingsPage() { ) + const renderApiTab = () => { + const HTTP_API_DOC_URL = 'https://github.com/ILoveBingLu/miyu/blob/main/Docs/HTTP_API_%E5%BC%80%E5%8F%91%E8%A7%84%E5%88%92%E4%B8%8E%E6%8E%A5%E5%8F%A3%E6%A0%87%E5%87%86.md' + const status = httpApiStatus + const startedAtMs = status?.startedAt ? new Date(status.startedAt).getTime() : 0 + const uptime = status?.running && startedAtMs > 0 + ? Math.max(0, nowTs - startedAtMs) + : (status?.uptimeMs ?? 0) + const uptimeText = uptime > 0 + ? `${Math.floor(uptime / 1000)} 秒` + : '0 秒' + + return ( +
+
+
+

开放接口(HTTP API)

+ +
+
用于给外部工具调用,默认仅监听本机地址 `127.0.0.1`。
+ +
+
+
+
+
启用 HTTP API
+
关闭后将停止监听端口,不再对外提供接口。
+
+ +
+
+
+ +
+ + 建议保持默认 5031,范围 1-65535 + setHttpApiPort(Number(e.target.value || 5031))} + /> +
+ +
+ + 令牌预设说明:留空表示不鉴权;设置后,`/v1/status` 等接口必须携带 `Authorization: Bearer <token>`。 +
+ setHttpApiToken(e.target.value)} + placeholder="留空表示不启用令牌鉴权" + /> +
+ + +
+
+
+ +
+ + + +
+
+ +
+ +
+

接口状态与信息

+ + {status ? ( + <> +
+
+
运行状态
+
{status.running ? '运行中' : '未运行'}
+
+
+
监听地址
+
{status.host}:{status.port}
+
+
+
运行时长
+
{uptimeText}
+
+
+
鉴权状态
+
{status.tokenConfigured ? '已启用' : '未启用'}
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ {status.tokenConfigured && ( + + )} +
+
+
+ + {status.lastError && ( +
+ +

最近错误:{status.lastError}

+
+ )} + + ) : ( +

尚未读取到接口状态,请点击“刷新状态”。

+ )} +
+
+ ) + } + const [isGettingImageKey, setIsGettingImageKey] = useState(false) const [imageKeyStatus, setImageKeyStatus] = useState('') @@ -2621,6 +2930,7 @@ function SettingsPage() {
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'database' && renderDatabaseTab()} + {activeTab === 'api' && renderApiTab()} {activeTab === 'security' && renderSecurityTab()} {activeTab === 'stt' && renderSttTab()} {activeTab === 'ai' && ( diff --git a/src/services/config.ts b/src/services/config.ts index f163da7..65c072e 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -25,6 +25,9 @@ export const CONFIG_KEYS = { AUTO_UPDATE_CHECK_INTERVAL: 'autoUpdateCheckInterval', // 检查间隔(秒) AUTO_UPDATE_MIN_INTERVAL: 'autoUpdateMinInterval', // 最小更新间隔(毫秒) AUTO_UPDATE_DEBOUNCE_TIME: 'autoUpdateDebounceTime', // 防抖时间(毫秒) + HTTP_API_ENABLED: 'httpApiEnabled', + HTTP_API_PORT: 'httpApiPort', + HTTP_API_TOKEN: 'httpApiToken', AUTH_ENABLED: 'authEnabled', AUTH_CREDENTIAL_ID: 'authCredentialId', AUTH_PASSWORD_HASH: 'authPasswordHash', @@ -82,6 +85,42 @@ export async function setAutoUpdateDebounceTime(ms: number): Promise { await config.set(CONFIG_KEYS.AUTO_UPDATE_DEBOUNCE_TIME, Math.max(100, Math.min(5000, ms))) } +// --- HTTP API 配置 --- + +// 获取是否启用 HTTP API +export async function getHttpApiEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED) + return value !== undefined ? (value as boolean) : false +} + +// 设置是否启用 HTTP API +export async function setHttpApiEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled) +} + +// 获取 HTTP API 端口 +export async function getHttpApiPort(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_PORT) + return (value as number) || 5031 +} + +// 设置 HTTP API 端口 +export async function setHttpApiPort(port: number): Promise { + const safePort = Number.isFinite(port) ? Math.max(1, Math.min(65535, Math.floor(port))) : 5031 + await config.set(CONFIG_KEYS.HTTP_API_PORT, safePort) +} + +// 获取 HTTP API 访问令牌 +export async function getHttpApiToken(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN) + return (value as string) || '' +} + +// 设置 HTTP API 访问令牌 +export async function setHttpApiToken(token: string): Promise { + await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token.trim()) +} + // --- AI 摘要配置 --- diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2768234..5875ee8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -71,6 +71,59 @@ export interface ElectronAPI { onDownloadProgress: (callback: (progress: number) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void } + httpApi: { + getStatus: () => Promise<{ + success: boolean + status?: { + running: boolean + host: string + port: number + enabled: boolean + startedAt: string + uptimeMs: number + tokenConfigured: boolean + tokenPreview: string + baseUrl: string + endpoints: Array<{ method: string; path: string; desc: string }> + lastError: string + } + error?: string + }> + applySettings: (payload: { enabled: boolean; port: number; token: string }) => Promise<{ + success: boolean + status?: { + running: boolean + host: string + port: number + enabled: boolean + startedAt: string + uptimeMs: number + tokenConfigured: boolean + tokenPreview: string + baseUrl: string + endpoints: Array<{ method: string; path: string; desc: string }> + lastError: string + } + error?: string + }> + restart: () => Promise<{ + success: boolean + status?: { + running: boolean + host: string + port: number + enabled: boolean + startedAt: string + uptimeMs: number + tokenConfigured: boolean + tokenPreview: string + baseUrl: string + endpoints: Array<{ method: string; path: string; desc: string }> + lastError: string + } + error?: string + }> + } // Windows Hello 原生验证 (比 WebAuthn 更快) windowsHello: { /** 检查 Windows Hello 是否可用 */