chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
type LearningEventType,
|
||||
type FeedbackSentiment,
|
||||
} from '../types/active-learning';
|
||||
import { generateRandomString } from './crypto-utils';
|
||||
|
||||
// === 常量 ===
|
||||
|
||||
@@ -23,7 +24,7 @@ const SUGGESTION_COOLDOWN_HOURS = 2;
|
||||
// === 生成 ID ===
|
||||
|
||||
function generateEventId(): string {
|
||||
return `le-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
return `le-${Date.now()}-${generateRandomString(8)}`;
|
||||
}
|
||||
|
||||
// === 分析反馈情感 ===
|
||||
@@ -172,7 +173,7 @@ export class ActiveLearningEngine {
|
||||
// 1. 正面反馈 -> 偏好正面回复
|
||||
if (event.observation.includes('谢谢') || event.observation.includes('好的')) {
|
||||
this.addPattern({
|
||||
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
id: `pat-${Date.now()}-${generateRandomString(8)}`,
|
||||
type: 'preference',
|
||||
pattern: 'positive_response_preference',
|
||||
description: '用户偏好正面回复风格',
|
||||
@@ -185,7 +186,7 @@ export class ActiveLearningEngine {
|
||||
// 2. 纠正 -> 需要更精确
|
||||
if (event.type === 'correction') {
|
||||
this.addPattern({
|
||||
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
id: `pat-${Date.now()}-${generateRandomString(8)}`,
|
||||
type: 'preference',
|
||||
pattern: 'precision_preference',
|
||||
description: '用户对精确性有更高要求',
|
||||
@@ -198,7 +199,7 @@ export class ActiveLearningEngine {
|
||||
// 3. 上下文相关 -> 场景偏好
|
||||
if (event.context) {
|
||||
this.addPattern({
|
||||
id: `pat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
id: `pat-${Date.now()}-${generateRandomString(8)}`,
|
||||
type: 'context',
|
||||
pattern: 'context_aware',
|
||||
description: 'Agent 需要关注上下文',
|
||||
@@ -252,7 +253,7 @@ export class ActiveLearningEngine {
|
||||
|
||||
// 生成建议
|
||||
suggestions.push({
|
||||
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
id: `sug-${Date.now()}-${generateRandomString(8)}`,
|
||||
agentId,
|
||||
type: pattern.type,
|
||||
pattern: pattern.pattern,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
import { hashSha256 } from './crypto-utils';
|
||||
import { hashSha256, generateRandomString } from './crypto-utils';
|
||||
|
||||
// Storage key prefixes
|
||||
const API_KEY_PREFIX = 'zclaw_api_key_';
|
||||
@@ -456,21 +456,19 @@ export function clearSecurityLog(): void {
|
||||
|
||||
/**
|
||||
* Generate a random API key for testing
|
||||
* WARNING: Only use for testing purposes
|
||||
* @internal Only use for testing purposes
|
||||
*/
|
||||
export function generateTestApiKey(type: ApiKeyType): string {
|
||||
const rules = KEY_VALIDATION_RULES[type];
|
||||
const length = rules.minLength + 10;
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
let key = '';
|
||||
if (rules.prefix && rules.prefix.length > 0) {
|
||||
key = rules.prefix[0];
|
||||
}
|
||||
|
||||
for (let i = key.length; i < length; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
const remainingLength = length - key.length;
|
||||
key += generateRandomString(remainingLength);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `audit_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
||||
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
|
||||
return `audit_${Date.now()}_${suffix}`;
|
||||
}
|
||||
|
||||
function getTimestamp(): string {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.3
|
||||
*/
|
||||
|
||||
import { generateRandomString } from './crypto-utils';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type AutonomyLevel = 'supervised' | 'assisted' | 'autonomous';
|
||||
@@ -278,7 +280,7 @@ export class AutonomyManager {
|
||||
* Returns approval ID that can be used to approve/reject.
|
||||
*/
|
||||
requestApproval(decision: AutonomyDecision): string {
|
||||
const approvalId = `approval_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const approvalId = `approval_${Date.now()}_${generateRandomString(6)}`;
|
||||
this.pendingApprovals.set(approvalId, decision);
|
||||
|
||||
// Store in localStorage for persistence
|
||||
@@ -349,7 +351,7 @@ export class AutonomyManager {
|
||||
|
||||
private logDecision(decision: AutonomyDecision, context: Record<string, unknown>): void {
|
||||
const entry: AuditLogEntry = {
|
||||
id: `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: `audit_${Date.now()}_${generateRandomString(6)}`,
|
||||
action: decision.action,
|
||||
decision,
|
||||
context,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - Secure key caching with automatic expiration
|
||||
*/
|
||||
|
||||
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
||||
// Legacy static salt — only used for backward compatibility with existing encrypted data
|
||||
const LEGACY_SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
||||
const ITERATIONS = 100000;
|
||||
const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@@ -87,13 +88,15 @@ function getCacheKey(masterKey: string, salt: Uint8Array): string {
|
||||
*/
|
||||
export async function deriveKey(
|
||||
masterKey: string,
|
||||
salt: Uint8Array = SALT
|
||||
salt?: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
// Use legacy salt for backward compatibility if no salt provided
|
||||
const effectiveSalt = salt ?? LEGACY_SALT;
|
||||
// Clean up expired keys periodically
|
||||
cleanupExpiredKeys();
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(masterKey, salt);
|
||||
const cacheKey = getCacheKey(masterKey, effectiveSalt);
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) {
|
||||
return cached.key;
|
||||
@@ -111,7 +114,7 @@ export async function deriveKey(
|
||||
const derivedKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
salt: effectiveSalt,
|
||||
iterations: ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { secureStorage } from './secure-storage';
|
||||
|
||||
export interface EmbeddingConfig {
|
||||
provider: string;
|
||||
@@ -32,12 +33,18 @@ export interface EmbeddingProvider {
|
||||
}
|
||||
|
||||
const EMBEDDING_STORAGE_KEY = 'zclaw-embedding-config';
|
||||
const EMBEDDING_KEY_SECURE = 'zclaw-secure-embedding-apikey';
|
||||
|
||||
/**
|
||||
* Load embedding config from localStorage. apiKey is NOT included;
|
||||
* use loadEmbeddingApiKey() to retrieve it from secure storage.
|
||||
*/
|
||||
export function loadEmbeddingConfig(): EmbeddingConfig {
|
||||
try {
|
||||
const stored = localStorage.getItem(EMBEDDING_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...parsed, apiKey: '' };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -51,14 +58,37 @@ export function loadEmbeddingConfig(): EmbeddingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save embedding config to localStorage. API key is NOT saved here;
|
||||
* use saveEmbeddingApiKey() separately.
|
||||
*/
|
||||
export function saveEmbeddingConfig(config: EmbeddingConfig): void {
|
||||
try {
|
||||
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
|
||||
const { apiKey: _, ...rest } = config;
|
||||
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(rest));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load embedding API key from secure storage.
|
||||
*/
|
||||
export async function loadEmbeddingApiKey(): Promise<string | null> {
|
||||
return secureStorage.get(EMBEDDING_KEY_SECURE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save embedding API key to secure storage.
|
||||
*/
|
||||
export async function saveEmbeddingApiKey(apiKey: string): Promise<void> {
|
||||
if (!apiKey.trim()) {
|
||||
await secureStorage.delete(EMBEDDING_KEY_SECURE);
|
||||
return;
|
||||
}
|
||||
await secureStorage.set(EMBEDDING_KEY_SECURE, apiKey.trim());
|
||||
}
|
||||
|
||||
export async function getEmbeddingProviders(): Promise<EmbeddingProvider[]> {
|
||||
const result = await invoke<[string, string, string, number][]>('embedding_providers');
|
||||
return result.map(([id, name, defaultModel, dimensions]) => ({
|
||||
@@ -75,7 +105,9 @@ export async function createEmbedding(
|
||||
): Promise<EmbeddingResponse> {
|
||||
const savedConfig = loadEmbeddingConfig();
|
||||
const provider = config?.provider ?? savedConfig.provider;
|
||||
const apiKey = config?.apiKey ?? savedConfig.apiKey;
|
||||
// Resolve apiKey: use explicit config value, then secure storage, then empty
|
||||
const explicitKey = config?.apiKey?.trim();
|
||||
const apiKey = explicitKey || await loadEmbeddingApiKey() || '';
|
||||
const model = config?.model ?? savedConfig.model;
|
||||
const endpoint = config?.endpoint ?? savedConfig.endpoint;
|
||||
|
||||
@@ -136,6 +168,8 @@ export class EmbeddingClient {
|
||||
|
||||
constructor(config?: EmbeddingConfig) {
|
||||
this.config = config ?? loadEmbeddingConfig();
|
||||
// If no explicit apiKey was provided and config was loaded from localStorage,
|
||||
// the apiKey will be empty. It will be resolved from secure storage lazily.
|
||||
}
|
||||
|
||||
get isApiMode(): boolean {
|
||||
@@ -143,7 +177,11 @@ export class EmbeddingClient {
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await createEmbedding(text, this.config);
|
||||
// Resolve apiKey from secure storage if not in config
|
||||
const effectiveConfig = this.config.apiKey
|
||||
? this.config
|
||||
: { ...this.config, apiKey: await loadEmbeddingApiKey() ?? '' };
|
||||
const response = await createEmbedding(text, effectiveConfig);
|
||||
return response.embedding;
|
||||
}
|
||||
|
||||
@@ -161,7 +199,12 @@ export class EmbeddingClient {
|
||||
if (config.provider !== undefined || config.apiKey !== undefined) {
|
||||
this.config.enabled = this.config.provider !== 'local' && !!this.config.apiKey;
|
||||
}
|
||||
// Save non-key fields to localStorage
|
||||
saveEmbeddingConfig(this.config);
|
||||
// Save apiKey to secure storage (fire-and-forget)
|
||||
if (config.apiKey !== undefined) {
|
||||
saveEmbeddingApiKey(config.apiKey).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): EmbeddingConfig {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* for user-friendly error handling.
|
||||
*/
|
||||
|
||||
import { generateRandomString } from './crypto-utils';
|
||||
|
||||
// === Error Categories ===
|
||||
|
||||
export type ErrorCategory =
|
||||
@@ -348,7 +350,7 @@ export function classifyError(error: unknown): AppError {
|
||||
if (matched) {
|
||||
const { pattern, match } = matched;
|
||||
return {
|
||||
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: `err_${Date.now()}_${generateRandomString(6)}`,
|
||||
category: pattern.category,
|
||||
severity: pattern.severity,
|
||||
title: pattern.title,
|
||||
@@ -367,7 +369,7 @@ export function classifyError(error: unknown): AppError {
|
||||
|
||||
// Unknown error - return generic error
|
||||
return {
|
||||
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: `err_${Date.now()}_${generateRandomString(6)}`,
|
||||
category: 'system',
|
||||
severity: 'medium',
|
||||
title: 'An Error Occurred',
|
||||
|
||||
@@ -151,7 +151,9 @@ function createIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
||||
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
|
||||
return `idem_${Date.now()}_${suffix}`;
|
||||
}
|
||||
|
||||
// === Client ===
|
||||
@@ -474,7 +476,7 @@ export class GatewayClient {
|
||||
): Promise<{ runId: string }> {
|
||||
const agentId = opts?.agentId || this.defaultAgentId;
|
||||
const runId = createIdempotencyKey();
|
||||
const sessionId = opts?.sessionKey || `session_${Date.now()}`;
|
||||
const sessionId = opts?.sessionKey || crypto.randomUUID();
|
||||
|
||||
// If no agent ID, try to fetch from ZCLAW status (async, but we'll handle it in connectZclawStream)
|
||||
if (!agentId) {
|
||||
@@ -732,7 +734,7 @@ export class GatewayClient {
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
log.error(`POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}`);
|
||||
(error as any).status = response.status;
|
||||
(error as any).body = errorBody;
|
||||
throw error;
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { isTauriRuntime } from './tauri-gateway';
|
||||
import { generateRandomString } from './crypto-utils';
|
||||
|
||||
import {
|
||||
intelligence,
|
||||
@@ -379,7 +380,7 @@ const fallbackMemory = {
|
||||
|
||||
async store(entry: MemoryEntryInput): Promise<string> {
|
||||
const store = getFallbackStore();
|
||||
const id = `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const id = `mem_${Date.now()}_${generateRandomString(6)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const memory: MemoryEntry = {
|
||||
|
||||
@@ -464,7 +464,7 @@ export class KernelClient {
|
||||
agentId?: string;
|
||||
}
|
||||
): Promise<{ runId: string }> {
|
||||
const runId = `run_${Date.now()}`;
|
||||
const runId = crypto.randomUUID();
|
||||
const sessionId = opts?.sessionKey || runId;
|
||||
const agentId = opts?.agentId || this.defaultAgentId;
|
||||
|
||||
@@ -1100,12 +1100,54 @@ export class KernelClient {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// === A2A (Agent-to-Agent) API ===
|
||||
|
||||
/**
|
||||
* Verify audit log chain (GatewayClient compatibility)
|
||||
* Note: Not implemented for internal kernel
|
||||
* Send a direct A2A message from one agent to another
|
||||
*/
|
||||
async verifyAuditLogChain(): Promise<{ valid: boolean; chain_depth?: number; root_hash?: string; broken_at_index?: number }> {
|
||||
return { valid: false, chain_depth: 0, root_hash: '' };
|
||||
/**
|
||||
* Send a direct A2A message from one agent to another
|
||||
*/
|
||||
async a2aSend(from: string, to: string, payload: unknown, messageType?: string): Promise<void> {
|
||||
await invoke('agent_a2a_send', {
|
||||
from,
|
||||
to,
|
||||
payload,
|
||||
messageType: messageType || 'notification',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message from an agent to all other agents
|
||||
*/
|
||||
async a2aBroadcast(from: string, payload: unknown): Promise<void> {
|
||||
await invoke('agent_a2a_broadcast', { from, payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover agents that have a specific capability
|
||||
*/
|
||||
async a2aDiscover(capability: string): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
capabilities: Array<{ name: string; description: string }>;
|
||||
role: string;
|
||||
priority: number;
|
||||
}>> {
|
||||
return await invoke('agent_a2a_discover', { capability });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate a task to another agent and wait for response
|
||||
*/
|
||||
async a2aDelegateTask(from: string, to: string, task: string, timeoutMs?: number): Promise<unknown> {
|
||||
return await invoke('agent_a2a_delegate_task', {
|
||||
from,
|
||||
to,
|
||||
task,
|
||||
timeoutMs: timeoutMs || 30000,
|
||||
});
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
@@ -342,13 +342,9 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
latencyMs,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[LLMService] Kernel chat failed, falling back to mock:', err);
|
||||
// Return empty response instead of throwing
|
||||
return {
|
||||
content: '',
|
||||
tokensUsed: { input: 0, output: 0 },
|
||||
latencyMs: Date.now() - startTime,
|
||||
};
|
||||
console.error('[LLMService] Kernel chat failed:', err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Gateway] Kernel chat failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +466,7 @@ class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
const data = await response.json();
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
const result = {
|
||||
content: data.choices?.[0]?.message?.content || '',
|
||||
tokensUsed: {
|
||||
input: data.usage?.prompt_tokens || 0,
|
||||
@@ -479,6 +475,19 @@ class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
model: data.model,
|
||||
latencyMs,
|
||||
};
|
||||
|
||||
// Record telemetry for SaaS relay usage
|
||||
try {
|
||||
const { recordLLMUsage } = await import('./telemetry-collector');
|
||||
recordLLMUsage(
|
||||
result.model || 'saas-relay',
|
||||
result.tokensUsed.input,
|
||||
result.tokensUsed.output,
|
||||
{ latencyMs, success: true, connectionMode: 'saas' },
|
||||
);
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
@@ -559,12 +568,17 @@ export function saveConfig(config: LLMConfig): void {
|
||||
delete safeConfig.apiKey;
|
||||
|
||||
localStorage.setItem(LLM_CONFIG_KEY, JSON.stringify(safeConfig));
|
||||
|
||||
// Mark config as dirty for SaaS push sync
|
||||
localStorage.setItem('zclaw-config-dirty.llm.default', '1');
|
||||
|
||||
resetLLMAdapter();
|
||||
}
|
||||
|
||||
// === Prompt Templates ===
|
||||
|
||||
export const LLM_PROMPTS = {
|
||||
// 硬编码默认值 — 当 SaaS 不可用且本地无缓存时的终极兜底
|
||||
const HARDCODED_PROMPTS: Record<string, { system: string; user: (arg: string) => string }> = {
|
||||
reflection: {
|
||||
system: `你是一个 AI Agent 的自我反思引擎。分析最近的对话历史,识别行为模式,并生成改进建议。
|
||||
|
||||
@@ -587,11 +601,7 @@ export const LLM_PROMPTS = {
|
||||
],
|
||||
"identityProposals": []
|
||||
}`,
|
||||
user: (context: string) => `分析以下对话历史,进行自我反思:
|
||||
|
||||
${context}
|
||||
|
||||
请识别行为模式(积极和消极),并提供具体的改进建议。`,
|
||||
user: (context: string) => `分析以下对话历史,进行自我反思:\n\n${context}\n\n请识别行为模式(积极和消极),并提供具体的改进建议。`,
|
||||
},
|
||||
|
||||
compaction: {
|
||||
@@ -603,9 +613,7 @@ ${context}
|
||||
3. 保留未完成的任务
|
||||
4. 保持时间顺序
|
||||
5. 摘要应能在后续对话中替代原始内容`,
|
||||
user: (messages: string) => `请将以下对话压缩为简洁摘要,保留关键信息:
|
||||
|
||||
${messages}`,
|
||||
user: (messages: string) => `请将以下对话压缩为简洁摘要,保留关键信息:\n\n${messages}`,
|
||||
},
|
||||
|
||||
extraction: {
|
||||
@@ -626,14 +634,200 @@ ${messages}`,
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
]`,
|
||||
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:
|
||||
|
||||
${conversation}
|
||||
|
||||
如果没有值得记忆的内容,返回空数组 []。`,
|
||||
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容,返回空数组 []。`,
|
||||
},
|
||||
};
|
||||
|
||||
// === Prompt Cache (SaaS OTA) ===
|
||||
|
||||
const PROMPT_CACHE_KEY = 'zclaw-prompt-cache';
|
||||
|
||||
interface CachedPrompt {
|
||||
name: string;
|
||||
version: number;
|
||||
source: string;
|
||||
system: string;
|
||||
userTemplate: string | null;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
/** 获取本地缓存的提示词版本号映射 */
|
||||
function loadPromptCache(): Record<string, CachedPrompt> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(PROMPT_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存提示词缓存到 localStorage */
|
||||
function savePromptCache(cache: Record<string, CachedPrompt>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(PROMPT_CACHE_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定提示词的系统提示词
|
||||
* 优先级:本地缓存 → 硬编码默认值
|
||||
*/
|
||||
export function getSystemPrompt(name: string): string {
|
||||
const cache = loadPromptCache();
|
||||
if (cache[name]?.system) {
|
||||
return cache[name].system;
|
||||
}
|
||||
return HARDCODED_PROMPTS[name]?.system ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定提示词的用户提示词模板
|
||||
* 优先级:本地缓存 → 硬编码默认值
|
||||
*/
|
||||
export function getUserPromptTemplate(name: string): string | ((arg: string) => string) | null {
|
||||
const cache = loadPromptCache();
|
||||
if (cache[name]) {
|
||||
const tmpl = cache[name].userTemplate;
|
||||
if (tmpl) return tmpl;
|
||||
}
|
||||
return HARDCODED_PROMPTS[name]?.user ?? null;
|
||||
}
|
||||
|
||||
/** 获取提示词当前版本号(本地缓存) */
|
||||
export function getPromptVersion(name: string): number {
|
||||
const cache = loadPromptCache();
|
||||
return cache[name]?.version ?? 0;
|
||||
}
|
||||
|
||||
/** 获取所有本地缓存的提示词版本(用于 OTA 检查) */
|
||||
export function getAllPromptVersions(): Record<string, number> {
|
||||
const cache = loadPromptCache();
|
||||
const versions: Record<string, number> = {};
|
||||
for (const [name, entry] of Object.entries(cache)) {
|
||||
versions[name] = entry.version;
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 SaaS OTA 更新到本地缓存
|
||||
* @param updates 从 SaaS 拉取的更新列表
|
||||
*/
|
||||
export function applyPromptUpdates(updates: Array<{
|
||||
name: string;
|
||||
version: number;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string | null;
|
||||
source: string;
|
||||
changelog?: string | null;
|
||||
}>): number {
|
||||
const cache = loadPromptCache();
|
||||
let applied = 0;
|
||||
|
||||
for (const update of updates) {
|
||||
cache[update.name] = {
|
||||
name: update.name,
|
||||
version: update.version,
|
||||
source: update.source,
|
||||
system: update.system_prompt,
|
||||
userTemplate: update.user_prompt_template,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (applied > 0) {
|
||||
savePromptCache(cache);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台异步检查 SaaS 提示词更新
|
||||
* 启动时和每 30 分钟调用一次
|
||||
*/
|
||||
let promptSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startPromptOTASync(deviceId: string): void {
|
||||
if (promptSyncTimer) return; // 已启动
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const doSync = async () => {
|
||||
try {
|
||||
const { saasClient } = await import('./saas-client');
|
||||
const { useSaaSStore } = await import('../store/saasStore');
|
||||
const { saasUrl, authToken } = useSaaSStore.getState();
|
||||
|
||||
if (!saasUrl || !authToken) return;
|
||||
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
|
||||
const versions = getAllPromptVersions();
|
||||
const result = await saasClient.checkPromptUpdates(deviceId, versions);
|
||||
|
||||
if (result.updates.length > 0) {
|
||||
const applied = applyPromptUpdates(result.updates);
|
||||
if (applied > 0) {
|
||||
console.log(`[Prompt OTA] 已更新 ${applied} 个提示词模板`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 静默失败,不影响正常使用
|
||||
console.debug('[Prompt OTA] 检查更新失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
doSync();
|
||||
// 每 30 分钟检查一次
|
||||
promptSyncTimer = setInterval(doSync, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function stopPromptOTASync(): void {
|
||||
if (promptSyncTimer) {
|
||||
clearInterval(promptSyncTimer);
|
||||
promptSyncTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保留向后兼容的 LLM_PROMPTS 导出(读取走 PromptCache)
|
||||
export const LLM_PROMPTS = {
|
||||
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
|
||||
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
|
||||
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
|
||||
};
|
||||
|
||||
// === Telemetry Integration ===
|
||||
|
||||
/**
|
||||
* 记录一次 LLM 调用结果到遥测收集器。
|
||||
* 所有 adapter 的 complete() 返回后应调用此函数。
|
||||
*/
|
||||
function trackLLMCall(
|
||||
adapter: LLMServiceAdapter,
|
||||
response: LLMResponse,
|
||||
error?: unknown,
|
||||
): void {
|
||||
try {
|
||||
const { recordLLMUsage } = require('./telemetry-collector');
|
||||
recordLLMUsage(
|
||||
response.model || adapter.getProvider(),
|
||||
response.tokensUsed?.input ?? 0,
|
||||
response.tokensUsed?.output ?? 0,
|
||||
{
|
||||
latencyMs: response.latencyMs,
|
||||
success: !error,
|
||||
errorType: error instanceof Error ? error.message.slice(0, 80) : undefined,
|
||||
connectionMode: adapter.getProvider() === 'saas' ? 'saas' : 'tauri',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// telemetry-collector may not be available (e.g., SSR)
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
export async function llmReflect(context: string, adapter?: LLMServiceAdapter): Promise<string> {
|
||||
@@ -641,9 +835,10 @@ export async function llmReflect(context: string, adapter?: LLMServiceAdapter):
|
||||
|
||||
const response = await llm.complete([
|
||||
{ role: 'system', content: LLM_PROMPTS.reflection.system },
|
||||
{ role: 'user', content: LLM_PROMPTS.reflection.user(context) },
|
||||
{ role: 'user', content: typeof LLM_PROMPTS.reflection.user === 'function' ? LLM_PROMPTS.reflection.user(context) : LLM_PROMPTS.reflection.user },
|
||||
]);
|
||||
|
||||
trackLLMCall(llm, response);
|
||||
return response.content;
|
||||
}
|
||||
|
||||
@@ -652,9 +847,10 @@ export async function llmCompact(messages: string, adapter?: LLMServiceAdapter):
|
||||
|
||||
const response = await llm.complete([
|
||||
{ role: 'system', content: LLM_PROMPTS.compaction.system },
|
||||
{ role: 'user', content: LLM_PROMPTS.compaction.user(messages) },
|
||||
{ role: 'user', content: typeof LLM_PROMPTS.compaction.user === 'function' ? LLM_PROMPTS.compaction.user(messages) : LLM_PROMPTS.compaction.user },
|
||||
]);
|
||||
|
||||
trackLLMCall(llm, response);
|
||||
return response.content;
|
||||
}
|
||||
|
||||
@@ -666,8 +862,9 @@ export async function llmExtract(
|
||||
|
||||
const response = await llm.complete([
|
||||
{ role: 'system', content: LLM_PROMPTS.extraction.system },
|
||||
{ role: 'user', content: LLM_PROMPTS.extraction.user(conversation) },
|
||||
{ role: 'user', content: typeof LLM_PROMPTS.extraction.user === 'function' ? LLM_PROMPTS.extraction.user(conversation) : LLM_PROMPTS.extraction.user },
|
||||
]);
|
||||
|
||||
trackLLMCall(llm, response);
|
||||
return response.content;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,282 @@ export interface ConfigSyncResult {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/** Paginated response wrapper */
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// === Prompt OTA Types ===
|
||||
|
||||
/** Prompt template info */
|
||||
export interface PromptTemplateInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string | null;
|
||||
source: string;
|
||||
current_version: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Prompt version info */
|
||||
export interface PromptVersionInfo {
|
||||
id: string;
|
||||
template_id: string;
|
||||
version: number;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string | null;
|
||||
variables: PromptVariable[];
|
||||
changelog: string | null;
|
||||
min_app_version: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Prompt variable definition */
|
||||
export interface PromptVariable {
|
||||
name: string;
|
||||
type: string;
|
||||
default_value?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** OTA update check result */
|
||||
export interface PromptCheckResult {
|
||||
updates: PromptUpdatePayload[];
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
/** Single OTA update payload */
|
||||
export interface PromptUpdatePayload {
|
||||
name: string;
|
||||
version: number;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string | null;
|
||||
variables: PromptVariable[];
|
||||
source: string;
|
||||
min_app_version: string | null;
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
/** Provider info from GET /api/v1/providers */
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_url: string;
|
||||
api_protocol: string;
|
||||
enabled: boolean;
|
||||
rate_limit_rpm: number | null;
|
||||
rate_limit_tpm: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create provider request */
|
||||
export interface CreateProviderRequest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_url: string;
|
||||
api_protocol?: string;
|
||||
api_key?: string;
|
||||
rate_limit_rpm?: number;
|
||||
rate_limit_tpm?: number;
|
||||
}
|
||||
|
||||
/** Update provider request */
|
||||
export interface UpdateProviderRequest {
|
||||
display_name?: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
rate_limit_rpm?: number;
|
||||
rate_limit_tpm?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Model info from GET /api/v1/models */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
alias: string;
|
||||
context_window: number;
|
||||
max_output_tokens: number;
|
||||
supports_streaming: boolean;
|
||||
supports_vision: boolean;
|
||||
enabled: boolean;
|
||||
pricing_input: number;
|
||||
pricing_output: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create model request */
|
||||
export interface CreateModelRequest {
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
alias: string;
|
||||
context_window?: number;
|
||||
max_output_tokens?: number;
|
||||
supports_streaming?: boolean;
|
||||
supports_vision?: boolean;
|
||||
pricing_input?: number;
|
||||
pricing_output?: number;
|
||||
}
|
||||
|
||||
/** Update model request */
|
||||
export interface UpdateModelRequest {
|
||||
alias?: string;
|
||||
context_window?: number;
|
||||
max_output_tokens?: number;
|
||||
supports_streaming?: boolean;
|
||||
supports_vision?: boolean;
|
||||
enabled?: boolean;
|
||||
pricing_input?: number;
|
||||
pricing_output?: number;
|
||||
}
|
||||
|
||||
/** Account API key info */
|
||||
export interface AccountApiKeyInfo {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
key_label: string | null;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create API key request */
|
||||
export interface CreateApiKeyRequest {
|
||||
provider_id: string;
|
||||
key_value: string;
|
||||
key_label?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/** Usage statistics */
|
||||
export interface UsageStats {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_requests: number;
|
||||
by_provider: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
|
||||
by_model: Record<string, { input_tokens: number; output_tokens: number; requests: number }>;
|
||||
daily: Array<{ date: string; input_tokens: number; output_tokens: number; requests: number }>;
|
||||
}
|
||||
|
||||
/** Account public info (extended) */
|
||||
export interface AccountPublic {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
totp_enabled: boolean;
|
||||
last_login_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Update account request */
|
||||
export interface UpdateAccountRequest {
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
/** Token info */
|
||||
export interface TokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
permissions: string[];
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/** Create token request */
|
||||
export interface CreateTokenRequest {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
expires_days?: number;
|
||||
}
|
||||
|
||||
/** Operation log info */
|
||||
export interface OperationLogInfo {
|
||||
id: number;
|
||||
account_id: string | null;
|
||||
action: string;
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Dashboard statistics */
|
||||
export interface DashboardStats {
|
||||
total_accounts: number;
|
||||
active_accounts: number;
|
||||
tasks_today: number;
|
||||
active_providers: number;
|
||||
active_models: number;
|
||||
tokens_today_input: number;
|
||||
tokens_today_output: number;
|
||||
}
|
||||
|
||||
/** Role info */
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create role request */
|
||||
export interface CreateRoleRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** Update role request */
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/** Permission template */
|
||||
export interface PermissionTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create template request */
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// === Error Class ===
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
@@ -258,6 +534,11 @@ export class SaaSClient {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
|
||||
private _isAuthEndpoint(path: string): boolean {
|
||||
return path.includes('/auth/login') || path.includes('/auth/register') || path.includes('/auth/refresh');
|
||||
}
|
||||
|
||||
// --- Core HTTP ---
|
||||
|
||||
/** Track whether the server appears reachable */
|
||||
@@ -278,6 +559,7 @@ export class SaaSClient {
|
||||
path: string,
|
||||
body?: unknown,
|
||||
timeoutMs = 15000,
|
||||
_isRefreshRetry = false,
|
||||
): Promise<T> {
|
||||
const maxRetries = 2;
|
||||
const baseDelay = 1000;
|
||||
@@ -300,8 +582,31 @@ export class SaaSClient {
|
||||
|
||||
this._serverReachable = true;
|
||||
|
||||
// Handle 401 specially - caller may want to trigger re-auth
|
||||
if (response.status === 401) {
|
||||
// 401: 尝试刷新 Token 后重试 (防止递归)
|
||||
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
if (newToken) {
|
||||
// Persist refreshed token to localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem('zclaw-saas-session');
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
session.token = newToken;
|
||||
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
return this.request<T>(method, path, body, timeoutMs, true);
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
// Token refresh failed — clear session and trigger logout
|
||||
try {
|
||||
const { clearSaaSSession } = require('./saas-client');
|
||||
clearSaaSSession();
|
||||
localStorage.removeItem('zclaw-connection-mode');
|
||||
} catch { /* non-blocking */ }
|
||||
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
|
||||
}
|
||||
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
||||
}
|
||||
|
||||
@@ -364,6 +669,8 @@ export class SaaSClient {
|
||||
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
|
||||
const body: Record<string, string> = { username, password };
|
||||
if (totpCode) body.totp_code = totpCode;
|
||||
// Clear stale token before login — avoid sending expired token on auth endpoint
|
||||
this.token = null;
|
||||
const data = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/login', body,
|
||||
);
|
||||
@@ -381,6 +688,8 @@ export class SaaSClient {
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}): Promise<SaaSLoginResponse> {
|
||||
// Clear stale token before register
|
||||
this.token = null;
|
||||
const result = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/register', data,
|
||||
);
|
||||
@@ -449,10 +758,14 @@ export class SaaSClient {
|
||||
|
||||
/**
|
||||
* Send a heartbeat to indicate the device is still active.
|
||||
* Also sends platform and app_version so the backend can detect client upgrades.
|
||||
*/
|
||||
async deviceHeartbeat(deviceId: string): Promise<void> {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
|
||||
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
|
||||
device_id: deviceId,
|
||||
platform: typeof navigator !== 'undefined' ? navigator.platform : undefined,
|
||||
app_version: appVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -460,7 +773,8 @@ export class SaaSClient {
|
||||
* List devices registered for the current account.
|
||||
*/
|
||||
async listDevices(): Promise<DeviceInfo[]> {
|
||||
return this.request<DeviceInfo[]>('GET', '/api/v1/devices');
|
||||
const res = await this.request<{ items: DeviceInfo[] }>('GET', '/api/v1/devices');
|
||||
return res.items;
|
||||
}
|
||||
|
||||
// --- Model Endpoints ---
|
||||
@@ -501,6 +815,7 @@ export class SaaSClient {
|
||||
* Send a chat completion request via the SaaS relay.
|
||||
* Returns the raw Response object to support both streaming and non-streaming.
|
||||
*
|
||||
* Includes one retry on 401 (auto token refresh) and on network errors.
|
||||
* The caller is responsible for:
|
||||
* - Reading the response body (JSON or SSE stream)
|
||||
* - Handling errors from the response
|
||||
@@ -509,27 +824,59 @@ export class SaaSClient {
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const maxAttempts = 2; // 1 initial + 1 retry
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// Use caller's AbortSignal if provided, otherwise default 5min timeout
|
||||
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v1/relay/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: effectiveSignal,
|
||||
},
|
||||
);
|
||||
|
||||
// On 401, attempt token refresh once
|
||||
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
if (newToken) continue; // Retry with refreshed token
|
||||
} catch {
|
||||
// Refresh failed, return the 401 response
|
||||
}
|
||||
}
|
||||
|
||||
this._serverReachable = true;
|
||||
return response;
|
||||
} catch (err: unknown) {
|
||||
this._serverReachable = false;
|
||||
const isNetworkError = err instanceof TypeError
|
||||
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
|
||||
|
||||
if (isNetworkError && attempt < maxAttempts - 1) {
|
||||
// Brief backoff before retry
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Use caller's AbortSignal if provided, otherwise default 5min timeout
|
||||
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v1/relay/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: effectiveSignal,
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
// Unreachable but TypeScript needs it
|
||||
throw new Error('chatCompletion: all attempts exhausted');
|
||||
}
|
||||
|
||||
// --- Config Endpoints ---
|
||||
@@ -539,7 +886,8 @@ export class SaaSClient {
|
||||
*/
|
||||
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
|
||||
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
|
||||
const res = await this.request<{ items: SaaSConfigItem[] }>('GET', `/api/v1/config/items${qs}`);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
/** Compute config diff between client and SaaS (read-only) */
|
||||
@@ -551,6 +899,302 @@ export class SaaSClient {
|
||||
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
|
||||
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull all config items from SaaS (for startup auto-sync).
|
||||
* Returns configs updated since the given timestamp, or all if since is omitted.
|
||||
*/
|
||||
async pullConfig(since?: string): Promise<{
|
||||
configs: Array<{
|
||||
key: string
|
||||
category: string
|
||||
value: string | null
|
||||
value_type: string
|
||||
default: string | null
|
||||
updated_at: string
|
||||
}>
|
||||
pulled_at: string
|
||||
}> {
|
||||
const qs = since ? `?since=${encodeURIComponent(since)}` : '';
|
||||
return this.request('GET', '/api/v1/config/pull' + qs);
|
||||
}
|
||||
|
||||
// --- Provider Management (Admin) ---
|
||||
|
||||
/** List all providers */
|
||||
async listProviders(): Promise<ProviderInfo[]> {
|
||||
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
|
||||
}
|
||||
|
||||
/** Get provider by ID */
|
||||
async getProvider(id: string): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
|
||||
}
|
||||
|
||||
/** Create a new provider (admin only) */
|
||||
async createProvider(data: CreateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
|
||||
}
|
||||
|
||||
/** Update a provider (admin only) */
|
||||
async updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
|
||||
}
|
||||
|
||||
/** Delete a provider (admin only) */
|
||||
async deleteProvider(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
|
||||
}
|
||||
|
||||
// --- Model Management (Admin) ---
|
||||
|
||||
/** List models, optionally filtered by provider */
|
||||
async listModelsAdmin(providerId?: string): Promise<ModelInfo[]> {
|
||||
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
|
||||
}
|
||||
|
||||
/** Get model by ID */
|
||||
async getModel(id: string): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
|
||||
}
|
||||
|
||||
/** Create a new model (admin only) */
|
||||
async createModel(data: CreateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('POST', '/api/v1/models', data);
|
||||
}
|
||||
|
||||
/** Update a model (admin only) */
|
||||
async updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
|
||||
}
|
||||
|
||||
/** Delete a model (admin only) */
|
||||
async deleteModel(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/models/${id}`);
|
||||
}
|
||||
|
||||
// --- Account API Keys ---
|
||||
|
||||
/** List account's API keys */
|
||||
async listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]> {
|
||||
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
|
||||
}
|
||||
|
||||
/** Create a new API key */
|
||||
async createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
|
||||
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
|
||||
}
|
||||
|
||||
/** Rotate an API key */
|
||||
async rotateApiKey(id: string, newKeyValue: string): Promise<void> {
|
||||
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
|
||||
}
|
||||
|
||||
/** Revoke an API key */
|
||||
async revokeApiKey(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
|
||||
}
|
||||
|
||||
// --- Usage Statistics ---
|
||||
|
||||
/** Get usage statistics for current account */
|
||||
async getUsage(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.from) qs.set('from', params.from);
|
||||
if (params?.to) qs.set('to', params.to);
|
||||
if (params?.provider_id) qs.set('provider_id', params.provider_id);
|
||||
if (params?.model_id) qs.set('model_id', params.model_id);
|
||||
const query = qs.toString();
|
||||
return this.request<UsageStats>('GET', `/api/v1/usage${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
// --- Account Management (Admin) ---
|
||||
|
||||
/** List all accounts (admin only) */
|
||||
async listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.page_size) qs.set('page_size', String(params.page_size));
|
||||
if (params?.role) qs.set('role', params.role);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
const query = qs.toString();
|
||||
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
/** Get account by ID (admin or self) */
|
||||
async getAccount(id: string): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
|
||||
}
|
||||
|
||||
/** Update account (admin or self) */
|
||||
async updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
|
||||
}
|
||||
|
||||
/** Update account status (admin only) */
|
||||
async updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
|
||||
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
|
||||
}
|
||||
|
||||
// --- API Token Management ---
|
||||
|
||||
/** List API tokens for current account */
|
||||
async listTokens(): Promise<TokenInfo[]> {
|
||||
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
|
||||
}
|
||||
|
||||
/** Create a new API token */
|
||||
async createToken(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
|
||||
}
|
||||
|
||||
/** Revoke an API token */
|
||||
async revokeToken(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
|
||||
}
|
||||
|
||||
// --- Operation Logs (Admin) ---
|
||||
|
||||
/** List operation logs (admin only) */
|
||||
async listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.page_size) qs.set('page_size', String(params.page_size));
|
||||
const query = qs.toString();
|
||||
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
// --- Dashboard Statistics (Admin) ---
|
||||
|
||||
/** Get dashboard statistics (admin only) */
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
|
||||
}
|
||||
|
||||
// --- Role Management (Admin) ---
|
||||
|
||||
/** List all roles */
|
||||
async listRoles(): Promise<RoleInfo[]> {
|
||||
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
|
||||
}
|
||||
|
||||
/** Get role by ID */
|
||||
async getRole(id: string): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
|
||||
}
|
||||
|
||||
/** Create a new role (admin only) */
|
||||
async createRole(data: CreateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
|
||||
}
|
||||
|
||||
/** Update a role (admin only) */
|
||||
async updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
|
||||
}
|
||||
|
||||
/** Delete a role (admin only) */
|
||||
async deleteRole(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
|
||||
}
|
||||
|
||||
// --- Permission Templates ---
|
||||
|
||||
/** List permission templates */
|
||||
async listPermissionTemplates(): Promise<PermissionTemplate[]> {
|
||||
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
|
||||
}
|
||||
|
||||
/** Get permission template by ID */
|
||||
async getPermissionTemplate(id: string): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
|
||||
}
|
||||
|
||||
/** Create a permission template (admin only) */
|
||||
async createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
|
||||
}
|
||||
|
||||
/** Delete a permission template (admin only) */
|
||||
async deletePermissionTemplate(id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
|
||||
}
|
||||
|
||||
/** Apply permission template to accounts (admin only) */
|
||||
async applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
|
||||
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
|
||||
}
|
||||
|
||||
// === Prompt OTA ===
|
||||
|
||||
/** Check for prompt updates (OTA) */
|
||||
async checkPromptUpdates(deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult> {
|
||||
return this.request<PromptCheckResult>('POST', '/api/v1/prompts/check', {
|
||||
device_id: deviceId,
|
||||
versions: currentVersions,
|
||||
});
|
||||
}
|
||||
|
||||
/** List all prompt templates */
|
||||
async listPrompts(params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise<PaginatedResponse<PromptTemplateInfo>> {
|
||||
const qs = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
|
||||
return this.request<PaginatedResponse<PromptTemplateInfo>>('GET', `/api/v1/prompts${qs}`);
|
||||
}
|
||||
|
||||
/** Get prompt template by name */
|
||||
async getPrompt(name: string): Promise<PromptTemplateInfo> {
|
||||
return this.request<PromptTemplateInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
/** List prompt versions */
|
||||
async listPromptVersions(name: string): Promise<PromptVersionInfo[]> {
|
||||
return this.request<PromptVersionInfo[]>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions`);
|
||||
}
|
||||
|
||||
/** Get specific prompt version */
|
||||
async getPromptVersion(name: string, version: number): Promise<PromptVersionInfo> {
|
||||
return this.request<PromptVersionInfo>('GET', `/api/v1/prompts/${encodeURIComponent(name)}/versions/${version}`);
|
||||
}
|
||||
|
||||
// === Telemetry ===
|
||||
|
||||
/** Report anonymous usage telemetry (token counts only, no content) */
|
||||
async reportTelemetry(data: {
|
||||
device_id: string;
|
||||
app_version: string;
|
||||
entries: Array<{
|
||||
model_id: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
latency_ms?: number;
|
||||
success: boolean;
|
||||
error_type?: string;
|
||||
timestamp: string;
|
||||
connection_mode: string;
|
||||
}>;
|
||||
}): Promise<{ accepted: number; rejected: number }> {
|
||||
return this.request<{ accepted: number; rejected: number }>(
|
||||
'POST', '/api/v1/telemetry/report', data,
|
||||
);
|
||||
}
|
||||
|
||||
/** Report audit log summary (action types and counts only, no content) */
|
||||
async reportAuditSummary(data: {
|
||||
device_id: string;
|
||||
entries: Array<{
|
||||
action: string;
|
||||
target: string;
|
||||
result: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}): Promise<{ accepted: number; total: number }> {
|
||||
return this.request<{ accepted: number; total: number }>(
|
||||
'POST', '/api/v1/telemetry/audit', data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateMasterKey,
|
||||
generateSalt,
|
||||
arrayToBase64,
|
||||
base64ToArray,
|
||||
} from './crypto-utils';
|
||||
|
||||
// Cache for keyring availability check
|
||||
@@ -27,9 +30,6 @@ let keyringAvailable: boolean | null = null;
|
||||
const ENCRYPTED_PREFIX = 'enc_';
|
||||
const MASTER_KEY_NAME = 'zclaw-master-key';
|
||||
|
||||
// Cache for the derived crypto key
|
||||
let cachedCryptoKey: CryptoKey | null = null;
|
||||
|
||||
/**
|
||||
* Check if secure storage (keyring) is available
|
||||
*/
|
||||
@@ -138,25 +138,6 @@ export const secureStorage = {
|
||||
* Now with AES-GCM encryption for non-Tauri environments
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get or create the master encryption key for localStorage fallback
|
||||
*/
|
||||
async function getOrCreateMasterKey(): Promise<CryptoKey> {
|
||||
if (cachedCryptoKey) {
|
||||
return cachedCryptoKey;
|
||||
}
|
||||
|
||||
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
|
||||
|
||||
if (!masterKeyRaw) {
|
||||
masterKeyRaw = generateMasterKey();
|
||||
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
|
||||
}
|
||||
|
||||
cachedCryptoKey = await deriveKey(masterKeyRaw);
|
||||
return cachedCryptoKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored value is encrypted (has iv and data fields)
|
||||
*/
|
||||
@@ -169,8 +150,21 @@ function isEncrypted(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored value uses the v2 format (with random salt)
|
||||
*/
|
||||
function isV2Encrypted(value: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && parsed.version === 2 && typeof parsed.salt === 'string' && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write encrypted data to localStorage
|
||||
* Uses random salt per encryption (v2 format) for forward secrecy
|
||||
*/
|
||||
async function writeEncryptedLocalStorage(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
@@ -178,45 +172,78 @@ async function writeEncryptedLocalStorage(key: string, value: string): Promise<v
|
||||
|
||||
if (!value) {
|
||||
localStorage.removeItem(encryptedKey);
|
||||
// Also remove legacy unencrypted key
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cryptoKey = await getOrCreateMasterKey();
|
||||
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
|
||||
if (!masterKeyRaw) {
|
||||
masterKeyRaw = generateMasterKey();
|
||||
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
|
||||
}
|
||||
|
||||
// Generate a random salt for each encryption (v2 format)
|
||||
const salt = generateSalt(16);
|
||||
const cryptoKey = await deriveKey(masterKeyRaw, salt);
|
||||
const encrypted = await encrypt(value, cryptoKey);
|
||||
localStorage.setItem(encryptedKey, JSON.stringify(encrypted));
|
||||
// Remove legacy unencrypted key if it exists
|
||||
|
||||
const encryptedPayload = {
|
||||
version: 2,
|
||||
salt: arrayToBase64(salt),
|
||||
iv: encrypted.iv,
|
||||
data: encrypted.data,
|
||||
};
|
||||
localStorage.setItem(encryptedKey, JSON.stringify(encryptedPayload));
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Encryption failed:', error);
|
||||
// Fallback to plaintext if encryption fails (should not happen)
|
||||
localStorage.setItem(key, value);
|
||||
// Do NOT fall back to plaintext — throw to signal the error
|
||||
throw error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to write encrypted localStorage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decrypt data from localStorage
|
||||
* Supports both encrypted and legacy unencrypted formats
|
||||
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
|
||||
*/
|
||||
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
||||
try {
|
||||
// Try encrypted key first
|
||||
const encryptedKey = ENCRYPTED_PREFIX + key;
|
||||
const encryptedRaw = localStorage.getItem(encryptedKey);
|
||||
|
||||
if (encryptedRaw && isEncrypted(encryptedRaw)) {
|
||||
try {
|
||||
const cryptoKey = await getOrCreateMasterKey();
|
||||
const encrypted = JSON.parse(encryptedRaw);
|
||||
return await decrypt(encrypted, cryptoKey);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Decryption failed:', error);
|
||||
// Fall through to try legacy key
|
||||
if (encryptedRaw) {
|
||||
const masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
|
||||
|
||||
// Try v2 format (random salt)
|
||||
if (masterKeyRaw && isV2Encrypted(encryptedRaw)) {
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedRaw);
|
||||
const salt = base64ToArray(parsed.salt);
|
||||
const cryptoKey = await deriveKey(masterKeyRaw, salt);
|
||||
return await decrypt(
|
||||
{ iv: parsed.iv, data: parsed.data },
|
||||
cryptoKey,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] v2 decryption failed:', error);
|
||||
// Fall through to try v1
|
||||
}
|
||||
}
|
||||
|
||||
// Try v1 format (static salt, backward compat)
|
||||
if (masterKeyRaw && isEncrypted(encryptedRaw)) {
|
||||
try {
|
||||
const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt
|
||||
const encrypted = JSON.parse(encryptedRaw);
|
||||
return await decrypt(encrypted, cryptoKey);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] v1 decryption failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Configuration changes
|
||||
*/
|
||||
|
||||
import { hashSha256 } from './crypto-utils';
|
||||
import { hashSha256, generateRandomString } from './crypto-utils';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -90,7 +90,7 @@ let currentSessionId: string | null = null;
|
||||
* Generate a unique event ID
|
||||
*/
|
||||
function generateEventId(): string {
|
||||
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `evt_${Date.now()}_${generateRandomString(8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
209
desktop/src/lib/telemetry-collector.ts
Normal file
209
desktop/src/lib/telemetry-collector.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Telemetry Collector — 桌面端遥测收集器
|
||||
*
|
||||
* 收集本地 LLM 调用的 Token 用量统计和审计日志摘要(均无内容),
|
||||
* 定期批量上报到 SaaS。
|
||||
*
|
||||
* 用量缓冲区上限 100 条,审计缓冲区上限 200 条,超限自动 flush。
|
||||
* 定时 flush 每 5 分钟。仅在 SaaS 已登录时上报。
|
||||
*/
|
||||
|
||||
import { saasClient } from './saas-client';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('TelemetryCollector');
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface TelemetryEntry {
|
||||
model_id: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
latency_ms?: number;
|
||||
success: boolean;
|
||||
error_type?: string;
|
||||
timestamp: string;
|
||||
connection_mode: string;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
action: string;
|
||||
target: string;
|
||||
result: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// === State ===
|
||||
|
||||
const USAGE_BUFFER_LIMIT = 100;
|
||||
const AUDIT_BUFFER_LIMIT = 200;
|
||||
const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
let usageBuffer: TelemetryEntry[] = [];
|
||||
let auditBuffer: AuditEntry[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let deviceId: string | null = null;
|
||||
|
||||
// === Public API ===
|
||||
|
||||
/**
|
||||
* 初始化遥测收集器(在 SaaS 登录后调用)。
|
||||
* @param devId 设备 ID(与 saasStore 使用的相同)
|
||||
*/
|
||||
export function initTelemetryCollector(devId: string): void {
|
||||
deviceId = devId;
|
||||
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
}
|
||||
|
||||
flushTimer = setInterval(() => {
|
||||
flushAll().catch((err: unknown) => {
|
||||
log.warn('Scheduled telemetry flush failed:', err);
|
||||
});
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
log.info('Telemetry collector initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止遥测收集器(在 SaaS 登出时调用)。
|
||||
* 会尝试 flush 剩余条目。
|
||||
*/
|
||||
export function stopTelemetryCollector(): void {
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
|
||||
// 尝试最后一次 flush
|
||||
flushAll().catch(() => {
|
||||
// 登出时不阻塞
|
||||
});
|
||||
|
||||
usageBuffer = [];
|
||||
auditBuffer = [];
|
||||
deviceId = null;
|
||||
log.info('Telemetry collector stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次 LLM 调用的用量。
|
||||
*
|
||||
* @param modelId 模型标识
|
||||
* @param inputTokens 输入 Token 数
|
||||
* @param outputTokens 输出 Token 数
|
||||
* @param options 可选参数
|
||||
*/
|
||||
export function recordLLMUsage(
|
||||
modelId: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
options?: {
|
||||
latencyMs?: number;
|
||||
success?: boolean;
|
||||
errorType?: string;
|
||||
connectionMode?: string;
|
||||
},
|
||||
): void {
|
||||
if (!deviceId) return;
|
||||
|
||||
usageBuffer.push({
|
||||
model_id: modelId,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
latency_ms: options?.latencyMs,
|
||||
success: options?.success ?? true,
|
||||
error_type: options?.errorType,
|
||||
timestamp: new Date().toISOString(),
|
||||
connection_mode: options?.connectionMode || 'tauri',
|
||||
});
|
||||
|
||||
if (usageBuffer.length >= USAGE_BUFFER_LIMIT) {
|
||||
flushUsage().catch((err: unknown) => {
|
||||
log.warn('Auto-flush usage triggered but failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条审计日志摘要(仅操作类型,无内容)。
|
||||
*
|
||||
* @param action 操作类型(如 "hand.trigger", "agent.create")
|
||||
* @param target 操作目标(如 Agent/Hand 名称)
|
||||
* @param result 操作结果
|
||||
*/
|
||||
export function recordAuditEvent(
|
||||
action: string,
|
||||
target: string,
|
||||
result: 'success' | 'failure' | 'pending',
|
||||
): void {
|
||||
if (!deviceId) return;
|
||||
|
||||
auditBuffer.push({
|
||||
action,
|
||||
target,
|
||||
result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (auditBuffer.length >= AUDIT_BUFFER_LIMIT) {
|
||||
flushAudit().catch((err: unknown) => {
|
||||
log.warn('Auto-flush audit triggered but failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
async function flushAll(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
flushUsage(),
|
||||
flushAudit(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function flushUsage(): Promise<void> {
|
||||
if (usageBuffer.length === 0 || !deviceId || !saasClient.isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = usageBuffer;
|
||||
usageBuffer = [];
|
||||
|
||||
try {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
|
||||
|
||||
const result = await saasClient.reportTelemetry({
|
||||
device_id: deviceId,
|
||||
app_version: appVersion,
|
||||
entries,
|
||||
});
|
||||
|
||||
log.info(`Usage telemetry flushed: ${result.accepted} accepted, ${result.rejected} rejected`);
|
||||
} catch (err: unknown) {
|
||||
usageBuffer = [...entries, ...usageBuffer].slice(0, USAGE_BUFFER_LIMIT * 2);
|
||||
log.warn('Usage telemetry flush failed, entries re-buffered:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAudit(): Promise<void> {
|
||||
if (auditBuffer.length === 0 || !deviceId || !saasClient.isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = auditBuffer;
|
||||
auditBuffer = [];
|
||||
|
||||
try {
|
||||
const result = await saasClient.reportAuditSummary({
|
||||
device_id: deviceId,
|
||||
entries,
|
||||
});
|
||||
|
||||
log.info(`Audit summary flushed: ${result.accepted} accepted`);
|
||||
} catch (err: unknown) {
|
||||
auditBuffer = [...entries, ...auditBuffer].slice(0, AUDIT_BUFFER_LIMIT * 2);
|
||||
log.warn('Audit summary flush failed, entries re-buffered:', err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user