mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完成本地 API 中心与 Reader Skill 流程
This commit is contained in:
@@ -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 支持情况调整)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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 鎺у埗)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -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 }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user