From 48ff37f40bc3b34227e54f472ff1e57d2a250f6f Mon Sep 17 00:00:00 2001 From: Zane Date: Wed, 12 Aug 2026 01:07:25 +0800 Subject: [PATCH] Add trusted AI access keys --- server/ankimo-api.mts | 160 ++++++++++++++++++++---- server/ankimo-api.test.ts | 79 ++++++++++-- src/features/ai-access/AiAccess.test.ts | 32 ++++- src/features/ai-access/AiAccess.tsx | 102 ++++++++++++--- 4 files changed, 322 insertions(+), 51 deletions(-) diff --git a/server/ankimo-api.mts b/server/ankimo-api.mts index b60aac2..d97161b 100644 --- a/server/ankimo-api.mts +++ b/server/ankimo-api.mts @@ -1,14 +1,20 @@ import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import { AnkiConnect } from '../src/api/ankiConnect.ts'; import { createTextNote, MEMO_MODEL, QA_MODEL } from '../src/domain/noteWriting.ts'; export const API_HOST = '127.0.0.1'; export const API_PORT = 8787; -export const TOKEN_TTL_MS = 15 * 60 * 1000; -export const CONNECTION_TTL_MS = 2 * 60 * 1000; -export const MAX_TOKEN_CALLS = 20; +export const TOKEN_TTL_MS = 60 * 60 * 1000; +export const CONNECTION_TTL_MS = 10 * 60 * 1000; +export const MAX_TOKEN_CALLS = 100; +export const MAX_TRUSTED_CALLS_PER_MINUTE = 20; +export const MAX_TRUSTED_CALLS_PER_DAY = 200; export const MAX_JSON_BODY_BYTES = 256 * 1024; +const MAX_IDEMPOTENCY_RECORDS = 1_000; const DEFAULT_DECK = 'mubu'; const PUBLIC_API_URL = 'https://ankimo-api.yzr-stack.top'; const OPENAPI_URL = `${PUBLIC_API_URL}/openapi.json`; @@ -22,16 +28,25 @@ type JsonObject = Record; type JsonResponse = { status: number; body: JsonObject; headers?: Record }; type IdempotencyRecord = { fingerprint: string; result: Promise }; type ConnectionRecord = { expiresAt: number }; -type TokenRecord = { +type AuthRecord = { idempotency: Map }; +type TokenRecord = AuthRecord & { expiresAt: number; calls: number; - idempotency: Map; }; +type TrustedTokenRecord = AuthRecord & { + tokenHash: string; + minuteWindow: number; + minuteCalls: number; + dayWindow: number; + dayCalls: number; +}; +type TrustedAccess = { record: TrustedTokenRecord | null; path?: string }; export type AnkimoApiOptions = { client?: ApiClient; noteWriter?: NoteWriter; now?: () => number; + trustedTokenPath?: string; }; class HttpError extends Error { @@ -47,11 +62,11 @@ class HttpError extends Error { const OPENAPI_DOCUMENT = { openapi: '3.1.0', - info: { title: 'Ankimo AI API', version: '1.0.0' }, + info: { title: 'Ankimo AI API', version: '1.1.0' }, servers: [{ url: PUBLIC_API_URL }], components: { securitySchemes: { - bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'temporary token' } + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'temporary or trusted token' } }, schemas: { Tags: { type: 'array', maxItems: 50, items: { type: 'string', maxLength: 100 } }, @@ -142,6 +157,59 @@ function hash(value: string): string { return createHash('sha256').update(value).digest('hex'); } +function trustedRecord(tokenHash: string, currentTime: number): TrustedTokenRecord { + return { + tokenHash, + minuteWindow: Math.floor(currentTime / 60_000), + minuteCalls: 0, + dayWindow: Math.floor(currentTime / 86_400_000), + dayCalls: 0, + idempotency: new Map() + }; +} + +function loadTrustedToken(path: string | undefined, currentTime: number): TrustedTokenRecord | null { + if (!path) return null; + try { + const value: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const stored = value as Record; + if (stored.version === 1 && typeof stored.tokenHash === 'string' && /^[a-f0-9]{64}$/.test(stored.tokenHash)) { + return trustedRecord(stored.tokenHash, currentTime); + } + } + } catch { + // A missing or damaged state file safely means no trusted access. + } + return null; +} + +function saveTrustedToken(path: string | undefined, tokenHash: string): void { + if (!path) return; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temporaryPath = `${path}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify({ version: 1, tokenHash })}\n`, { mode: 0o600 }); + renameSync(temporaryPath, path); + chmodSync(path, 0o600); +} + +function removeTrustedToken(path: string | undefined): void { + if (!path) return; + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +function issueTrustedToken(access: TrustedAccess, now: () => number): string { + const token = `ank_live_${randomBytes(32).toString('base64url')}`; + const record = trustedRecord(hash(token), now()); + saveTrustedToken(access.path, record.tokenHash); + access.record = record; + return token; +} + function issueToken(tokens: Map, now: () => number) { const token = `ank_tmp_${randomBytes(32).toString('base64url')}`; const expiresAt = now() + TOKEN_TTL_MS; @@ -226,7 +294,7 @@ function optionalDeck(body: JsonObject): string { return body.deck; } -function bearerToken(request: IncomingMessage, tokens: Map, now: () => number): TokenRecord { +function bearerToken(request: IncomingMessage, tokens: Map, trusted: TrustedAccess, now: () => number): AuthRecord { const header = request.headers.authorization; if (typeof header !== 'string' || !/^Bearer \S+$/.test(header)) { throw new HttpError(401, 'UNAUTHORIZED', '需要有效的 Bearer Token'); @@ -235,21 +303,48 @@ function bearerToken(request: IncomingMessage, tokens: Map, const tokenHash = hash(token); const record = tokens.get(tokenHash); const currentTime = now(); - if (!record || record.expiresAt <= currentTime || record.calls >= MAX_TOKEN_CALLS) { - if (record && record.expiresAt <= currentTime) tokens.delete(tokenHash); + if (record) { + if (record.expiresAt <= currentTime || record.calls >= MAX_TOKEN_CALLS) { + if (record.expiresAt <= currentTime) tokens.delete(tokenHash); + throw new HttpError(401, 'UNAUTHORIZED', 'Bearer Token 无效、已过期或已达到调用上限'); + } + record.calls += 1; + return record; + } + + const trustedRecord = trusted.record; + if (!trustedRecord || trustedRecord.tokenHash !== tokenHash) { throw new HttpError(401, 'UNAUTHORIZED', 'Bearer Token 无效、已过期或已达到调用上限'); } - record.calls += 1; - return record; + const minuteWindow = Math.floor(currentTime / 60_000); + const dayWindow = Math.floor(currentTime / 86_400_000); + if (trustedRecord.minuteWindow !== minuteWindow) { + trustedRecord.minuteWindow = minuteWindow; + trustedRecord.minuteCalls = 0; + } + if (trustedRecord.dayWindow !== dayWindow) { + trustedRecord.dayWindow = dayWindow; + trustedRecord.dayCalls = 0; + } + if (trustedRecord.minuteCalls >= MAX_TRUSTED_CALLS_PER_MINUTE || trustedRecord.dayCalls >= MAX_TRUSTED_CALLS_PER_DAY) { + throw new HttpError(429, 'RATE_LIMITED', '可信 AI 密钥已达到调用频率上限'); + } + trustedRecord.minuteCalls += 1; + trustedRecord.dayCalls += 1; + return trustedRecord; } -function withIdempotency(record: TokenRecord, key: string, fingerprint: string, action: () => Promise): Promise { +function withIdempotency(record: AuthRecord, key: string, fingerprint: string, action: () => Promise): Promise { const mapKey = hash(key); const existing = record.idempotency.get(mapKey); if (existing) { if (existing.fingerprint !== fingerprint) throw new HttpError(409, 'IDEMPOTENCY_CONFLICT', '相同 idempotencyKey 已用于不同请求'); return existing.result; } + if (record.idempotency.size >= MAX_IDEMPOTENCY_RECORDS) { + const oldest = record.idempotency.keys().next().value; + if (oldest) record.idempotency.delete(oldest); + } const result = Promise.resolve().then(action).catch(errorResponse); record.idempotency.set(mapKey, { fingerprint, result }); return result; @@ -281,6 +376,7 @@ async function handleRequest( noteWriter: NoteWriter, tokens: Map, connections: Map, + trusted: TrustedAccess, now: () => number ): Promise { const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname; @@ -340,24 +436,38 @@ async function handleRequest( return; } if (pathname === '/api/ai-tokens') { - request.resume(); if (method === 'DELETE') { + request.resume(); const revoked = tokens.size; const connectionsRevoked = connections.size; + const trustedRevoked = trusted.record ? 1 : 0; tokens.clear(); connections.clear(); - sendJson(response, json(200, { revoked, connectionsRevoked }, SECRET_HEADERS)); + try { + removeTrustedToken(trusted.path); + trusted.record = null; + sendJson(response, json(200, { revoked, connectionsRevoked, trustedRevoked }, SECRET_HEADERS)); + } catch { + sendJson(response, { ...errorResponse(new Error('token revocation failed')), headers: SECRET_HEADERS }); + } + return; + } + if (contentType(request) !== 'application/json') { + request.resume(); + sendJson(response, { ...errorResponse(new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', '请求体必须使用 application/json')), headers: SECRET_HEADERS }); return; } try { - const { token, expiresAt } = issueToken(tokens, now); + const body = objectBody(await readJson(request)); + onlyFields(body, []); + const token = issueTrustedToken(trusted, now); sendJson(response, json(200, { token, - expiresAt: new Date(expiresAt).toISOString(), - maxUses: MAX_TOKEN_CALLS + maxCallsPerMinute: MAX_TRUSTED_CALLS_PER_MINUTE, + maxCallsPerDay: MAX_TRUSTED_CALLS_PER_DAY }, SECRET_HEADERS)); - } catch { - sendJson(response, { ...errorResponse(new Error('token generation failed')), headers: SECRET_HEADERS }); + } catch (error) { + sendJson(response, { ...errorResponse(error), headers: SECRET_HEADERS }); } return; } @@ -405,7 +515,7 @@ async function handleRequest( return; } - const tokenRecord = bearerToken(request, tokens, now); + const tokenRecord = bearerToken(request, tokens, trusted, now); if (pathname === '/v1/decks') { try { sendJson(response, json(200, { decks: await client.deckNames() })); @@ -454,13 +564,17 @@ export function createAnkimoApiServer(options: AnkimoApiOptions = {}): Server { const tokens = new Map(); const connections = new Map(); const now = options.now || Date.now; + const trusted: TrustedAccess = { + record: loadTrustedToken(options.trustedTokenPath, now()), + path: options.trustedTokenPath + }; return createServer((request, response) => { - void handleRequest(request, response, client, noteWriter, tokens, connections, now).catch(error => sendJson(response, errorResponse(error))); + void handleRequest(request, response, client, noteWriter, tokens, connections, trusted, now).catch(error => sendJson(response, errorResponse(error))); }); } export const openApiDocument = OPENAPI_DOCUMENT; if (process.argv.includes('--serve')) { - createAnkimoApiServer().listen(API_PORT, API_HOST); + createAnkimoApiServer({ trustedTokenPath: join(homedir(), 'Library', 'Application Support', 'Ankimo', 'trusted-ai-key.json') }).listen(API_PORT, API_HOST); } diff --git a/server/ankimo-api.test.ts b/server/ankimo-api.test.ts index 1db2315..76a6bf2 100644 --- a/server/ankimo-api.test.ts +++ b/server/ankimo-api.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { CONNECTION_TTL_MS, createAnkimoApiServer, MAX_TOKEN_CALLS, TOKEN_TTL_MS, type AnkimoApiOptions } from './ankimo-api.mts'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CONNECTION_TTL_MS, createAnkimoApiServer, MAX_TOKEN_CALLS, MAX_TRUSTED_CALLS_PER_DAY, MAX_TRUSTED_CALLS_PER_MINUTE, TOKEN_TTL_MS, type AnkimoApiOptions } from './ankimo-api.mts'; type FakeAnki = { deckNames: () => Promise; @@ -11,6 +14,7 @@ type FakeAnki = { }; const servers: ReturnType[] = []; +const tempDirs: string[] = []; function fakeAnki(overrides: Partial = {}): FakeAnki { return { @@ -41,14 +45,28 @@ async function request(base: string, path: string, init: RequestInit = {}) { return { response, body: await response.json() as Record }; } -async function token(base: string): Promise { - const { response, body } = await request(base, '/api/ai-tokens', { method: 'POST' }); +async function trustedToken(base: string): Promise { + const { response, body } = await request(base, '/api/ai-tokens', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' + }); expect(response.status).toBe(200); - expect(body.maxUses).toBe(MAX_TOKEN_CALLS); + expect(body.maxCallsPerMinute).toBe(MAX_TRUSTED_CALLS_PER_MINUTE); + expect(body.maxCallsPerDay).toBe(MAX_TRUSTED_CALLS_PER_DAY); if (typeof body.token !== 'string') throw new Error('token response missing token'); return body.token; } +async function temporaryToken(base: string): Promise { + const connection = await request(base, '/api/ai-connections', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' + }); + const exchanged = await request(base, new URL(String(connection.body.connectUrl)).pathname, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' + }); + if (typeof exchanged.body.access_token !== 'string') throw new Error('temporary token response missing token'); + return exchanged.body.access_token; +} + function auth(tokenValue: string, body?: unknown): RequestInit { return { headers: { Authorization: `Bearer ${tokenValue}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, @@ -58,6 +76,7 @@ function auth(tokenValue: string, body?: unknown): RequestInit { afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); describe('Ankimo HTTP API', () => { @@ -129,11 +148,11 @@ describe('Ankimo HTTP API', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const connectPath = new URL(String(connection.body.connectUrl)).pathname; - const bearer = await token(base); + const bearer = await trustedToken(base); const revoked = await request(base, '/api/ai-tokens', { method: 'DELETE' }); expect(revoked.response.status).toBe(200); - expect(revoked.body).toMatchObject({ revoked: 1, connectionsRevoked: 1 }); + expect(revoked.body).toMatchObject({ revoked: 0, connectionsRevoked: 1, trustedRevoked: 1 }); expect((await request(base, '/v1/decks', { headers: { Authorization: `Bearer ${bearer}` } })).response.status).toBe(401); expect((await request(base, connectPath, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' @@ -143,8 +162,8 @@ describe('Ankimo HTTP API', () => { it('limits bearer calls and expires tokens', async () => { let currentTime = 1_000_000; const base = await start({ client: fakeAnki(), now: () => currentTime }); - const replaced = await token(base); - const bearer = await token(base); + const replaced = await temporaryToken(base); + const bearer = await temporaryToken(base); expect((await request(base, '/v1/decks', { headers: { Authorization: `Bearer ${replaced}` } })).response.status).toBe(401); for (let call = 0; call < MAX_TOKEN_CALLS; call++) { @@ -153,11 +172,49 @@ describe('Ankimo HTTP API', () => { } expect((await request(base, '/v1/decks', { headers: { Authorization: `Bearer ${bearer}` } })).response.status).toBe(401); - const expiring = await token(base); + const expiring = await temporaryToken(base); currentTime += TOKEN_TTL_MS + 1; expect((await request(base, '/v1/decks', { headers: { Authorization: `Bearer ${expiring}` } })).response.status).toBe(401); }); + it('persists only a trusted token hash and revokes it across restarts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ankimo-api-test-')); + tempDirs.push(dir); + const trustedTokenPath = join(dir, 'trusted.json'); + const first = await start({ client: fakeAnki(), trustedTokenPath }); + const original = await trustedToken(first); + expect(readFileSync(trustedTokenPath, 'utf8')).not.toContain(original); + expect(statSync(trustedTokenPath).mode & 0o777).toBe(0o600); + + const restarted = await start({ client: fakeAnki(), trustedTokenPath }); + expect((await request(restarted, '/v1/decks', { headers: { Authorization: `Bearer ${original}` } })).response.status).toBe(200); + const replacement = await trustedToken(restarted); + expect((await request(restarted, '/v1/decks', { headers: { Authorization: `Bearer ${original}` } })).response.status).toBe(401); + expect((await request(restarted, '/v1/decks', { headers: { Authorization: `Bearer ${replacement}` } })).response.status).toBe(200); + + expect((await request(restarted, '/api/ai-tokens', { method: 'DELETE' })).response.status).toBe(200); + const afterRevocation = await start({ client: fakeAnki(), trustedTokenPath }); + expect((await request(afterRevocation, '/v1/decks', { headers: { Authorization: `Bearer ${replacement}` } })).response.status).toBe(401); + }); + + it('rate limits trusted tokens per minute and per day', async () => { + let currentTime = 1_000_000; + const base = await start({ client: fakeAnki(), now: () => currentTime }); + const bearer = await trustedToken(base); + const call = () => request(base, '/v1/decks', { headers: { Authorization: `Bearer ${bearer}` } }); + + for (let count = 0; count < MAX_TRUSTED_CALLS_PER_MINUTE; count++) expect((await call()).response.status).toBe(200); + expect((await call()).response.status).toBe(429); + for (let batch = 1; batch < MAX_TRUSTED_CALLS_PER_DAY / MAX_TRUSTED_CALLS_PER_MINUTE; batch++) { + currentTime += 60_000; + for (let count = 0; count < MAX_TRUSTED_CALLS_PER_MINUTE; count++) expect((await call()).response.status).toBe(200); + } + currentTime += 60_000; + expect((await call()).response.status).toBe(429); + currentTime += 86_400_000; + expect((await call()).response.status).toBe(200); + }); + it('creates memo and QA cards with the mubu default and uses shared memo suspension', async () => { const calls: { deck: string; model: string; fields: Record; suspended: number[][] } = { deck: '', model: '', fields: {}, suspended: [] @@ -167,7 +224,7 @@ describe('Ankimo HTTP API', () => { suspend: async cards => { calls.suspended.push(cards); return null; } }); const base = await start({ client }); - const bearer = await token(base); + const bearer = await trustedToken(base); const memo = await request(base, '/v1/memos', auth(bearer, { content: '原始 笔记', idempotencyKey: 'memo-key-1', tags: ['ai'] })); expect(memo.response.status).toBe(200); @@ -187,7 +244,7 @@ describe('Ankimo HTTP API', () => { it('keeps the first idempotent result and rejects a different payload', async () => { let writes = 0; const base = await start({ client: fakeAnki({ addNote: async () => { writes++; throw new Error('write status unknown'); } }) }); - const bearer = await token(base); + const bearer = await trustedToken(base); const body = { content: '一次写入', idempotencyKey: 'same-key-1' }; const first = await request(base, '/v1/memos', auth(bearer, body)); const second = await request(base, '/v1/memos', auth(bearer, body)); diff --git a/src/features/ai-access/AiAccess.test.ts b/src/features/ai-access/AiAccess.test.ts index 1676306..0cc2c84 100644 --- a/src/features/ai-access/AiAccess.test.ts +++ b/src/features/ai-access/AiAccess.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { createAiConnection, revokeAiTokens, type AiAccessFetch } from './AiAccess'; +import { createAiConnection, createTrustedAiToken, revokeAiTokens, type AiAccessFetch } from './AiAccess'; function fetchResponse(body: unknown, status = 200): AiAccessFetch { return async () => new Response(body === undefined ? null : JSON.stringify(body), { status }); } -describe('AI temporary access API', () => { +describe('AI access API', () => { it('creates a connection link with same-origin JSON request settings', async () => { let request: { input: RequestInfo | URL; init?: RequestInit } | undefined; const fetcher: AiAccessFetch = async (input, init) => { @@ -27,6 +27,33 @@ describe('AI temporary access API', () => { }); }); + it('creates a trusted token with same-origin JSON request settings', async () => { + let request: { input: RequestInfo | URL; init?: RequestInit } | undefined; + const fetcher: AiAccessFetch = async (input, init) => { + request = { input, init }; + return new Response(JSON.stringify({ + token: `ank_live_${'a'.repeat(43)}`, + maxCallsPerMinute: 20, + maxCallsPerDay: 200 + })); + }; + + await expect(createTrustedAiToken(fetcher)).resolves.toEqual({ + token: `ank_live_${'a'.repeat(43)}`, + maxCallsPerMinute: 20, + maxCallsPerDay: 200 + }); + expect(request).toMatchObject({ + input: '/api/ai-tokens', + init: { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: '{}' + } + }); + }); + it('revokes all tokens with a same-origin JSON request', async () => { let request: RequestInit | undefined; const fetcher: AiAccessFetch = async (_input, init) => { @@ -50,5 +77,6 @@ describe('AI temporary access API', () => { it('rejects malformed connection responses', async () => { await expect(createAiConnection(fetchResponse({ connectUrl: '', expiresAt: '', expiresIn: 0 }))).rejects.toThrow('响应格式无效'); + await expect(createTrustedAiToken(fetchResponse({ token: 'short', maxCallsPerMinute: 0, maxCallsPerDay: 0 }))).rejects.toThrow('响应格式无效'); }); }); diff --git a/src/features/ai-access/AiAccess.tsx b/src/features/ai-access/AiAccess.tsx index d728927..a6ae1d9 100644 --- a/src/features/ai-access/AiAccess.tsx +++ b/src/features/ai-access/AiAccess.tsx @@ -14,6 +14,12 @@ export type AiConnection = { expiresIn: number; }; +export type TrustedAiToken = { + token: string; + maxCallsPerMinute: number; + maxCallsPerDay: number; +}; + export type AiAccessFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; function isRecord(value: unknown): value is Record { @@ -44,6 +50,13 @@ function parseAiConnection(value: unknown): AiConnection { return { connectUrl: value.connectUrl, expiresAt: value.expiresAt, expiresIn: value.expiresIn }; } +function parseTrustedAiToken(value: unknown): TrustedAiToken { + if (!isRecord(value) || typeof value.token !== 'string' || !/^ank_live_[A-Za-z0-9_-]{43}$/.test(value.token) || typeof value.maxCallsPerMinute !== 'number' || !Number.isInteger(value.maxCallsPerMinute) || value.maxCallsPerMinute < 1 || typeof value.maxCallsPerDay !== 'number' || !Number.isInteger(value.maxCallsPerDay) || value.maxCallsPerDay < 1) { + throw new Error('可信 AI 密钥响应格式无效'); + } + return { token: value.token, maxCallsPerMinute: value.maxCallsPerMinute, maxCallsPerDay: value.maxCallsPerDay }; +} + const defaultFetch: AiAccessFetch = (input, init) => globalThis.fetch(input, init); export async function createAiConnection(fetcher: AiAccessFetch = defaultFetch): Promise { @@ -63,6 +76,23 @@ export async function createAiConnection(fetcher: AiAccessFetch = defaultFetch): return parseAiConnection(payload); } +export async function createTrustedAiToken(fetcher: AiAccessFetch = defaultFetch): Promise { + const response = await fetcher(AI_TOKEN_PATH, { + method: 'POST', + credentials: 'same-origin', + headers: JSON_HEADERS, + body: JSON.stringify({}) + }); + if (!response.ok) await responseError(response); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error('可信 AI 密钥响应格式无效'); + } + return parseTrustedAiToken(payload); +} + export async function revokeAiTokens(fetcher: AiAccessFetch = defaultFetch): Promise { const response = await fetcher(AI_TOKEN_PATH, { method: 'DELETE', @@ -78,15 +108,16 @@ function causeMessage(cause: unknown): string { export function AiAccess() { const [connection, setConnection] = useState(null); - const [busy, setBusy] = useState<'create' | 'copy' | 'revoke' | null>(null); + const [trustedToken, setTrustedToken] = useState(null); + const [busy, setBusy] = useState<'connection' | 'trusted' | 'copy' | 'revoke' | null>(null); const [feedback, setFeedback] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const generate = async () => { - setBusy('create'); + setBusy('connection'); setFeedback(null); try { setConnection(await createAiConnection()); - setFeedback({ message: '一次性连接链接已生成,请在 2 分钟内发送给 AI。', type: 'success' }); + setFeedback({ message: '一次性连接链接已生成,请在 10 分钟内发送给 AI。', type: 'success' }); } catch (cause) { setFeedback({ message: `生成失败:${causeMessage(cause)}`, type: 'error' }); } finally { @@ -94,13 +125,25 @@ export function AiAccess() { } }; - const copyConnection = async () => { - if (!connection) return; + const generateTrusted = async () => { + setBusy('trusted'); + setFeedback(null); + try { + setTrustedToken(await createTrustedAiToken()); + setFeedback({ message: '长期密钥已生成;旧长期密钥已失效。请立即保存到安全密钥存储。', type: 'success' }); + } catch (cause) { + setFeedback({ message: `生成失败:${causeMessage(cause)}`, type: 'error' }); + } finally { + setBusy(null); + } + }; + + const copyValue = async (value: string, label: string) => { setBusy('copy'); try { if (!navigator.clipboard) throw new Error('当前环境不支持剪贴板'); - await navigator.clipboard.writeText(connection.connectUrl); - setFeedback({ message: 'AI 连接链接已复制。', type: 'success' }); + await navigator.clipboard.writeText(value); + setFeedback({ message: `${label}已复制。`, type: 'success' }); } catch (cause) { setFeedback({ message: `复制失败:${causeMessage(cause)}`, type: 'error' }); } finally { @@ -114,7 +157,8 @@ export function AiAccess() { try { await revokeAiTokens(); setConnection(null); - setFeedback({ message: '全部 AI 临时访问已撤销。', type: 'success' }); + setTrustedToken(null); + setFeedback({ message: '全部 AI 访问已撤销。', type: 'success' }); } catch (cause) { setFeedback({ message: `撤销失败:${causeMessage(cause)}`, type: 'error' }); } finally { @@ -125,9 +169,9 @@ export function AiAccess() { return (
-

AI 临时访问

+

AI 访问

-

生成一次性连接链接后,只需把链接提供给支持 HTTP 工具的 AI。

+

日常使用请选择长期密钥;不能安全保存密钥的 AI 仍使用一次性连接链接。

+ {trustedToken && ( +
+ +

只在当前页面显示一次。请保存到 AI 平台的 Secret Store 或本机 Keychain,切勿发送到聊天。

+

本机 Codex:复制后在终端运行 codex-secret save ankimo,并按隐藏提示保存。

+
+ +
+ )} + {connection && (
-

只在当前页面显示,不写入浏览器存储或日志;生成新链接会使旧链接失效。

+

只在当前页面显示,不写入浏览器存储或日志;只能兑换一次。

请在 {connection.expiresAt} 前让 AI 访问并兑换。

+
+
)}