mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 完成本地 API 中心与 Reader Skill 流程
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user