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
+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) }
}
}
}