chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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',
},

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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 ===

View File

@@ -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;
}

View File

@@ -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 ===

View File

@@ -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);
}
}
}

View File

@@ -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)}`;
}
/**

View 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);
}
}