feat: 完成本地 API 中心与 Reader Skill 流程

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent 519c10223d
commit 5d041aa137
30 changed files with 1743 additions and 5 deletions
+2 -2
View File
@@ -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 支持情况调整)
(视 MCP over HTTP 支持情况调整)
+6
View File
@@ -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
+5
View File
@@ -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",
@@ -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')
+19
View File
@@ -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 鎺у埗)
+153
View File
@@ -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<LocalApiTestResponse> {
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
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()
})
}
@@ -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) }
}
}
}
+15
View File
@@ -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<LocalApiTestResponse>
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
}
}
}
+9 -2
View File
@@ -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) {
+12 -1
View File
@@ -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 (
<ApiWorkspace
selectedContact={selectedContact}
dbReady={isAuthenticated}
onOpenSettings={() => {
setActivePage('settings')
setShowSettings(true)
}}
/>
)
case 'search':
case 'export':
case 'api':
case 'settings':
return renderPlaceholderPage(activePage)
}
+19
View File
@@ -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);
@@ -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 (
<div className="api-center-layout">
<main className="api-main">
<div className="api-main-scroll">
<ReaderSkillOverview
skill={state.skill}
service={state.service}
dbReady={dbReady}
target={state.installTarget}
onTargetChange={controller.setInstallTarget}
onPreview={controller.showMarkdown}
onOpenFolder={controller.openSkillDirectory}
onOpenGithub={controller.openSkillGithub}
onStart={() => void controller.controlService('start')}
onCopyInstruction={() => void controller.copyInstallInstruction()}
onCopyVerification={() => void controller.copyVerificationPrompt()}
/>
<EndpointCatalog
activeEndpointId={state.endpointId}
onSelect={controller.selectEndpoint}
onCopy={(path) => controller.copyText(path, '接口路径已复制')}
/>
<ApiRequestTester
endpoint={controller.endpoint}
settings={state.settings}
service={state.service}
params={state.params}
body={state.body}
state={state.requestState}
error={state.error}
onParams={controller.updateParams}
onBody={controller.updateBody}
onSend={controller.runRequest}
onClear={clear}
onCopyCurl={(command) => controller.copyText(command, 'curl 命令已复制')}
/>
</div>
{state.rawMarkdown && (
<SkillPreviewDialog
content={state.rawMarkdown}
version={state.skill?.version}
onClose={controller.closeMarkdown}
/>
)}
</main>
<ApiRuntimePanel
service={state.service}
dbReady={dbReady}
response={state.response}
history={state.history}
onControl={controller.controlService}
onOpenSettings={onOpenSettings}
onCopy={controller.copyText}
/>
{state.toast && <div className="app-toast">{state.toast}</div>}
</div>
)
}
@@ -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<string, string>
body: string
state: 'idle' | 'loading' | 'success' | 'error'
error: string
onParams: (params: Record<string, string>) => void
onBody: (body: string) => void
onSend: () => void
onClear: () => void
onCopyCurl: (command: string) => Promise<void>
}
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 (
<section className="api-request-tester" id="api-request-tester">
<div className="api-section-heading">
<h2></h2>
<span></span>
</div>
<div className="api-request-meta">
<span className={`api-method ${endpoint.method.toLowerCase()}`}>{endpoint.method}</span>
<code>{url || endpoint.path}</code>
</div>
{endpoint.parameters?.length ? (
<div className="api-param-grid">
{endpoint.parameters.map((parameter) => (
<label key={parameter.key}>
<span>
{parameter.label}
{parameter.required && <b></b>}
</span>
<input
value={params[parameter.key] || ''}
onChange={(event) => update(parameter.key, event.target.value)}
placeholder={parameter.placeholder}
/>
</label>
))}
</div>
) : null}
{endpoint.body && (
<label className="api-json-input">
<span>JSON </span>
<textarea
value={body}
onChange={(event) => onBody(event.target.value)}
spellCheck={false}
/>
</label>
)}
<div className="api-tester-actions">
<button type="button" onClick={onClear}>
</button>
<button type="button" onClick={copyCurl}>
curl
</button>
<button
type="button"
className="api-primary-button"
disabled={!service?.running || state === 'loading'}
onClick={onSend}
>
{state === 'loading'
? '发送中…'
: service?.running
? '发送请求'
: '请先启动本地 API 服务'}
</button>
</div>
{error && <p className="api-inline-error">{error}</p>}
</section>
)
}
@@ -0,0 +1,151 @@
import type { ApiResponse, ApiServiceState, RequestHistoryItem } from '../model/types'
import { type ReactElement } from 'react'
import { isLoopbackHost } from '../utils/buildApiUrl'
import { formatJson, formatResponseSize, inferResponseCount } from '../utils/formatResponse'
interface Props {
service: ApiServiceState | null
dbReady: boolean
response: ApiResponse | null
history: RequestHistoryItem[]
onControl: (action: 'start' | 'stop' | 'restart') => void
onOpenSettings: () => void
onCopy: (text: string, message: string) => Promise<void>
}
export function ApiRuntimePanel({
service,
dbReady,
response,
history,
onControl,
onOpenSettings,
onCopy
}: Props): ReactElement {
const host = service?.host || '127.0.0.1'
const port = service?.port || 6131
const localOnly = isLoopbackHost(host)
const copyAddress = (): void => void onCopy(`http://${host}:${port}`, '监听地址已复制')
return (
<aside className="api-runtime-panel" id="api-runtime-panel">
<div className="api-runtime-scroll">
<section>
<div className="api-runtime-title">
<h2></h2>
<span className={service?.running ? 'ready' : 'stopped'}>
{service?.running ? '运行中' : '已停止'}
</span>
</div>
<dl>
<div>
<dt></dt>
<dd>
{host}:{port}
</dd>
</div>
<div>
<dt></dt>
<dd className={dbReady ? 'ready-text' : ''}>{dbReady ? '已连接' : '未连接'}</dd>
</div>
<div>
<dt></dt>
<dd></dd>
</div>
<div>
<dt>访</dt>
<dd className={localOnly ? 'ready-text' : 'warning-text'}>
{localOnly ? '仅本机访问' : '可能被局域网访问'}
</dd>
</div>
</dl>
{service?.error && <p className="api-inline-error">{service.error}</p>}
<div className="api-runtime-actions">
{service?.running ? (
<button type="button" onClick={() => onControl('stop')}>
</button>
) : (
<button
type="button"
className="api-primary-button"
onClick={() => onControl('start')}
>
</button>
)}
<button type="button" onClick={() => onControl('restart')}>
</button>
<button type="button" onClick={copyAddress}>
</button>
<button type="button" onClick={onOpenSettings}>
API
</button>
</div>
</section>
{!localOnly && (
<p className="api-security-warning">访 API </p>
)}
<section>
<h3></h3>
{response ? (
<>
<div className="api-response-summary">
<span>
{response.endpoint.method} {response.endpoint.path}
</span>
<b className={response.status < 300 ? 'ready-text' : 'warning-text'}>
{response.status}
</b>
</div>
<pre>{formatJson(response.text)}</pre>
<p className="api-response-meta">
{response.durationMs}ms · {formatResponseSize(response.responseSize)}
{inferResponseCount(response.data) !== null
? ` · ${inferResponseCount(response.data)}`
: ''}
</p>
<button
type="button"
onClick={() => void onCopy(formatJson(response.text), 'JSON 响应已复制')}
>
JSON
</button>
</>
) : (
<p className="api-empty-text"></p>
)}
</section>
<section id="api-request-history">
<h3></h3>
{history.length ? (
<ol className="api-history">
{history.map((item) => (
<li key={item.id}>
<span>
{item.method} {item.path}
</span>
<b className={item.success ? 'ready-text' : 'warning-text'}>{item.status}</b>
<small>
{new Date(item.timestamp).toLocaleTimeString()} · {item.durationMs}ms
</small>
</li>
))}
</ol>
) : (
<p className="api-empty-text"> 30 </p>
)}
</section>
<section className="api-privacy">
<h3></h3>
<p>
{localOnly
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
: '当前服务并非仅本机访问。请确认局域网环境可信,且注意 API 暂无鉴权。'}
</p>
</section>
</div>
</aside>
)
}
@@ -0,0 +1,53 @@
import { API_ENDPOINTS } from '../model/apiEndpoints'
import { type ReactElement } from 'react'
export function EndpointCatalog({
activeEndpointId,
onSelect,
onCopy
}: {
activeEndpointId: string
onSelect: (id: string) => void
onCopy: (path: string) => Promise<void>
}): ReactElement {
return (
<section className="api-endpoint-catalog">
<div className="api-section-heading">
<h2></h2>
<span></span>
</div>
<div className="api-endpoint-table">
<div className="api-endpoint-head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{API_ENDPOINTS.map((endpoint) => (
<div
className={`api-endpoint-row ${activeEndpointId === endpoint.id ? 'active' : ''}`}
key={endpoint.id}
>
<span className={`api-method ${endpoint.method.toLowerCase()}`}>{endpoint.method}</span>
<div>
<code>{endpoint.path}</code>
{endpoint.parameters?.some((item) => item.required) && <small></small>}
</div>
<span>
{endpoint.name}
<small>{endpoint.description}</small>
</span>
<span>
<button type="button" onClick={() => void onCopy(endpoint.path)}>
</button>
<button type="button" onClick={() => onSelect(endpoint.id)}>
</button>
</span>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,115 @@
import { type ReactElement } from 'react'
import type { ApiServiceState, SkillStatus } from '../model/types'
import type { AgentInstallTarget } from '../model/skillDistribution'
import { SkillDetails } from './SkillDetails'
import { SkillInstallFlow } from './SkillInstallFlow'
interface Props {
skill: SkillStatus | null
service: ApiServiceState | null
dbReady: boolean
target: AgentInstallTarget
onTargetChange: (target: AgentInstallTarget) => void
onPreview: () => void
onOpenFolder: () => void
onOpenGithub: () => void
onStart: () => void
onCopyInstruction: () => void
onCopyVerification: () => void
}
export function ReaderSkillOverview({
skill,
service,
dbReady,
target,
onTargetChange,
onPreview,
onOpenFolder,
onOpenGithub,
onStart,
onCopyInstruction,
onCopyVerification
}: Props): ReactElement {
return (
<>
<section className="api-skill-overview" id="api-reader-skill">
<div className="api-workspace-heading">
<div>
<div className="api-title-line">
<h1>WechatExplorer Reader</h1>
<span className={`api-skill-status ${skill?.available ? 'ready' : 'error'}`}>
{skill?.available ? '已安装' : '文件不可用'}
</span>
<span className="api-version">{skill?.version || 'v1.0'}</span>
</div>
<p> HTTP API WechatExplorer </p>
</div>
<div className="api-header-actions">
<button type="button" onClick={onPreview} disabled={!skill?.available}>
Skill
</button>
<button
type="button"
className="api-primary-button"
disabled={!service?.running || !skill?.available}
onClick={onCopyInstruction}
>
</button>
<details className="api-skill-more">
<summary></summary>
<button type="button" onClick={onOpenFolder} disabled={!skill?.available}>
</button>
<button type="button" onClick={onOpenGithub}>
GitHub
</button>
</details>
</div>
</div>
{!skill?.available && (
<p className="api-inline-error">{skill?.error || 'Skill 文件不可用'}</p>
)}
<div className="api-trust-bar">
<span>{skill?.available ? '已就绪' : '待修复'}</span>
<i /> <span> HTTP API</span>
<i /> <span> API </span>
</div>
<div className="api-introduction">
<h2></h2>
<p>
WechatExplorer Reader AI Agent
WechatExplorer API
</p>
<div className="api-flow">
<span>AI Agent</span>
<b></b>
<strong>Reader Skill</strong>
<b></b>
<span>Local HTTP API</span>
<b></b>
<span></span>
</div>
</div>
</section>
<SkillInstallFlow
service={service}
dbReady={dbReady}
skill={skill}
target={target}
onTargetChange={onTargetChange}
onStart={onStart}
onCopyInstruction={onCopyInstruction}
onCopyVerification={onCopyVerification}
/>
<SkillDetails
skill={skill}
onOpenFolder={onOpenFolder}
onPreview={onPreview}
onGithub={onOpenGithub}
/>
</>
)
}
@@ -0,0 +1,51 @@
import { type ReactElement } from 'react'
import type { SkillStatus } from '../model/types'
export function SkillDetails({
skill,
onOpenFolder,
onPreview,
onGithub
}: {
skill: SkillStatus | null
onOpenFolder: () => void
onPreview: () => void
onGithub: () => void
}): ReactElement {
return (
<details className="api-skill-details">
<summary>Skill </summary>
<dl>
<div>
<dt></dt>
<dd>wechatexplorer-reader</dd>
</div>
<div>
<dt></dt>
<dd>{skill?.version || '未知'}</dd>
</div>
<div>
<dt></dt>
<dd></dd>
</div>
<div>
<dt></dt>
<dd>
{skill?.available ? '可用' : `本地 Skill 文件不可用:${skill?.error || '无法读取文件'}`}
</dd>
</div>
</dl>
<div>
<button type="button" onClick={onOpenFolder} disabled={!skill?.available}>
</button>
<button type="button" onClick={onPreview} disabled={!skill?.available}>
SKILL.md
</button>
<button type="button" onClick={onGithub}>
GitHub
</button>
</div>
</details>
)
}
@@ -0,0 +1,94 @@
import { type ReactElement } from 'react'
import type { ApiServiceState, SkillStatus } from '../model/types'
import { type AgentInstallTarget } from '../model/skillDistribution'
import { SkillTargetSelector } from './SkillTargetSelector'
interface Props {
service: ApiServiceState | null
dbReady: boolean
skill: SkillStatus | null
target: AgentInstallTarget
onTargetChange: (target: AgentInstallTarget) => void
onStart: () => void
onCopyInstruction: () => void
onCopyVerification: () => void
}
const buttonLabel: Record<AgentInstallTarget, string> = {
codex: '复制给 Codex',
'claude-code': '复制给 Claude Code',
openclaw: '复制给 OpenClaw',
generic: '复制通用指令'
}
export function SkillInstallFlow({
service,
dbReady,
skill,
target,
onTargetChange,
onStart,
onCopyInstruction,
onCopyVerification
}: Props): ReactElement {
const ready = Boolean(service?.running && dbReady && skill?.available)
const address = `${service?.host || '127.0.0.1'}:${service?.port || 6131}`
return (
<section className="skill-install-flow">
<div className="api-section-heading">
<h2></h2>
<span></span>
</div>
<div className="skill-flow-steps">
<section className={service?.running && dbReady ? 'done' : 'active'}>
<b>1</b>
<div>
<h3> WechatExplorer </h3>
<p>
API{service?.running ? '运行中' : '已停止'} · {address}
</p>
<p>
{dbReady ? '已连接' : '未连接'} · Reader Skill
{skill?.available ? '可用' : '不可用'}
</p>
{!service?.running && (
<button type="button" className="api-primary-button" onClick={onStart}>
</button>
)}
{!skill?.available && (
<p className="api-inline-error">{skill?.error || '本地 Skill 文件不可用'}</p>
)}
</div>
</section>
<section className="active">
<b>2</b>
<div>
<h3> Agent</h3>
<SkillTargetSelector value={target} onChange={onTargetChange} />
</div>
</section>
<section className={ready ? 'active' : ''}>
<b>3</b>
<div>
<h3></h3>
<p> Agent health </p>
<div className="skill-flow-actions">
<button
type="button"
className="api-primary-button"
disabled={!ready}
onClick={onCopyInstruction}
>
{buttonLabel[target]}
</button>
<button type="button" disabled={!ready} onClick={onCopyVerification}>
</button>
</div>
</div>
</section>
</div>
</section>
)
}
@@ -0,0 +1,58 @@
import { useMemo, useState, type ReactElement } from 'react'
export function SkillPreviewDialog({
content,
version,
onClose
}: {
content: string
version?: string
onClose: () => void
}): ReactElement {
const [raw, setRaw] = useState(false)
const lines = useMemo(() => content.split('\n'), [content])
return (
<div
className="api-markdown-overlay"
role="dialog"
aria-modal="true"
aria-label="WechatExplorer Reader Skill 预览"
>
<div>
<header>
<div>
<strong>WechatExplorer Reader</strong>
<span>{version || 'v1.0'}</span>
</div>
<div>
<button type="button" onClick={() => setRaw((current) => !current)}>
{raw ? '渲染预览' : '原始文本'}
</button>
<button type="button" onClick={onClose}>
</button>
</div>
</header>
{raw ? (
<pre>{content}</pre>
) : (
<article className="skill-markdown-preview">
{lines.map((line, index) =>
line.startsWith('# ') ? (
<h1 key={index}>{line.slice(2)}</h1>
) : line.startsWith('## ') ? (
<h2 key={index}>{line.slice(3)}</h2>
) : line.startsWith('- ') ? (
<li key={index}>{line.slice(2)}</li>
) : line.startsWith('```') ? null : line ? (
<p key={index}>{line}</p>
) : (
<br key={index} />
)
)}
</article>
)}
</div>
</div>
)
}
@@ -0,0 +1,25 @@
import { type ReactElement } from 'react'
import { AGENT_INSTALL_TARGETS, type AgentInstallTarget } from '../model/skillDistribution'
export function SkillTargetSelector({
value,
onChange
}: {
value: AgentInstallTarget
onChange: (value: AgentInstallTarget) => void
}): ReactElement {
return (
<div className="skill-target-selector" role="group" aria-label="选择目标 Agent">
{AGENT_INSTALL_TARGETS.map((target) => (
<button
key={target.value}
type="button"
className={value === target.value ? 'active' : ''}
onClick={() => onChange(target.value)}
>
{target.label}
</button>
))}
</div>
)
}
@@ -0,0 +1,315 @@
import { useCallback, useEffect, useReducer } from 'react'
import type { Contact } from '../../../../../shared/types'
import { findEndpoint } from '../model/apiEndpoints'
import { REPORT_REQUEST_PRESET } from '../model/requestPresets'
import { type AgentInstallTarget, type SkillInstallSource } from '../model/skillDistribution'
import type {
ApiResponse,
ApiServiceState,
ApiSettings,
RequestHistoryItem,
SkillStatus
} from '../model/types'
import { sendLocalApiRequest } from '../services/localApiClient'
import {
buildSkillInstallInstruction,
buildSkillVerificationPrompt
} from '../utils/buildSkillInstallInstruction'
type RequestState = 'idle' | 'loading' | 'success' | 'error'
interface State {
settings: ApiSettings | null
service: ApiServiceState | null
skill: SkillStatus | null
endpointId: string
params: Record<string, string>
body: string
requestState: RequestState
response: ApiResponse | null
history: RequestHistoryItem[]
error: string
rawMarkdown: string | null
toast: string
installTarget: AgentInstallTarget
}
type Action =
| { type: 'loaded'; settings: ApiSettings; service: ApiServiceState; skill: SkillStatus }
| { type: 'endpoint'; endpointId: string; talker?: string }
| { type: 'params'; params: Record<string, string> }
| { type: 'body'; body: string }
| { type: 'requesting' }
| { type: 'response'; response: ApiResponse; history: RequestHistoryItem[] }
| { type: 'error'; error: string }
| { type: 'markdown'; content: string | null }
| { type: 'toast'; message: string }
| { type: 'installTarget'; target: AgentInstallTarget }
const initialState: State = {
settings: null,
service: null,
skill: null,
endpointId: 'health',
params: {},
body: REPORT_REQUEST_PRESET,
requestState: 'idle',
response: null,
history: [],
error: '',
rawMarkdown: null,
toast: '',
installTarget: 'codex'
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'loaded':
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
case 'endpoint':
return {
...state,
endpointId: action.endpointId,
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
body: action.endpointId === 'report' ? state.body || REPORT_REQUEST_PRESET : state.body,
error: ''
}
case 'params':
return { ...state, params: action.params }
case 'body':
return { ...state, body: action.body }
case 'requesting':
return { ...state, requestState: 'loading', error: '' }
case 'response':
return {
...state,
requestState: 'success',
response: action.response,
history: action.history
}
case 'error':
return { ...state, requestState: 'error', error: action.error }
case 'markdown':
return { ...state, rawMarkdown: action.content }
case 'toast':
return { ...state, toast: action.message }
case 'installTarget':
return { ...state, installTarget: action.target }
}
}
export function useApiCenterController(selectedContact: Contact | null): {
state: State
endpoint: ReturnType<typeof findEndpoint>
refresh: () => Promise<void>
selectEndpoint: (endpointId: string) => void
updateParams: (params: Record<string, string>) => void
updateBody: (body: string) => void
runRequest: () => Promise<void>
controlService: (action: 'start' | 'stop' | 'restart') => Promise<void>
showMarkdown: () => Promise<void>
reportError: (error: string) => void
showToast: (message: string) => void
copyText: (text: string, successMessage: string) => Promise<void>
setInstallTarget: (target: AgentInstallTarget) => void
copyInstallInstruction: () => Promise<void>
copyVerificationPrompt: () => Promise<void>
openSkillDirectory: () => Promise<void>
openSkillGithub: () => Promise<void>
closeMarkdown: () => void
} {
const [state, dispatch] = useReducer(reducer, initialState)
const refresh = useCallback(async (): Promise<void> => {
try {
const [{ settings }, service, skill] = await Promise.all([
window.api.getSettings(),
window.api.apiStatus(),
window.api.getReaderSkillStatus()
])
dispatch({ type: 'loaded', settings, service, skill })
} catch (error) {
dispatch({
type: 'error',
error: error instanceof Error ? error.message : '无法读取 API 服务状态'
})
}
}, [])
useEffect(() => {
void refresh()
const timer = window.setInterval(() => void refresh(), 4000)
return () => window.clearInterval(timer)
}, [refresh])
const selectEndpoint = useCallback(
(endpointId: string): void => {
const defaults: Record<string, string> = endpointId === 'recent-chat' ? { limit: '50' } : {}
dispatch({ type: 'endpoint', endpointId, talker: selectedContact?.m_nsUsrName })
if (endpointId !== 'chatlog') dispatch({ type: 'params', params: defaults })
window.setTimeout(
() =>
document
.getElementById('api-request-tester')
?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
0
)
},
[selectedContact]
)
const updateParams = useCallback(
(params: Record<string, string>) => dispatch({ type: 'params', params }),
[]
)
const updateBody = useCallback((body: string) => dispatch({ type: 'body', body }), [])
const runRequest = useCallback(async (): Promise<void> => {
const endpoint = findEndpoint(state.endpointId)
if (!state.service?.running || !state.settings) {
dispatch({ type: 'error', error: '请先启动本地 API 服务' })
return
}
const required = endpoint.parameters?.find(
(parameter) => parameter.required && !state.params[parameter.key]?.trim()
)
if (required) {
dispatch({ type: 'error', error: `请填写必要参数:${required.label}` })
return
}
if (endpoint.body) {
try {
JSON.parse(state.body)
} catch {
dispatch({ type: 'error', error: '请求体不是有效 JSON,未发送请求。' })
return
}
}
dispatch({ type: 'requesting' })
try {
const response = await sendLocalApiRequest(endpoint, state.params, state.body)
if ('error' in response) {
dispatch({ type: 'error', error: response.error })
return
}
const item: RequestHistoryItem = {
id: `${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
method: endpoint.method,
path: endpoint.path,
status: response.status,
durationMs: response.durationMs,
responseSize: response.responseSize,
success: response.status >= 200 && response.status < 300
}
dispatch({ type: 'response', response, history: [item, ...state.history].slice(0, 30) })
} catch (error) {
dispatch({ type: 'error', error: error instanceof Error ? error.message : '请求失败' })
}
}, [
state.body,
state.endpointId,
state.history,
state.params,
state.service?.running,
state.settings
])
const controlService = useCallback(
async (action: 'start' | 'stop' | 'restart'): Promise<void> => {
try {
const settings = state.settings
if (!settings) return
if (action === 'stop') await window.api.apiToggle(false)
if (action === 'start') await window.api.apiToggle(true)
if (action === 'restart') {
await window.api.apiStop()
await window.api.apiStart(settings.apiHost, settings.apiPort)
}
await refresh()
} catch (error) {
dispatch({ type: 'error', error: error instanceof Error ? error.message : '服务操作失败' })
}
},
[refresh, state.settings]
)
const showMarkdown = useCallback(async (): Promise<void> => {
const result = await window.api.readReaderSkill()
if (result.success && result.content) dispatch({ type: 'markdown', content: result.content })
else dispatch({ type: 'error', error: result.error || '无法读取 Skill 文件' })
}, [])
const reportError = useCallback((error: string) => dispatch({ type: 'error', error }), [])
const showToast = useCallback((message: string): void => {
dispatch({ type: 'toast', message })
window.setTimeout(() => dispatch({ type: 'toast', message: '' }), 2000)
}, [])
const copyText = useCallback(
async (text: string, successMessage: string): Promise<void> => {
const result = await window.api.copyText(text)
showToast(result.success ? successMessage : '复制失败,请重试')
},
[showToast]
)
const setInstallTarget = useCallback(
(target: AgentInstallTarget): void => dispatch({ type: 'installTarget', target }),
[]
)
const copyInstallInstruction = useCallback(async (): Promise<void> => {
const skill = state.skill
const service = state.service
if (!skill?.available || !skill.directoryPath || !service?.running)
return showToast('请先确认本地 API 与 Skill 均可用')
const source: SkillInstallSource = {
type: 'local',
directoryPath: skill.directoryPath,
skillPath: skill.filePath || '',
version: skill.version || 'v1.0'
}
const instruction = buildSkillInstallInstruction({
target: state.installTarget,
source,
apiBaseUrl: { host: service.host, port: service.port }
})
const labels: Record<AgentInstallTarget, string> = {
codex: 'Codex 安装指令已复制',
'claude-code': 'Claude Code 安装指令已复制',
openclaw: 'OpenClaw 安装指令已复制',
generic: '通用安装指令已复制'
}
await copyText(instruction, labels[state.installTarget])
}, [copyText, showToast, state.installTarget, state.service, state.skill])
const copyVerificationPrompt = useCallback(async (): Promise<void> => {
await copyText(buildSkillVerificationPrompt(), '测试问题已复制')
}, [copyText])
const openSkillDirectory = useCallback(async (): Promise<void> => {
const result = await window.api.revealReaderSkill()
showToast(result.success ? '已打开 Skill 文件夹' : result.error || '打开 Skill 文件夹失败')
}, [showToast])
const openSkillGithub = useCallback(async (): Promise<void> => {
const result = await window.api.openReaderSkillGithub()
showToast(result.success ? '已在浏览器中打开 GitHub' : '无法打开浏览器,请复制链接后手动访问')
}, [showToast])
return {
state,
endpoint: findEndpoint(state.endpointId),
refresh,
selectEndpoint,
updateParams,
updateBody,
runRequest,
controlService,
showMarkdown,
reportError,
showToast,
copyText,
setInstallTarget,
copyInstallInstruction,
copyVerificationPrompt,
openSkillDirectory,
openSkillGithub,
closeMarkdown: () => dispatch({ type: 'markdown', content: null })
}
}
@@ -0,0 +1,72 @@
import type { ApiEndpoint } from './types'
import { LOCAL_API_ENDPOINTS } from '../../../../../shared/local-api-test'
function endpoint(
id: ApiEndpoint['id'],
detail: Omit<ApiEndpoint, 'id' | 'method' | 'path'>
): ApiEndpoint {
const { method, path } = LOCAL_API_ENDPOINTS[id]
return { id, method, path, ...detail }
}
export const API_ENDPOINTS: ApiEndpoint[] = [
endpoint('health', {
name: '服务健康检查',
description: '检查本地服务和数据库初始化状态。'
}),
endpoint('current-time', {
name: '获取本地时间',
description: '获取本机时区和当前时间。'
}),
endpoint('contact', {
name: '联系人和群聊',
description: '按昵称或类型查找联系人与群聊。',
parameters: [
{ key: 'filter', label: '筛选关键词', placeholder: '昵称或备注' },
{ key: 'type', label: '类型', placeholder: 'user 或 group' }
]
}),
endpoint('chatroom', {
name: '群聊列表',
description: '按关键词获取群聊列表。',
parameters: [{ key: 'keyword', label: '关键词', placeholder: '群昵称' }]
}),
endpoint('recent-chat', {
name: '最近会话',
description: '获取最近活跃会话。',
parameters: [{ key: 'limit', label: '数量', placeholder: '50' }]
}),
endpoint('chatlog', {
name: '聊天记录',
description: '读取指定会话在指定时间范围内的消息。',
parameters: [
{ key: 'talker', label: '会话标识', required: true, placeholder: '群昵称、wxid 或 md5' },
{ key: 'time', label: '时间范围', placeholder: '2026-07-13 或 2026-07-01~2026-07-13' },
{ key: 'startTime', label: '开始时间', placeholder: 'Unix 秒级时间戳' },
{ key: 'endTime', label: '结束时间', placeholder: 'Unix 秒级时间戳' }
]
}),
endpoint('group-snapshot', {
name: '群成员快照',
description: '获取群成员信息快照。',
parameters: [{ key: 'md5', label: '群聊 md5', required: true, placeholder: '群聊 md5' }]
}),
endpoint('resolve', {
name: '标识解析',
description: '将昵称、wxid 或 md5 解析为会话信息。',
parameters: [
{ key: 'q', label: '待解析标识', required: true, placeholder: '昵称、wxid 或 md5' }
]
}),
endpoint('report', {
name: '群聊日报导出',
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
body: true
})
]
export const DEFAULT_ENDPOINT = API_ENDPOINTS[0]
export function findEndpoint(id: string): ApiEndpoint {
return API_ENDPOINTS.find((endpoint) => endpoint.id === id) || DEFAULT_ENDPOINT
}
@@ -0,0 +1,31 @@
export const REPORT_REQUEST_PRESET = JSON.stringify(
{
report: {
overview: '请填入群聊日报概览',
topics: [],
resources: [],
importantMessages: [],
quotes: [],
qa: [],
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [] },
keywords: []
},
metadata: {
groupName: '技术交流',
reportDate: '2026-07-13',
dateRange: '2026-07-13 全天',
messageCount: 0,
activeUsers: 0,
timeSpan: '00:00-23:59',
generatedAt: '2026-07-13 22:00',
recordNote: '本日报由 WechatExplorer 自动生成',
footerNote: '',
heroParticipants: [],
avatars: {},
talker: '技术交流',
timeRange: '2026-07-13'
}
},
null,
2
)
@@ -0,0 +1,17 @@
export type AgentInstallTarget = 'codex' | 'claude-code' | 'openclaw' | 'generic'
export type SkillInstallSource =
| { type: 'local'; directoryPath: string; skillPath: string; version: string }
| { type: 'remote'; installUrl: string; version: string }
export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }[] = [
{ value: 'codex', label: 'Codex' },
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'openclaw', label: 'OpenClaw' },
{ value: 'generic', label: '其他 Agent' }
]
export const githubLatestUrl =
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
export const githubVersionedInstallUrl: string | null = null
@@ -0,0 +1,62 @@
export type ApiMethod = 'GET' | 'POST'
export interface ApiParameter {
key: string
label: string
required?: boolean
placeholder?: string
}
export interface ApiEndpoint {
id: LocalApiEndpointId
method: ApiMethod
path: string
name: string
description: string
parameters?: ApiParameter[]
body?: boolean
}
export interface ApiServiceState {
running: boolean
host: string
port: number
error?: string
}
export interface ApiSettings {
apiEnabled: boolean
apiHost: string
apiPort: number
}
export interface ApiResponse {
endpoint: ApiEndpoint
status: number
durationMs: number
responseSize: number
text: string
data: unknown
}
export interface RequestHistoryItem {
id: string
timestamp: number
method: ApiMethod
path: string
status: number
durationMs: number
responseSize: number
success: boolean
}
export interface SkillStatus {
available: boolean
version?: string
filePath?: string
directoryPath?: string
source: 'development' | 'bundled'
githubUrl: string
error?: string
}
import type { LocalApiEndpointId } from '../../../../../shared/local-api-test'
@@ -0,0 +1,24 @@
import type { ApiEndpoint, ApiResponse } from '../model/types'
export async function sendLocalApiRequest(
endpoint: ApiEndpoint,
params: Record<string, string>,
body: string
): Promise<ApiResponse | { error: string }> {
const response = await window.api.testLocalApiRequest({
endpointId: endpoint.id,
query: params,
body
})
if (!response.ok || typeof response.status !== 'number')
return { error: response.error || '本地 API 请求失败' }
const text = response.bodyText || JSON.stringify(response.json ?? {}, null, 2)
return {
endpoint,
status: response.status,
durationMs: response.durationMs,
responseSize: response.responseSize,
text,
data: response.json ?? response.bodyText
}
}
@@ -0,0 +1,16 @@
export function buildApiUrl(
host: string,
port: number,
path: string,
params: Record<string, string>
): string {
const url = new URL(path, `http://${host}:${port}`)
Object.entries(params).forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
return url.toString()
}
export function isLoopbackHost(host: string): boolean {
return ['127.0.0.1', 'localhost', '::1'].includes(host.trim().toLowerCase())
}
@@ -0,0 +1,41 @@
import type { AgentInstallTarget, SkillInstallSource } from '../model/skillDistribution'
function requestHost(host: string): string {
return host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host
}
function opening(target: AgentInstallTarget): string {
switch (target) {
case 'codex':
return '请将本地目录中的 WechatExplorer Reader Skill 安装到当前 Codex 项目或用户 Skill 目录:'
case 'claude-code':
return '请安装以下本地 WechatExplorer Reader Skill,并按照 SKILL.md 调用本地 HTTP API'
case 'openclaw':
return '请将以下本地目录作为 WechatExplorer Reader Skill 安装,并阅读其中的 SKILL.md'
default:
return '请读取并安装以下 WechatExplorer Reader Skill'
}
}
export function buildSkillInstallInstruction({
target,
source,
apiBaseUrl
}: {
target: AgentInstallTarget
source: SkillInstallSource
apiBaseUrl: { host: string; port: number }
}): string {
const host = requestHost(apiBaseUrl.host)
const hostPart = host.includes(':') ? `[${host}]` : host
const healthUrl = `http://${hostPart}:${apiBaseUrl.port}/api/v1/health`
const sourceText =
source.type === 'local'
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
: `请从以下地址安装 WechatExplorer Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
return `${sourceText}\n\n${healthUrl}\n\n验证 WechatExplorer 本地 API 是否已就绪。安装完成后告诉我验证结果。`
}
export function buildSkillVerificationPrompt(): string {
return '请检查 WechatExplorer Reader 是否已连接,然后列出最近 5 个微信会话。'
}
@@ -0,0 +1,19 @@
export function formatResponseSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function formatJson(text: string): string {
try {
return JSON.stringify(JSON.parse(text), null, 2)
} catch {
return text
}
}
export function inferResponseCount(data: unknown): number | null {
if (!data || typeof data !== 'object') return null
const value = data as Record<string, unknown>
return typeof value.count === 'number' ? value.count : null
}
+40
View File
@@ -0,0 +1,40 @@
export const LOCAL_API_ENDPOINTS = {
health: { method: 'GET', path: '/api/v1/health', queryKeys: [] },
'current-time': { method: 'GET', path: '/api/v1/current_time', queryKeys: [] },
contact: { method: 'GET', path: '/api/v1/contact', queryKeys: ['filter', 'type'] },
chatroom: { method: 'GET', path: '/api/v1/chatroom', queryKeys: ['keyword'] },
'recent-chat': { method: 'GET', path: '/api/v1/recent_chat', queryKeys: ['limit'] },
chatlog: {
method: 'GET',
path: '/api/v1/chatlog',
queryKeys: ['talker', 'time', 'startTime', 'endTime']
},
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] }
} as const
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS
export type LocalApiMethod = (typeof LOCAL_API_ENDPOINTS)[LocalApiEndpointId]['method']
export interface LocalApiTestRequest {
endpointId: LocalApiEndpointId
query?: Record<string, string>
body?: string
}
export interface LocalApiTestResponse {
ok: boolean
method: LocalApiMethod
path: string
url: string
status?: number
statusText?: string
durationMs: number
responseSize: number
contentType?: string
json?: unknown
bodyText?: string
errorCode?: 'API_NOT_RUNNING' | 'CONNECTION_REFUSED' | 'TIMEOUT' | 'INVALID_REQUEST' | 'UNKNOWN'
error?: string
}