mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 为本地 HTTP API 增加 Token 鉴权与安全加固
- 使用 Electron safeStorage 加密存储并自动初始化 API Token - 为 health 以外的接口增加 Bearer Token 鉴权 - 限制 CORS 仅允许可信本地 Origin - 增加鉴权、Token rotation、safeStorage 和手动验收测试
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import crypto from 'crypto'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ApiTokenActionResult,
|
||||
ApiTokenRevealResult,
|
||||
ApiTokenStatus
|
||||
} from '../shared/local-api-auth'
|
||||
|
||||
const MASKED_TOKEN = '••••••••••••••••'
|
||||
const TOKEN_BYTES = 32
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
interface TokenReadResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ApiTokenStore {
|
||||
private cachedToken: string | null = null
|
||||
|
||||
constructor(private readonly filePathOverride?: string) {}
|
||||
|
||||
private get filePath(): string {
|
||||
return this.filePathOverride || path.join(app.getPath('userData'), 'local-api-token.bin')
|
||||
}
|
||||
|
||||
getStatus(): ApiTokenStatus {
|
||||
const available = safeStorage.isEncryptionAvailable()
|
||||
if (!available) {
|
||||
return {
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
const result = this.read()
|
||||
return {
|
||||
available: true,
|
||||
hasToken: result.success && Boolean(result.token),
|
||||
maskedToken: MASKED_TOKEN,
|
||||
...(result.success ? {} : { error: result.error })
|
||||
}
|
||||
}
|
||||
|
||||
ensureToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success) return this.actionError(current.error)
|
||||
if (current.token) return this.actionSuccess()
|
||||
return this.persist(this.generateToken())
|
||||
}
|
||||
|
||||
revealToken(): ApiTokenRevealResult {
|
||||
const ensured = this.ensureToken()
|
||||
if (!ensured.success) return ensured
|
||||
return { ...ensured, token: this.cachedToken || undefined }
|
||||
}
|
||||
|
||||
rotateToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
|
||||
return this.persist(this.generateToken())
|
||||
}
|
||||
|
||||
getTokenForAuthentication(): string | null {
|
||||
if (this.cachedToken) return this.cachedToken
|
||||
const result = this.read()
|
||||
return result.success ? result.token || null : null
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomBytes(TOKEN_BYTES).toString('base64url')
|
||||
}
|
||||
|
||||
private read(): TokenReadResult {
|
||||
if (this.cachedToken) return { success: true, token: this.cachedToken }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
if (!fs.existsSync(this.filePath)) return { success: true }
|
||||
try {
|
||||
const token = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
if (!TOKEN_PATTERN.test(token)) throw new Error('invalid token data')
|
||||
this.cachedToken = token
|
||||
return { success: true, token }
|
||||
} catch {
|
||||
return { success: false, error: '已保存的 API Token 无法从系统安全存储读取' }
|
||||
}
|
||||
}
|
||||
|
||||
private persist(token: string): ApiTokenActionResult {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(token), { mode: 0o600 })
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
this.cachedToken = token
|
||||
return this.actionSuccess()
|
||||
} catch {
|
||||
return this.actionError('API Token 无法保存到系统安全存储')
|
||||
}
|
||||
}
|
||||
|
||||
private actionSuccess(): ApiTokenActionResult {
|
||||
return {
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: MASKED_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
private actionError(error?: string): ApiTokenActionResult {
|
||||
return {
|
||||
success: false,
|
||||
available: safeStorage.isEncryptionAvailable(),
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: error || 'API Token 安全存储不可用'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiTokenStore = new ApiTokenStore()
|
||||
+69
-8
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto'
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
@@ -12,6 +13,7 @@ import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -29,6 +31,10 @@ interface RouteContext {
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
@@ -36,12 +42,50 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function isAllowedCorsOrigin(origin: string): boolean {
|
||||
if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
if (parsed.protocol !== 'http:') return false
|
||||
if (parsed.username || parsed.password) return false
|
||||
return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const origin = req.headers.origin
|
||||
if (!origin) return true
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
|
||||
function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean {
|
||||
const header = req.headers.authorization
|
||||
const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null
|
||||
if (!match || !expectedToken) return false
|
||||
const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest()
|
||||
const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest()
|
||||
return crypto.timingSafeEqual(actualDigest, expectedDigest)
|
||||
}
|
||||
|
||||
function sendUnauthorized(res: ServerResponse): void {
|
||||
sendJson(res, 401, {
|
||||
error: 'unauthorized',
|
||||
message: 'Valid API token required'
|
||||
})
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
@@ -301,24 +345,28 @@ const routes: Record<string, RouteHandler> = {
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
port: number = DEFAULT_HTTP_PORT,
|
||||
options: HttpServerOptions = {}
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (!applyCorsHeaders(req, res)) {
|
||||
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
})
|
||||
res.writeHead(204)
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
||||
return sendUnauthorized(res)
|
||||
}
|
||||
let body: string | undefined
|
||||
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
body = await readBody(req)
|
||||
@@ -391,11 +439,24 @@ export const apiServer = {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
const token = apiTokenStore.ensureToken()
|
||||
if (!token.success) {
|
||||
singletonState = {
|
||||
running: false,
|
||||
host,
|
||||
port,
|
||||
error: token.error || 'API Token 安全存储不可用'
|
||||
}
|
||||
return { ...singletonState }
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
singleton = await startHttpServer(host, port)
|
||||
singleton = await startHttpServer(host, port, {
|
||||
tokenProvider: () => apiTokenStore.getTokenForAuthentication()
|
||||
})
|
||||
singletonState = {
|
||||
running: true,
|
||||
host: singleton.host,
|
||||
|
||||
+40
-4
@@ -43,6 +43,7 @@ import type {
|
||||
LegacyAIConfig
|
||||
} from '../shared/ai-provider'
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { AIProviderService } from './services/ai-provider-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
@@ -58,7 +59,7 @@ 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 { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
import {
|
||||
inspectImageDecryptionStatus,
|
||||
@@ -321,7 +322,10 @@ function getLocalMediaMimeType(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageResponse(image: DecodedImage, includeData = false): {
|
||||
function buildImageResponse(
|
||||
image: DecodedImage,
|
||||
includeData = false
|
||||
): {
|
||||
success: true
|
||||
data: string
|
||||
isThumb: boolean
|
||||
@@ -443,8 +447,8 @@ app.whenReady().then(async () => {
|
||||
app.getPath('userData'),
|
||||
join(__dirname, 'knowledgeWorker.js')
|
||||
)
|
||||
knowledgeSearchService.setVoiceTranscriptResolver((reference) =>
|
||||
voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' }
|
||||
knowledgeSearchService.setVoiceTranscriptResolver(
|
||||
(reference) => voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' }
|
||||
)
|
||||
voiceRecognition.onTranscriptUpdate((update) =>
|
||||
knowledgeSearchService?.indexVoiceTranscript(update)
|
||||
@@ -1441,6 +1445,25 @@ app.whenReady().then(async () => {
|
||||
|
||||
ipcMain.handle('api:getStatus', () => apiServer.getState())
|
||||
|
||||
ipcMain.handle('api:tokenStatus', () => apiTokenStore.ensureToken())
|
||||
ipcMain.handle('api:revealToken', () => apiTokenStore.revealToken())
|
||||
ipcMain.handle('api:copyToken', () => {
|
||||
const result = apiTokenStore.revealToken()
|
||||
if (!result.token) return { ...result, success: false }
|
||||
try {
|
||||
clipboard.writeText(result.token)
|
||||
return {
|
||||
success: true,
|
||||
available: result.available,
|
||||
hasToken: result.hasToken,
|
||||
maskedToken: result.maskedToken
|
||||
}
|
||||
} catch {
|
||||
return { ...apiTokenStore.getStatus(), success: false, error: 'API Token 复制失败' }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('api:rotateToken', () => apiTokenStore.rotateToken())
|
||||
|
||||
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
|
||||
const settings = loadSettings()
|
||||
const target = {
|
||||
@@ -1466,6 +1489,16 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('api:revealSkill', () => skillResourceService.reveal())
|
||||
ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub())
|
||||
ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request))
|
||||
ipcMain.handle('api:copyCurl', (_, request) => {
|
||||
const result = buildLocalApiCurlCommand(request)
|
||||
if (!result.success || !result.command) return { success: false, error: result.error }
|
||||
try {
|
||||
clipboard.writeText(result.command)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: 'curl 命令复制失败' }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('api:copyText', (_, text: unknown) => {
|
||||
if (typeof text !== 'string' || text.length > 1024 * 1024) {
|
||||
return { success: false, error: '复制内容无效或过大' }
|
||||
@@ -1499,6 +1532,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||
const settings = loadSettings()
|
||||
// v2.1.8 and earlier did not have an API token. Generate it once during
|
||||
// upgrade/startup without changing any existing API or database settings.
|
||||
apiTokenStore.ensureToken()
|
||||
if (settings.apiEnabled) {
|
||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from 'http'
|
||||
import { apiServer } from '../http-server'
|
||||
import { apiTokenStore } from '../api-token-store'
|
||||
import {
|
||||
LOCAL_API_ENDPOINTS,
|
||||
type LocalApiEndpointId,
|
||||
@@ -45,6 +46,47 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo
|
||||
return { bodyText }
|
||||
}
|
||||
|
||||
export function buildLocalApiCurlCommand(payload: unknown): {
|
||||
success: boolean
|
||||
command?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!payload || typeof payload !== 'object') return { success: false, error: '请求格式无效' }
|
||||
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
|
||||
if (!isEndpointId(endpointId)) return { success: false, error: '不允许访问该 API 端点' }
|
||||
if (!query || typeof query !== 'object' || Array.isArray(query))
|
||||
return { success: false, error: '查询参数格式无效' }
|
||||
if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE)
|
||||
return { success: false, error: '请求体格式无效' }
|
||||
|
||||
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 { success: false, error: '查询参数不属于当前端点' }
|
||||
}
|
||||
const service = apiServer.getState()
|
||||
const targetHost = requestHost(service.host)
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
|
||||
if (endpointId !== 'health' && !token) {
|
||||
return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' }
|
||||
}
|
||||
const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : ''
|
||||
const command =
|
||||
endpoint.method === 'POST'
|
||||
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
|
||||
: `curl '${url.toString()}'${authHeader}`
|
||||
return { success: true, command }
|
||||
}
|
||||
|
||||
export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTestResponse> {
|
||||
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
|
||||
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
|
||||
@@ -94,11 +136,27 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
|
||||
if (endpointId !== 'health' && !token) {
|
||||
return finish({
|
||||
ok: false,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
url: url.toString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: 0,
|
||||
errorCode: 'TOKEN_UNAVAILABLE',
|
||||
error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态'
|
||||
})
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const request = http.request(
|
||||
url,
|
||||
{
|
||||
method: endpoint.method,
|
||||
headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined
|
||||
headers
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
@@ -48,7 +48,7 @@ function getStatus(): SkillResourceStatus {
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
version: 'v1.0',
|
||||
version: 'v1.1',
|
||||
filePath,
|
||||
directoryPath,
|
||||
source,
|
||||
|
||||
Vendored
+7
@@ -445,6 +445,10 @@ declare global {
|
||||
port: number
|
||||
error?: string
|
||||
}>
|
||||
apiTokenStatus: () => Promise<import('../shared/local-api-auth').ApiTokenStatus>
|
||||
revealApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenRevealResult>
|
||||
copyApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
|
||||
rotateApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
|
||||
apiStart: (
|
||||
host?: string,
|
||||
port?: number
|
||||
@@ -469,6 +473,9 @@ declare global {
|
||||
revealReaderSkill: () => Promise<{ success: boolean; error?: string }>
|
||||
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
||||
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
||||
copyLocalApiCurl: (
|
||||
request: LocalApiTestRequest
|
||||
) => Promise<import('../shared/local-api-test').LocalApiCurlCopyResult>
|
||||
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
|
||||
@@ -253,6 +253,10 @@ const api = {
|
||||
disconnectDb: (options?: { closeNative?: boolean }) =>
|
||||
ipcRenderer.invoke('db:disconnect', options),
|
||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||
apiTokenStatus: () => ipcRenderer.invoke('api:tokenStatus'),
|
||||
revealApiToken: () => ipcRenderer.invoke('api:revealToken'),
|
||||
copyApiToken: () => ipcRenderer.invoke('api:copyToken'),
|
||||
rotateApiToken: () => ipcRenderer.invoke('api:rotateToken'),
|
||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled),
|
||||
@@ -261,6 +265,7 @@ const api = {
|
||||
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
||||
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
||||
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
|
||||
copyLocalApiCurl: (request) => ipcRenderer.invoke('api:copyCurl', request),
|
||||
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text),
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
|
||||
@@ -73,9 +73,12 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [settingsPath, setSettingsPath] = useState('')
|
||||
const [apiState, setApiState] = useState<ApiState | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<
|
||||
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
|
||||
>({ kind: 'idle', message: '' })
|
||||
const [testStatus, setTestStatus] = useState<{
|
||||
kind: 'idle' | 'ok' | 'fail'
|
||||
message: string
|
||||
wxid?: string
|
||||
accountRoot?: string
|
||||
}>({ kind: 'idle', message: '' })
|
||||
const [reopenStatus, setReopenStatus] = useState<string>('')
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState<{
|
||||
kind: 'idle' | 'ok' | 'fail'
|
||||
@@ -135,7 +138,10 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
|
||||
setTestStatus({
|
||||
kind: 'fail',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -226,7 +232,11 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
<div className="settings-self">
|
||||
<div className="settings-self-avatar">
|
||||
{selfInfo.avatar ? (
|
||||
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
||||
<img
|
||||
src={selfInfo.avatar}
|
||||
alt={selfInfo.nickname}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
|
||||
)}
|
||||
@@ -334,7 +344,8 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-hint">
|
||||
目录默认使用数据库根目录,用于查找图片模板文件。Windows 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。
|
||||
目录默认使用数据库根目录,用于查找图片模板文件。Windows
|
||||
会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -346,24 +357,23 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={settings?.dbRoot ?? ''}
|
||||
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
|
||||
placeholder={dbRootPlaceholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<button
|
||||
className="settings-btn"
|
||||
onClick={handleReopen}
|
||||
disabled={busy || !dbReady}
|
||||
>
|
||||
<button className="settings-btn" onClick={handleReopen} disabled={busy || !dbReady}>
|
||||
应用并重新初始化
|
||||
</button>
|
||||
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。
|
||||
可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS
|
||||
通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -412,8 +422,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地
|
||||
localStorage 保存方式。
|
||||
所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地 localStorage 保存方式。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -442,7 +451,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
type="text"
|
||||
className="settings-input settings-input-half"
|
||||
value={settings?.apiHost ?? ''}
|
||||
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, apiHost: e.target.value } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ apiHost: e.target.value })}
|
||||
placeholder="host"
|
||||
spellCheck={false}
|
||||
@@ -452,7 +463,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
className="settings-input settings-input-quarter"
|
||||
value={settings?.apiPort ?? 6131}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
|
||||
setSettings(
|
||||
settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null
|
||||
)
|
||||
}
|
||||
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
|
||||
placeholder="port"
|
||||
@@ -462,7 +475,8 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
API 仅本机访问,无鉴权。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||
API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center
|
||||
中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||
<br />
|
||||
配置文档:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props
|
||||
onBody={controller.updateBody}
|
||||
onSend={controller.runRequest}
|
||||
onClear={clear}
|
||||
onCopyCurl={(command) => controller.copyText(command, 'curl 命令已复制')}
|
||||
onCopyCurl={controller.copyCurl}
|
||||
/>
|
||||
</div>
|
||||
{state.rawMarkdown && (
|
||||
@@ -67,12 +67,18 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props
|
||||
</main>
|
||||
<ApiRuntimePanel
|
||||
service={state.service}
|
||||
tokenStatus={state.tokenStatus}
|
||||
revealedToken={state.revealedToken}
|
||||
dbReady={dbReady}
|
||||
response={state.response}
|
||||
history={state.history}
|
||||
onControl={controller.controlService}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onCopy={controller.copyText}
|
||||
onRevealToken={controller.revealToken}
|
||||
onHideToken={controller.hideToken}
|
||||
onCopyToken={controller.copyToken}
|
||||
onRotateToken={controller.rotateToken}
|
||||
/>
|
||||
{state.toast && <div className="app-toast">{state.toast}</div>}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
onBody: (body: string) => void
|
||||
onSend: () => void
|
||||
onClear: () => void
|
||||
onCopyCurl: (command: string) => Promise<void>
|
||||
onCopyCurl: () => Promise<void>
|
||||
}
|
||||
|
||||
export function ApiRequestTester({
|
||||
@@ -32,13 +32,6 @@ export function ApiRequestTester({
|
||||
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 })
|
||||
const selectTestImage = async (): Promise<void> => {
|
||||
const result = await window.api.selectAgentHubTestImage()
|
||||
@@ -100,7 +93,7 @@ export function ApiRequestTester({
|
||||
<button type="button" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
<button type="button" onClick={copyCurl}>
|
||||
<button type="button" onClick={() => void onCopyCurl()}>
|
||||
复制 curl
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import type { ApiResponse, ApiServiceState, RequestHistoryItem } from '../model/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
ApiServiceState,
|
||||
ApiTokenStatus,
|
||||
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
|
||||
tokenStatus: ApiTokenStatus | null
|
||||
revealedToken: string
|
||||
dbReady: boolean
|
||||
response: ApiResponse | null
|
||||
history: RequestHistoryItem[]
|
||||
onControl: (action: 'start' | 'stop' | 'restart') => void
|
||||
onOpenSettings: () => void
|
||||
onCopy: (text: string, message: string) => Promise<void>
|
||||
onRevealToken: () => Promise<void>
|
||||
onHideToken: () => void
|
||||
onCopyToken: () => Promise<void>
|
||||
onRotateToken: () => Promise<void>
|
||||
}
|
||||
|
||||
export function ApiRuntimePanel({
|
||||
service,
|
||||
tokenStatus,
|
||||
revealedToken,
|
||||
dbReady,
|
||||
response,
|
||||
history,
|
||||
onControl,
|
||||
onOpenSettings,
|
||||
onCopy
|
||||
onCopy,
|
||||
onRevealToken,
|
||||
onHideToken,
|
||||
onCopyToken,
|
||||
onRotateToken
|
||||
}: Props): ReactElement {
|
||||
const host = service?.host || '127.0.0.1'
|
||||
const port = service?.port || 6131
|
||||
@@ -33,7 +50,7 @@ export function ApiRuntimePanel({
|
||||
<div className="api-runtime-title">
|
||||
<h2>运行状态</h2>
|
||||
<span className={service?.running ? 'ready' : 'stopped'}>
|
||||
{service?.running ? '运行中' : '已停止'}
|
||||
{service?.running ? 'API 已启用' : '已停止'}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
@@ -49,7 +66,7 @@ export function ApiRuntimePanel({
|
||||
</div>
|
||||
<div>
|
||||
<dt>鉴权方式</dt>
|
||||
<dd>无需鉴权</dd>
|
||||
<dd>{tokenStatus?.hasToken ? 'Bearer Token' : '不可用'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>访问范围</dt>
|
||||
@@ -85,8 +102,45 @@ export function ApiRuntimePanel({
|
||||
</div>
|
||||
</section>
|
||||
{!localOnly && (
|
||||
<p className="api-security-warning">当前服务可能被局域网设备访问,且 API 暂无鉴权。</p>
|
||||
<p className="api-security-warning">
|
||||
当前服务可能被局域网设备访问。Bearer Token 不能替代可信网络边界。
|
||||
</p>
|
||||
)}
|
||||
<section className="api-token-section">
|
||||
<div className="api-runtime-title">
|
||||
<h3>API Token</h3>
|
||||
<span className={tokenStatus?.hasToken ? 'ready' : 'stopped'}>
|
||||
{tokenStatus?.hasToken ? 'Token 已生成' : 'Token 不可用'}
|
||||
</span>
|
||||
</div>
|
||||
<code className="api-token-value">
|
||||
{revealedToken || tokenStatus?.maskedToken || '••••••••••••••••'}
|
||||
</code>
|
||||
{tokenStatus?.error && <p className="api-inline-error">{tokenStatus.error}</p>}
|
||||
<div className="api-runtime-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tokenStatus?.hasToken}
|
||||
onClick={() => void (revealedToken ? onHideToken() : onRevealToken())}
|
||||
>
|
||||
{revealedToken ? '隐藏 Token' : '显示 Token'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tokenStatus?.hasToken}
|
||||
onClick={() => void onCopyToken()}
|
||||
>
|
||||
复制 Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tokenStatus?.available}
|
||||
onClick={() => void onRotateToken()}
|
||||
>
|
||||
重新生成 Token
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>最近响应</h3>
|
||||
{response ? (
|
||||
@@ -142,7 +196,7 @@ export function ApiRuntimePanel({
|
||||
<p>
|
||||
{localOnly
|
||||
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
|
||||
: '当前服务并非仅本机访问。请确认局域网环境可信,且注意 API 暂无鉴权。'}
|
||||
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ApiResponse,
|
||||
ApiServiceState,
|
||||
ApiSettings,
|
||||
ApiTokenStatus,
|
||||
RequestHistoryItem,
|
||||
SkillStatus
|
||||
} from '../model/types'
|
||||
@@ -19,12 +20,15 @@ import {
|
||||
buildSkillInstallInstruction,
|
||||
buildSkillVerificationPrompt
|
||||
} from '../utils/buildSkillInstallInstruction'
|
||||
import { confirmApiTokenRotation } from '../utils/confirmApiTokenRotation'
|
||||
|
||||
type RequestState = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
interface State {
|
||||
settings: ApiSettings | null
|
||||
service: ApiServiceState | null
|
||||
tokenStatus: ApiTokenStatus | null
|
||||
revealedToken: string
|
||||
skill: SkillStatus | null
|
||||
endpointId: string
|
||||
params: Record<string, string>
|
||||
@@ -39,7 +43,13 @@ interface State {
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'loaded'; settings: ApiSettings; service: ApiServiceState; skill: SkillStatus }
|
||||
| {
|
||||
type: 'loaded'
|
||||
settings: ApiSettings
|
||||
service: ApiServiceState
|
||||
skill: SkillStatus
|
||||
tokenStatus: ApiTokenStatus
|
||||
}
|
||||
| { type: 'endpoint'; endpointId: string; talker?: string }
|
||||
| { type: 'params'; params: Record<string, string> }
|
||||
| { type: 'body'; body: string }
|
||||
@@ -49,10 +59,15 @@ type Action =
|
||||
| { type: 'markdown'; content: string | null }
|
||||
| { type: 'toast'; message: string }
|
||||
| { type: 'installTarget'; target: AgentInstallTarget }
|
||||
| { type: 'tokenRevealed'; token: string }
|
||||
| { type: 'tokenHidden' }
|
||||
| { type: 'tokenStatus'; status: ApiTokenStatus }
|
||||
|
||||
const initialState: State = {
|
||||
settings: null,
|
||||
service: null,
|
||||
tokenStatus: null,
|
||||
revealedToken: '',
|
||||
skill: null,
|
||||
endpointId: 'health',
|
||||
params: {},
|
||||
@@ -69,7 +84,13 @@ const initialState: State = {
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'loaded':
|
||||
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
|
||||
return {
|
||||
...state,
|
||||
settings: action.settings,
|
||||
service: action.service,
|
||||
skill: action.skill,
|
||||
tokenStatus: action.tokenStatus
|
||||
}
|
||||
case 'endpoint': {
|
||||
const preset =
|
||||
action.endpointId === 'report'
|
||||
@@ -108,6 +129,12 @@ function reducer(state: State, action: Action): State {
|
||||
return { ...state, toast: action.message }
|
||||
case 'installTarget':
|
||||
return { ...state, installTarget: action.target }
|
||||
case 'tokenRevealed':
|
||||
return { ...state, revealedToken: action.token }
|
||||
case 'tokenHidden':
|
||||
return { ...state, revealedToken: '' }
|
||||
case 'tokenStatus':
|
||||
return { ...state, tokenStatus: action.status, revealedToken: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +151,11 @@ export function useApiCenterController(selectedContact: Contact | null): {
|
||||
reportError: (error: string) => void
|
||||
showToast: (message: string) => void
|
||||
copyText: (text: string, successMessage: string) => Promise<void>
|
||||
copyCurl: () => Promise<void>
|
||||
revealToken: () => Promise<void>
|
||||
hideToken: () => void
|
||||
copyToken: () => Promise<void>
|
||||
rotateToken: () => Promise<void>
|
||||
setInstallTarget: (target: AgentInstallTarget) => void
|
||||
copyInstallInstruction: () => Promise<void>
|
||||
copyVerificationPrompt: () => Promise<void>
|
||||
@@ -135,12 +167,13 @@ export function useApiCenterController(selectedContact: Contact | null): {
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [{ settings }, service, skill] = await Promise.all([
|
||||
const [{ settings }, service, skill, tokenStatus] = await Promise.all([
|
||||
window.api.getSettings(),
|
||||
window.api.apiStatus(),
|
||||
window.api.getReaderSkillStatus()
|
||||
window.api.getReaderSkillStatus(),
|
||||
window.api.apiTokenStatus()
|
||||
])
|
||||
dispatch({ type: 'loaded', settings, service, skill })
|
||||
dispatch({ type: 'loaded', settings, service, skill, tokenStatus })
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'error',
|
||||
@@ -265,6 +298,31 @@ export function useApiCenterController(selectedContact: Contact | null): {
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
const revealToken = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.revealApiToken()
|
||||
if (result.token) dispatch({ type: 'tokenRevealed', token: result.token })
|
||||
else dispatch({ type: 'error', error: result.error || '无法读取 API Token' })
|
||||
}, [])
|
||||
const hideToken = useCallback((): void => dispatch({ type: 'tokenHidden' }), [])
|
||||
const copyToken = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.copyApiToken()
|
||||
showToast(result.success ? 'Token 已复制' : result.error || 'Token 复制失败')
|
||||
}, [showToast])
|
||||
const rotateToken = useCallback(async (): Promise<void> => {
|
||||
if (!confirmApiTokenRotation()) return
|
||||
const result = await window.api.rotateApiToken()
|
||||
dispatch({ type: 'tokenStatus', status: result })
|
||||
showToast(result.success ? 'Token 已重新生成' : result.error || 'Token 重新生成失败')
|
||||
}, [showToast])
|
||||
const copyCurl = useCallback(async (): Promise<void> => {
|
||||
const endpoint = findEndpoint(state.endpointId)
|
||||
const result = await window.api.copyLocalApiCurl({
|
||||
endpointId: endpoint.id,
|
||||
query: state.params,
|
||||
body: state.body
|
||||
})
|
||||
showToast(result.success ? 'curl 命令已复制' : result.error || 'curl 命令复制失败')
|
||||
}, [showToast, state.body, state.endpointId, state.params])
|
||||
const setInstallTarget = useCallback(
|
||||
(target: AgentInstallTarget): void => dispatch({ type: 'installTarget', target }),
|
||||
[]
|
||||
@@ -318,6 +376,11 @@ export function useApiCenterController(selectedContact: Contact | null): {
|
||||
reportError,
|
||||
showToast,
|
||||
copyText,
|
||||
copyCurl,
|
||||
revealToken,
|
||||
hideToken,
|
||||
copyToken,
|
||||
rotateToken,
|
||||
setInstallTarget,
|
||||
copyInstallInstruction,
|
||||
copyVerificationPrompt,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ApiMethod = 'GET' | 'POST'
|
||||
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
|
||||
|
||||
export interface ApiParameter {
|
||||
key: string
|
||||
|
||||
@@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({
|
||||
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 是否已就绪。安装完成后告诉我验证结果。`
|
||||
return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 WechatExplorer → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
|
||||
}
|
||||
|
||||
export function buildSkillVerificationPrompt(): string {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const API_TOKEN_ROTATION_CONFIRMATION =
|
||||
'重新生成后,旧 Token 将立即失效。\n已配置此 API 的 Agent / Reader Skill 需要更新 Token。\n是否继续?'
|
||||
|
||||
export function confirmApiTokenRotation(
|
||||
confirm: (message: string) => boolean = window.confirm
|
||||
): boolean {
|
||||
return confirm(API_TOKEN_ROTATION_CONFIRMATION)
|
||||
}
|
||||
@@ -631,6 +631,23 @@
|
||||
color: #915d1e;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
.api-token-value {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 10px 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-subtle);
|
||||
color: var(--wxex-text-primary);
|
||||
font:
|
||||
12px/18px ui-monospace,
|
||||
monospace;
|
||||
user-select: text;
|
||||
}
|
||||
.api-token-section .api-runtime-actions button:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.api-response-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface ApiTokenStatus {
|
||||
available: boolean
|
||||
hasToken: boolean
|
||||
maskedToken: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ApiTokenRevealResult extends ApiTokenStatus {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface ApiTokenActionResult extends ApiTokenStatus {
|
||||
success: boolean
|
||||
}
|
||||
@@ -38,6 +38,17 @@ export interface LocalApiTestResponse {
|
||||
contentType?: string
|
||||
json?: unknown
|
||||
bodyText?: string
|
||||
errorCode?: 'API_NOT_RUNNING' | 'CONNECTION_REFUSED' | 'TIMEOUT' | 'INVALID_REQUEST' | 'UNKNOWN'
|
||||
errorCode?:
|
||||
| 'API_NOT_RUNNING'
|
||||
| 'TOKEN_UNAVAILABLE'
|
||||
| 'CONNECTION_REFUSED'
|
||||
| 'TIMEOUT'
|
||||
| 'INVALID_REQUEST'
|
||||
| 'UNKNOWN'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LocalApiCurlCopyResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user