diff --git a/docs/skill/wechatexplorer-reader/SKILL.md b/docs/skill/wechatexplorer-reader/SKILL.md index ec703bf..09c925f 100644 --- a/docs/skill/wechatexplorer-reader/SKILL.md +++ b/docs/skill/wechatexplorer-reader/SKILL.md @@ -1,6 +1,6 @@ --- name: wechatexplorer-reader -description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app,而非 chatlog/WeFlow。 +description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。 --- # WechatExplorer Reader @@ -333,4 +333,4 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图 } ``` -(视 MCP over HTTP 支持情况调整) \ No newline at end of file +(视 MCP over HTTP 支持情况调整) diff --git a/electron-builder.yml b/electron-builder.yml index 66db4ce..bc5ba91 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -20,6 +20,10 @@ extraResources: to: resources filter: - '**/*' + - from: docs/skill/wechatexplorer-reader + to: skill/wechatexplorer-reader + filter: + - '**/*' win: # WCDB's Windows runtime checks the host executable name. The dev runtime is # electron.exe, so keep the packaged executable compatible while preserving @@ -58,4 +62,6 @@ appImage: npmRebuild: false publish: provider: github + owner: Wxw-Gu + repo: WechatExplorer releaseType: draft diff --git a/package.json b/package.json index dffa416..5e76efd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "AI群聊总结助手" ], "author": "Qingmao", + "repository": { + "type": "git", + "url": "https://github.com/Wxw-Gu/WechatExplorer.git" + }, "main": "./out/main/index.js", "scripts": { "format": "prettier --write .", @@ -18,6 +22,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", + "test:skill-install": "node scripts/test-skill-install-instruction.cjs", "cp:env": "node scripts/ensure-env.cjs", "prepare:env": "node scripts/ensure-env.cjs", "predev": "node scripts/ensure-env.cjs", diff --git a/scripts/test-skill-install-instruction.cjs b/scripts/test-skill-install-instruction.cjs new file mode 100644 index 0000000..a8c3f97 --- /dev/null +++ b/scripts/test-skill-install-instruction.cjs @@ -0,0 +1,45 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const ts = require('typescript') + +const filePath = path.join( + __dirname, + '..', + 'src', + 'renderer', + 'src', + 'features', + 'api-center', + 'utils', + 'buildSkillInstallInstruction.ts' +) +const source = fs.readFileSync(filePath, 'utf8') +const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText +const moduleExports = {} +new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports }) + +const { buildSkillInstallInstruction } = moduleExports +const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' } + +for (const [target, expected] of [ + ['codex', 'Codex 项目或用户 Skill 目录'], + ['claude-code', '按照 SKILL\.md 调用本地 HTTP API'], + ['openclaw', '作为 WechatExplorer Reader Skill 安装'], + ['generic', '读取并安装'] +]) { + const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } }) + assert.match(text, new RegExp(expected)) + assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/) +} + +assert.match( + buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }), + /http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/ +) +assert.match( + buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }), + /https:\/\/example\.com\/skill/ +) + +console.log('skill install instruction tests passed') diff --git a/src/main/index.ts b/src/main/index.ts index cd386b6..7376ef4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,8 @@ import { KeyServiceMac } from './key-service-mac' import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' import { apiServer } from './http-server' +import { skillResourceService } from './services/skill-resource-service' +import { testLocalApiRequest } from './services/local-api-test-service' import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import { getBootstrapCache, @@ -498,6 +500,23 @@ app.whenReady().then(async () => { return apiServer.stop() }) + ipcMain.handle('api:skillStatus', () => skillResourceService.getStatus()) + ipcMain.handle('api:readSkill', () => skillResourceService.read()) + ipcMain.handle('api:revealSkill', () => skillResourceService.reveal()) + ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub()) + ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request)) + ipcMain.handle('api:copyText', (_, text: unknown) => { + if (typeof text !== 'string' || text.length > 1024 * 1024) { + return { success: false, error: '复制内容无效或过大' } + } + try { + clipboard.writeText(text) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + createWindow() // 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗) diff --git a/src/main/services/local-api-test-service.ts b/src/main/services/local-api-test-service.ts new file mode 100644 index 0000000..b4dad71 --- /dev/null +++ b/src/main/services/local-api-test-service.ts @@ -0,0 +1,153 @@ +import http from 'http' +import { apiServer } from '../http-server' +import { + LOCAL_API_ENDPOINTS, + type LocalApiEndpointId, + type LocalApiTestRequest, + type LocalApiTestResponse +} from '../../shared/local-api-test' + +const REQUEST_TIMEOUT_MS = 10_000 +const MAX_BODY_SIZE = 512 * 1024 + +function isEndpointId(value: unknown): value is LocalApiEndpointId { + return typeof value === 'string' && value in LOCAL_API_ENDPOINTS +} + +function requestHost(host: string): string { + if (host === '0.0.0.0') return '127.0.0.1' + if (host === '::') return '::1' + return host +} + +function invalidResponse(message: string): LocalApiTestResponse { + return { + ok: false, + method: 'GET', + path: '', + url: '', + durationMs: 0, + responseSize: 0, + errorCode: 'INVALID_REQUEST', + error: message + } +} + +function parseBody(bodyText: string, contentType?: string): { json?: unknown; bodyText?: string } { + if (contentType?.includes('application/json')) { + try { + return { json: JSON.parse(bodyText) } + } catch { + // Keep malformed responses readable to the tester. + } + } + return { bodyText } +} + +export async function testLocalApiRequest(payload: unknown): Promise { + if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效') + const { endpointId, query = {}, body = '' } = payload as Partial + if (!isEndpointId(endpointId)) return invalidResponse('不允许访问该 API 端点') + if (!query || typeof query !== 'object' || Array.isArray(query)) + return invalidResponse('查询参数格式无效') + if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE) + return invalidResponse('请求体格式无效') + + const endpoint = LOCAL_API_ENDPOINTS[endpointId] + const entries = Object.entries(query) + if ( + entries.some( + ([key, value]) => !endpoint.queryKeys.includes(key as never) || typeof value !== 'string' + ) + ) { + return invalidResponse('查询参数不属于当前端点') + } + + const service = apiServer.getState() + const targetHost = requestHost(service.host) + const targetPort = service.port + const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost + const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`) + entries.forEach(([key, value]) => { + if (value.trim()) url.searchParams.set(key, value.trim()) + }) + + if (!service.running) { + return { + ok: false, + method: endpoint.method, + path: endpoint.path, + url: url.toString(), + durationMs: 0, + responseSize: 0, + errorCode: 'API_NOT_RUNNING', + error: '请先启动本地 API 服务' + } + } + + return new Promise((resolve) => { + const startedAt = Date.now() + let settled = false + const finish = (result: LocalApiTestResponse): void => { + if (settled) return + settled = true + resolve(result) + } + const request = http.request( + url, + { + method: endpoint.method, + headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined + }, + (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.on('end', () => { + const bodyText = Buffer.concat(chunks).toString('utf-8') + const contentType = Array.isArray(response.headers['content-type']) + ? response.headers['content-type'][0] + : response.headers['content-type'] + finish({ + ok: true, + method: endpoint.method, + path: endpoint.path, + url: url.toString(), + status: response.statusCode || 0, + statusText: response.statusMessage || '', + durationMs: Date.now() - startedAt, + responseSize: Buffer.byteLength(bodyText), + contentType, + ...parseBody(bodyText, contentType) + }) + }) + } + ) + request.setTimeout(REQUEST_TIMEOUT_MS, () => { + request.destroy(new Error('请求超时')) + finish({ + ok: false, + method: endpoint.method, + path: endpoint.path, + url: url.toString(), + durationMs: Date.now() - startedAt, + responseSize: 0, + errorCode: 'TIMEOUT', + error: '请求超时(10 秒)' + }) + }) + request.on('error', (error: NodeJS.ErrnoException) => { + finish({ + ok: false, + method: endpoint.method, + path: endpoint.path, + url: url.toString(), + durationMs: Date.now() - startedAt, + responseSize: 0, + errorCode: error.code === 'ECONNREFUSED' ? 'CONNECTION_REFUSED' : 'UNKNOWN', + error: error.message + }) + }) + if (endpoint.method === 'POST') request.write(body) + request.end() + }) +} diff --git a/src/main/services/skill-resource-service.ts b/src/main/services/skill-resource-service.ts new file mode 100644 index 0000000..ac670af --- /dev/null +++ b/src/main/services/skill-resource-service.ts @@ -0,0 +1,91 @@ +import { app, shell } from 'electron' +import { existsSync, promises as fs } from 'fs' +import { dirname, join } from 'path' + +const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md') +const GITHUB_URL = + 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader' + +export interface SkillResourceStatus { + available: boolean + version?: string + filePath?: string + directoryPath?: string + source: 'development' | 'bundled' + githubUrl: string + error?: string +} + +function getSkillCandidates(): { path: string; source: 'development' | 'bundled' }[] { + const developmentPath = join(app.getAppPath(), 'docs', SKILL_RELATIVE_PATH) + const bundledPaths = [ + join(process.resourcesPath, SKILL_RELATIVE_PATH), + join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH), + join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH) + ] + return app.isPackaged + ? bundledPaths.map((path) => ({ path, source: 'bundled' as const })) + : [ + { path: developmentPath, source: 'development' as const }, + ...bundledPaths.map((path) => ({ path, source: 'bundled' as const })) + ] +} + +function getStatus(): SkillResourceStatus { + const candidates = getSkillCandidates() + const resolved = candidates.find((candidate) => existsSync(candidate.path)) + const filePath = resolved?.path || candidates[0].path + const source = resolved?.source || candidates[0].source + const directoryPath = dirname(filePath) + if (!resolved) { + return { + available: false, + source, + githubUrl: GITHUB_URL, + error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})` + } + } + return { + available: true, + version: 'v1.0', + filePath, + directoryPath, + source, + githubUrl: GITHUB_URL + } +} + +export const skillResourceService = { + getStatus, + + async read(): Promise<{ success: boolean; content?: string; error?: string }> { + const status = this.getStatus() + if (!status.available || !status.filePath) return { success: false, error: status.error } + try { + return { success: true, content: await fs.readFile(status.filePath, 'utf-8') } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + + async reveal(): Promise<{ success: boolean; error?: string }> { + const status = this.getStatus() + if (!status.available || !status.directoryPath) return { success: false, error: status.error } + try { + const error = await shell.openPath(status.directoryPath) + if (error) return { success: false, error } + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + + async openGithub(): Promise<{ success: boolean; error?: string }> { + try { + await shell.openExternal(GITHUB_URL) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d66beee..7eda0a8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { Contact, Message } from '../shared/types' import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report' +import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test' import { DeleteGeneratedReportResult, ReportHistoryResult, @@ -217,6 +218,20 @@ declare global { port: number error?: string }> + getReaderSkillStatus: () => Promise<{ + available: boolean + version?: string + filePath?: string + directoryPath?: string + source: 'development' | 'bundled' + githubUrl: string + error?: string + }> + readReaderSkill: () => Promise<{ success: boolean; content?: string; error?: string }> + revealReaderSkill: () => Promise<{ success: boolean; error?: string }> + openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }> + testLocalApiRequest: (request: LocalApiTestRequest) => Promise + copyText: (text: string) => Promise<{ success: boolean; error?: string }> } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8996c59..72bb2b4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,7 +40,8 @@ const api = { listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'), saveGeneratedReport: (request: SaveGeneratedReportRequest) => ipcRenderer.invoke('report:saveGenerated', request), - deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId), + deleteGeneratedReport: (reportId: string) => + ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), @@ -77,7 +78,13 @@ const api = { apiStatus: () => ipcRenderer.invoke('api:getStatus'), apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port), apiStop: () => ipcRenderer.invoke('api:stop'), - apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled) + apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled), + getReaderSkillStatus: () => ipcRenderer.invoke('api:skillStatus'), + readReaderSkill: () => ipcRenderer.invoke('api:readSkill'), + revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'), + openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'), + testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request), + copyText: (text: string) => ipcRenderer.invoke('api:copyText', text) } if (process.contextIsolated) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fad81c5..b017d0a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' import { SettingsPanel } from './components/SettingsPanel' import { AppShell } from './components/layout/AppShell' +import { ApiWorkspace } from './features/api-center/ApiWorkspace' import { AppPage } from './components/layout/navigation' import { AiReportWorkspace } from './components/reports/AiReportWorkspace' import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar' @@ -1085,9 +1086,19 @@ function App(): React.ReactElement { return renderArchiveWorkspace() case 'report': return renderReportWorkspace() + case 'api': + return ( + { + setActivePage('settings') + setShowSettings(true) + }} + /> + ) case 'search': case 'export': - case 'api': case 'settings': return renderPlaceholderPage(activePage) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 470b50d..4fe158c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3546,6 +3546,25 @@ body { padding: 0 10px 12px; } +/* API-01 本地 API 中心 */ +.api-center-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; background: var(--wxex-bg-main); } +.api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; } +.api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); } +.ready-text { color: var(--wxex-success); } +.api-main { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; background: var(--wxex-bg-main); }.api-main-scroll { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 22px 28px 36px; } +.api-workspace-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--wxex-border); }.api-title-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.api-title-line h1 { margin: 0; color: var(--wxex-text-primary); font: 700 22px/30px var(--wxex-font); }.api-workspace-heading p { margin: 4px 0 0; color: var(--wxex-text-secondary); font: 13px/19px var(--wxex-font); }.api-skill-status, .api-version { padding: 2px 7px; border-radius: 5px; font: 700 11px/17px var(--wxex-font); }.api-skill-status.ready { color: var(--wxex-brand); background: var(--wxex-brand-soft); }.api-skill-status.error { color: var(--wxex-danger); background: #fff1f1; }.api-version { color: var(--wxex-text-muted); background: var(--wxex-bg-app); } +.api-header-actions, .api-tester-actions, .api-runtime-actions, .api-skill-file-actions { display: flex; flex-wrap: wrap; gap: 8px; }.api-header-actions button, .api-tester-actions button, .api-runtime-actions button, .api-endpoint-row button, .api-response-summary + pre + p + button, .api-skill-file-actions button { min-height: 32px; padding: 0 11px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: var(--wxex-bg-elevated); color: var(--wxex-text-primary); cursor: pointer; font: 600 12px/18px var(--wxex-font); }.api-header-actions button:hover, .api-tester-actions button:hover, .api-runtime-actions button:hover, .api-endpoint-row button:hover, .api-skill-file-actions button:hover { border-color: var(--wxex-brand); color: var(--wxex-brand); }.api-primary-button { border-color: var(--wxex-brand) !important; background: var(--wxex-brand) !important; color: #fff !important; }.api-primary-button:hover:not(:disabled), .api-primary-button:focus-visible, .api-primary-button:active { background: var(--wxex-brand-hover) !important; color: #fff !important; }.api-primary-button:disabled { cursor: not-allowed; opacity: .55; } +.api-trust-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; padding: 10px 12px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-md); background: #f5f8f6; color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.api-trust-bar i { width: 3px; height: 3px; border-radius: 50%; background: var(--wxex-text-muted); }.api-introduction { margin-top: 24px; padding: 18px 20px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-lg); background: var(--wxex-bg-elevated); }.api-introduction p { margin: 10px 0 16px; color: var(--wxex-text-secondary); font: 13px/20px var(--wxex-font); }.api-flow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px; border: 1px dashed var(--wxex-border); border-radius: var(--wxex-radius-md); color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.api-flow span, .api-flow strong { padding: 7px 10px; border: 1px solid var(--wxex-border); border-radius: 5px; background: #fff; white-space: nowrap; }.api-flow strong { color: var(--wxex-brand); background: var(--wxex-brand-soft); }.api-flow b { color: var(--wxex-text-muted); font-size: 17px; } +.api-endpoint-catalog, .api-request-tester { margin-top: 26px; } +.skill-install-flow { margin-top: 26px; padding: 18px 20px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-lg); background: var(--wxex-bg-elevated); }.skill-flow-steps { display: grid; gap: 16px; margin-top: 16px; }.skill-flow-steps > section { position: relative; display: grid; grid-template-columns: 26px minmax(0,1fr); gap: 10px; color: var(--wxex-text-muted); }.skill-flow-steps > section:not(:last-child)::after { position: absolute; top: 28px; left: 12px; bottom: -16px; width: 1px; background: var(--wxex-border); content: ''; }.skill-flow-steps > section > b { position: relative; z-index: 1; display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid var(--wxex-border); border-radius: 50%; background: var(--wxex-bg-elevated); font: 700 12px/1 var(--wxex-font); }.skill-flow-steps > section.active > b, .skill-flow-steps > section.done > b { border-color: var(--wxex-brand); background: var(--wxex-brand); color: #fff; }.skill-flow-steps h3 { margin: 1px 0 5px; color: var(--wxex-text-primary); font: 700 14px/20px var(--wxex-font); }.skill-flow-steps p { margin: 3px 0; color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.skill-flow-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }.skill-flow-actions button, .api-skill-details button { min-height: 30px; padding: 0 10px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: #fff; color: var(--wxex-text-primary); cursor: pointer; font: 600 12px/18px var(--wxex-font); }.skill-flow-actions button:disabled, .api-skill-details button:disabled { cursor: not-allowed; opacity: .55; }.skill-target-selector { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }.skill-target-selector button { min-height: 30px; padding: 0 10px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: #fff; color: var(--wxex-text-secondary); cursor: pointer; font: 600 12px/18px var(--wxex-font); }.skill-target-selector button.active { border-color: var(--wxex-brand); background: var(--wxex-brand-soft); color: var(--wxex-brand); } +.api-skill-details { margin-top: 16px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-md); background: var(--wxex-bg-elevated); }.api-skill-details summary { padding: 12px 14px; color: var(--wxex-text-primary); cursor: pointer; font: 700 13px/18px var(--wxex-font); }.api-skill-details > dl, .api-skill-details > div { margin: 0; padding: 0 14px 14px; }.api-skill-details dl div { display: grid; grid-template-columns: 72px minmax(0,1fr); gap: 8px; padding: 4px 0; color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.api-skill-details dd { margin: 0; color: var(--wxex-text-primary); } +.api-skill-more { position: relative; }.api-skill-more summary { display: grid; min-height: 32px; place-items: center; padding: 0 11px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: var(--wxex-bg-elevated); color: var(--wxex-text-primary); cursor: pointer; font: 600 12px/18px var(--wxex-font); list-style: none; }.api-skill-more[open] > button { position: absolute; z-index: 2; top: 36px; right: 0; width: 170px; padding: 9px 10px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: #fff; color: var(--wxex-text-primary); box-shadow: var(--wxex-shadow-popover); font: 12px/18px var(--wxex-font); text-align: left; } +.api-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }.api-section-heading > span { color: var(--wxex-text-muted); font: 12px/18px var(--wxex-font); }.api-endpoint-table { margin-top: 12px; overflow: hidden; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-md); background: var(--wxex-bg-elevated); }.api-endpoint-head, .api-endpoint-row { display: grid; grid-template-columns: 72px minmax(128px, 1.1fr) minmax(150px, 1.2fr) 90px; align-items: center; gap: 12px; padding: 11px 14px; }.api-endpoint-head { background: #f1f4f2; color: var(--wxex-text-secondary); font: 700 12px/18px var(--wxex-font); }.api-endpoint-row { min-height: 54px; border-top: 1px solid var(--wxex-border); color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.api-endpoint-row.active { background: #f7faf8; }.api-endpoint-row code { color: var(--wxex-text-primary); font: 12px/18px ui-monospace, monospace; }.api-endpoint-row small { display: block; color: var(--wxex-text-muted); }.api-endpoint-row > span:last-child { display: flex; gap: 4px; }.api-endpoint-row button { min-height: 26px; padding: 0 7px; font-size: 11px; }.api-method { display: inline-block; color: var(--wxex-brand); font: 700 11px/18px ui-monospace, monospace; }.api-method.post { color: var(--wxex-ai); } +.api-request-meta { display: flex; align-items: center; gap: 10px; margin: 12px 0; padding: 10px 12px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-md); background: #f5f8f6; overflow: hidden; }.api-request-meta code { overflow: hidden; color: var(--wxex-text-primary); font: 12px/18px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.api-param-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }.api-param-grid label, .api-json-input { display: grid; gap: 5px; color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); }.api-param-grid label span b { margin-left: 5px; color: var(--wxex-danger); font-size: 11px; }.api-param-grid input, .api-json-input textarea { box-sizing: border-box; width: 100%; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); outline: none; background: #fff; color: var(--wxex-text-primary); font: 12px/18px var(--wxex-font); }.api-param-grid input { height: 34px; padding: 0 9px; }.api-json-input { margin-top: 10px; }.api-json-input textarea { min-height: 180px; padding: 10px; resize: vertical; font-family: ui-monospace, monospace; }.api-tester-actions { justify-content: flex-end; margin-top: 12px; }.api-inline-error { margin: 10px 0 0; padding: 8px 10px; border: 1px solid rgba(200,90,90,.35); border-radius: var(--wxex-radius-sm); background: #fff4f4; color: var(--wxex-danger); font: 12px/18px var(--wxex-font); } +.api-runtime-panel { border-left: 1px solid var(--wxex-border); background: var(--wxex-bg-main); overflow: hidden; }.api-runtime-scroll { height: 100%; overflow-y: auto; }.api-runtime-scroll section { padding: 18px 16px; border-bottom: 1px solid var(--wxex-border); }.api-runtime-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }.api-runtime-title > span { font: 700 12px/18px var(--wxex-font); }.api-runtime-title .ready { color: var(--wxex-success); }.api-runtime-title .stopped { color: var(--wxex-text-muted); }.api-runtime-scroll h3 { margin: 0 0 12px; color: var(--wxex-text-primary); font: 700 14px/20px var(--wxex-font); }.api-runtime-scroll dl { margin: 14px 0; }.api-runtime-scroll dl div { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; font: 12px/18px var(--wxex-font); }.api-runtime-scroll dt { color: var(--wxex-text-secondary); }.api-runtime-scroll dd { margin: 0; color: var(--wxex-text-primary); text-align: right; }.warning-text { color: var(--wxex-warning); }.api-runtime-actions { display: grid; grid-template-columns: 1fr 1fr; }.api-runtime-actions button { min-height: 30px; padding: 0 6px; }.api-security-warning { margin: 12px 16px; padding: 9px 10px; border-left: 3px solid var(--wxex-warning); background: #fff8ec; color: #915d1e; font: 12px/18px var(--wxex-font); }.api-response-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--wxex-text-primary); font: 12px/18px ui-monospace, monospace; }.api-runtime-scroll pre { max-height: 180px; overflow: auto; margin: 10px 0 8px; padding: 10px; border-radius: var(--wxex-radius-sm); background: #1f2824; color: #cde7dc; font: 11px/16px ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }.api-response-meta, .api-empty-text { margin: 0 0 9px; color: var(--wxex-text-muted); font: 11px/17px var(--wxex-font); }.api-history { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }.api-history li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; padding-bottom: 8px; border-bottom: 1px solid var(--wxex-border); }.api-history span { overflow: hidden; color: var(--wxex-text-primary); font: 11px/17px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.api-history b { font: 700 11px/17px var(--wxex-font); }.api-history small { grid-column: 1 / -1; color: var(--wxex-text-muted); font: 11px/16px var(--wxex-font); }.api-privacy { background: #f5f8f6; }.api-privacy p { margin: 0; color: var(--wxex-text-secondary); font: 12px/18px var(--wxex-font); } +.api-markdown-overlay { position: absolute; inset: 0; z-index: 4; display: flex; align-items: stretch; justify-content: center; padding: 20px; background: rgba(32,39,36,.24); }.api-markdown-overlay > div { display: flex; width: min(820px, 100%); flex-direction: column; min-height: 0; padding: 14px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-lg); background: #fff; box-shadow: 0 12px 40px rgba(0,0,0,.16); }.api-markdown-overlay header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.api-markdown-overlay header > div { display: flex; align-items: center; gap: 8px; }.api-markdown-overlay header span { color: var(--wxex-text-muted); font: 12px/18px var(--wxex-font); }.api-markdown-overlay button { min-height: 30px; padding: 0 10px; border: 1px solid var(--wxex-border); border-radius: var(--wxex-radius-sm); background: #fff; color: var(--wxex-text-primary); cursor: pointer; }.api-markdown-overlay pre, .skill-markdown-preview { min-height: 0; overflow: auto; margin: 14px 0 0; white-space: pre-wrap; color: var(--wxex-text-primary); font: 12px/19px var(--wxex-font); }.skill-markdown-preview h1 { font-size: 20px; }.skill-markdown-preview h2 { margin-top: 18px; font-size: 16px; }.skill-markdown-preview p { margin: 6px 0; }.skill-markdown-preview li { margin-left: 18px; } +@media (max-width: 1100px) { .api-center-layout { grid-template-columns: minmax(0,1fr) 280px; }.api-main-scroll { padding: 18px; }.api-header-actions { max-width: 260px; justify-content: flex-end; }.api-endpoint-head, .api-endpoint-row { grid-template-columns: 56px minmax(110px,1fr) minmax(100px,1fr) 78px; gap: 7px; padding: 10px; }.api-flow { overflow-x: auto; justify-content: flex-start; } } + .report-history-list-title { padding: 4px 4px 8px; color: var(--wxex-text-secondary); diff --git a/src/renderer/src/features/api-center/ApiWorkspace.tsx b/src/renderer/src/features/api-center/ApiWorkspace.tsx new file mode 100644 index 0000000..6ca7708 --- /dev/null +++ b/src/renderer/src/features/api-center/ApiWorkspace.tsx @@ -0,0 +1,80 @@ +import type { Contact } from '../../../../shared/types' +import { type ReactElement } from 'react' +import { ApiRequestTester } from './components/ApiRequestTester' +import { ApiRuntimePanel } from './components/ApiRuntimePanel' +import { EndpointCatalog } from './components/EndpointCatalog' +import { ReaderSkillOverview } from './components/ReaderSkillOverview' +import { SkillPreviewDialog } from './components/SkillPreviewDialog' +import { useApiCenterController } from './hooks/useApiCenterController' + +interface Props { + selectedContact: Contact | null + dbReady: boolean + onOpenSettings: () => void +} + +export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props): ReactElement { + const controller = useApiCenterController(selectedContact) + const { state } = controller + const clear = (): void => { + controller.updateParams({}) + controller.updateBody('') + } + return ( +
+
+
+ void controller.controlService('start')} + onCopyInstruction={() => void controller.copyInstallInstruction()} + onCopyVerification={() => void controller.copyVerificationPrompt()} + /> + controller.copyText(path, '接口路径已复制')} + /> + controller.copyText(command, 'curl 命令已复制')} + /> +
+ {state.rawMarkdown && ( + + )} +
+ + {state.toast &&
{state.toast}
} +
+ ) +} diff --git a/src/renderer/src/features/api-center/components/ApiRequestTester.tsx b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx new file mode 100644 index 0000000..e13657e --- /dev/null +++ b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx @@ -0,0 +1,103 @@ +import type { ApiEndpoint, ApiServiceState, ApiSettings } from '../model/types' +import { type ReactElement } from 'react' +import { buildApiUrl } from '../utils/buildApiUrl' + +interface Props { + endpoint: ApiEndpoint + settings: ApiSettings | null + service: ApiServiceState | null + params: Record + body: string + state: 'idle' | 'loading' | 'success' | 'error' + error: string + onParams: (params: Record) => void + onBody: (body: string) => void + onSend: () => void + onClear: () => void + onCopyCurl: (command: string) => Promise +} + +export function ApiRequestTester({ + endpoint, + settings, + service, + params, + body, + state, + error, + onParams, + onBody, + onSend, + onClear, + onCopyCurl +}: Props): ReactElement { + const url = settings ? buildApiUrl(settings.apiHost, settings.apiPort, endpoint.path, params) : '' + const copyCurl = (): void => { + const command = + endpoint.method === 'POST' + ? `curl -X POST '${url}' -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'` + : `curl '${url}'` + void onCopyCurl(command) + } + const update = (key: string, value: string): void => onParams({ ...params, [key]: value }) + return ( +
+
+

快速测试

+ 轻量本地调试 +
+
+ {endpoint.method} + {url || endpoint.path} +
+ {endpoint.parameters?.length ? ( +
+ {endpoint.parameters.map((parameter) => ( + + ))} +
+ ) : null} + {endpoint.body && ( +