fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
*
|
||||
* 为 ZCLAW 前端操作提供统一的审计日志记录功能。
|
||||
* 记录关键操作(Hand 触发、Agent 创建等)到本地存储。
|
||||
*
|
||||
* @reserved This module is reserved for future audit logging integration.
|
||||
* It is not currently imported by any component. When audit logging is needed,
|
||||
* import { logAudit, logAuditSuccess, logAuditFailure } from this module.
|
||||
*/
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
142
desktop/src/lib/classroom-adapter.ts
Normal file
142
desktop/src/lib/classroom-adapter.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Classroom Adapter
|
||||
*
|
||||
* Bridges the old ClassroomData type (ClassroomPreviewer) with the new
|
||||
* Classroom type (ClassroomPlayer + Tauri backend).
|
||||
*/
|
||||
|
||||
import type { Classroom, GeneratedScene } from '../types/classroom';
|
||||
import { SceneType, TeachingStyle, DifficultyLevel } from '../types/classroom';
|
||||
import type { ClassroomData, ClassroomScene } from '../components/ClassroomPreviewer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Old → New (ClassroomData → Classroom)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a legacy ClassroomData to the new Classroom format.
|
||||
* Used when opening ClassroomPlayer from Pipeline result previews.
|
||||
*/
|
||||
export function adaptToClassroom(data: ClassroomData): Classroom {
|
||||
const scenes: GeneratedScene[] = data.scenes.map((scene, index) => ({
|
||||
id: scene.id,
|
||||
outlineId: `outline-${index}`,
|
||||
content: {
|
||||
title: scene.title,
|
||||
sceneType: mapSceneType(scene.type),
|
||||
content: {
|
||||
heading: scene.content.heading ?? scene.title,
|
||||
key_points: scene.content.bullets ?? [],
|
||||
description: scene.content.explanation,
|
||||
quiz: scene.content.quiz ?? undefined,
|
||||
},
|
||||
actions: [],
|
||||
durationSeconds: scene.duration ?? 60,
|
||||
notes: scene.narration,
|
||||
},
|
||||
order: index,
|
||||
})) as GeneratedScene[];
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
description: data.subject,
|
||||
topic: data.subject,
|
||||
style: TeachingStyle.Lecture,
|
||||
level: mapDifficulty(data.difficulty),
|
||||
totalDuration: data.duration * 60,
|
||||
objectives: [],
|
||||
scenes,
|
||||
agents: [],
|
||||
metadata: {
|
||||
generatedAt: new Date(data.createdAt).getTime(),
|
||||
version: '1.0',
|
||||
custom: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New → Old (Classroom → ClassroomData)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a new Classroom to the legacy ClassroomData format.
|
||||
* Used when rendering ClassroomPreviewer from new pipeline results.
|
||||
*/
|
||||
export function adaptToClassroomData(classroom: Classroom): ClassroomData {
|
||||
const scenes: ClassroomScene[] = classroom.scenes.map((scene) => {
|
||||
const data = scene.content.content as Record<string, unknown>;
|
||||
return {
|
||||
id: scene.id,
|
||||
title: scene.content.title,
|
||||
type: mapToLegacySceneType(scene.content.sceneType),
|
||||
content: {
|
||||
heading: (data?.heading as string) ?? scene.content.title,
|
||||
bullets: (data?.key_points as string[]) ?? [],
|
||||
explanation: (data?.description as string) ?? '',
|
||||
quiz: (data?.quiz as ClassroomScene['content']['quiz']) ?? undefined,
|
||||
},
|
||||
narration: scene.content.notes,
|
||||
duration: scene.content.durationSeconds,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: classroom.id,
|
||||
title: classroom.title,
|
||||
subject: classroom.topic,
|
||||
difficulty: mapToLegacyDifficulty(classroom.level),
|
||||
duration: Math.ceil(classroom.totalDuration / 60),
|
||||
scenes,
|
||||
outline: {
|
||||
sections: classroom.scenes.map((scene) => ({
|
||||
title: scene.content.title,
|
||||
scenes: [scene.id],
|
||||
})),
|
||||
},
|
||||
createdAt: new Date(classroom.metadata.generatedAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapSceneType(type: ClassroomScene['type']): SceneType {
|
||||
switch (type) {
|
||||
case 'title': return SceneType.Slide;
|
||||
case 'content': return SceneType.Slide;
|
||||
case 'quiz': return SceneType.Quiz;
|
||||
case 'interactive': return SceneType.Interactive;
|
||||
case 'summary': return SceneType.Text;
|
||||
default: return SceneType.Slide;
|
||||
}
|
||||
}
|
||||
|
||||
function mapToLegacySceneType(sceneType: string): ClassroomScene['type'] {
|
||||
switch (sceneType) {
|
||||
case 'quiz': return 'quiz';
|
||||
case 'interactive': return 'interactive';
|
||||
case 'text': return 'summary';
|
||||
default: return 'content';
|
||||
}
|
||||
}
|
||||
|
||||
function mapDifficulty(difficulty: string): DifficultyLevel {
|
||||
switch (difficulty) {
|
||||
case '初级': return DifficultyLevel.Beginner;
|
||||
case '中级': return DifficultyLevel.Intermediate;
|
||||
case '高级': return DifficultyLevel.Advanced;
|
||||
default: return DifficultyLevel.Intermediate;
|
||||
}
|
||||
}
|
||||
|
||||
function mapToLegacyDifficulty(level: string): ClassroomData['difficulty'] {
|
||||
switch (level) {
|
||||
case 'beginner': return '初级';
|
||||
case 'advanced': return '高级';
|
||||
case 'expert': return '高级';
|
||||
default: return '中级';
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,19 @@ function initErrorStore(): void {
|
||||
errors: [],
|
||||
|
||||
addError: (error: AppError) => {
|
||||
// Dedup: skip if same title+message already exists and undismissed
|
||||
const isDuplicate = errorStore.errors.some(
|
||||
(e) => !e.dismissed && e.title === error.title && e.message === error.message
|
||||
);
|
||||
if (isDuplicate) return;
|
||||
|
||||
const storedError: StoredError = {
|
||||
...error,
|
||||
dismissed: false,
|
||||
reported: false,
|
||||
};
|
||||
errorStore.errors = [storedError, ...errorStore.errors];
|
||||
// Cap at 50 errors to prevent unbounded growth
|
||||
errorStore.errors = [storedError, ...errorStore.errors].slice(0, 50);
|
||||
// Notify listeners
|
||||
notifyErrorListeners(error);
|
||||
},
|
||||
|
||||
@@ -103,6 +103,12 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
callbacks.onDelta(streamEvent.delta);
|
||||
break;
|
||||
|
||||
case 'thinkingDelta':
|
||||
if (callbacks.onThinkingDelta) {
|
||||
callbacks.onThinkingDelta(streamEvent.delta);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
log.debug('Tool started:', streamEvent.name, streamEvent.input);
|
||||
if (callbacks.onTool) {
|
||||
|
||||
@@ -5,8 +5,20 @@
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { createLogger } from './logger';
|
||||
import type { KernelClient } from './kernel-client';
|
||||
|
||||
const log = createLogger('KernelHands');
|
||||
|
||||
/** Payload emitted by the Rust backend on `hand-execution-complete` events. */
|
||||
export interface HandExecutionCompletePayload {
|
||||
approvalId: string;
|
||||
handId: string;
|
||||
success: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
|
||||
const proto = ClientClass.prototype as any;
|
||||
|
||||
@@ -92,7 +104,7 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
*/
|
||||
proto.getHandStatus = async function (this: KernelClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
|
||||
try {
|
||||
return await invoke('hand_run_status', { handName: name, runId });
|
||||
return await invoke('hand_run_status', { runId });
|
||||
} catch (e) {
|
||||
const { createLogger } = await import('./logger');
|
||||
createLogger('KernelHands').debug('hand_run_status failed', { name, runId, error: e });
|
||||
@@ -171,4 +183,26 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
proto.respondToApproval = async function (this: KernelClient, approvalId: string, approved: boolean, reason?: string): Promise<void> {
|
||||
return invoke('approval_respond', { id: approvalId, approved, reason });
|
||||
};
|
||||
|
||||
// ─── Event Listeners ───
|
||||
|
||||
/**
|
||||
* Listen for `hand-execution-complete` events emitted by the Rust backend
|
||||
* after a hand finishes executing (both from direct trigger and approval flow).
|
||||
*
|
||||
* Returns an unlisten function for cleanup.
|
||||
*/
|
||||
proto.onHandExecutionComplete = async function (
|
||||
this: KernelClient,
|
||||
callback: (payload: HandExecutionCompletePayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
const unlisten = await listen<HandExecutionCompletePayload>(
|
||||
'hand-execution-complete',
|
||||
(event) => {
|
||||
log.debug('hand-execution-complete', event.payload);
|
||||
callback(event.payload);
|
||||
},
|
||||
);
|
||||
return unlisten;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,11 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v
|
||||
}> {
|
||||
return invoke('skill_execute', {
|
||||
id,
|
||||
context: {},
|
||||
context: {
|
||||
agentId: '',
|
||||
sessionId: '',
|
||||
workingDir: '',
|
||||
},
|
||||
input: input || {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -96,7 +96,12 @@ export function installTriggerMethods(ClientClass: { prototype: KernelClient }):
|
||||
triggerType?: TriggerTypeSpec;
|
||||
}): Promise<TriggerItem> {
|
||||
try {
|
||||
return await invoke<TriggerItem>('trigger_update', { id, updates });
|
||||
return await invoke<TriggerItem>('trigger_update', {
|
||||
id,
|
||||
name: updates.name,
|
||||
enabled: updates.enabled,
|
||||
handId: updates.handId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log('error', `[TriggersAPI] updateTrigger(${id}) failed: ${this.formatError(error)}`);
|
||||
throw error;
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface EventCallback {
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onDelta: (delta: string) => void;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onTool?: (tool: string, input: string, output: string) => void;
|
||||
onHand?: (name: string, status: string, result?: unknown) => void;
|
||||
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
||||
@@ -71,6 +72,11 @@ export interface StreamEventDelta {
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface StreamEventThinkingDelta {
|
||||
type: 'thinkingDelta';
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface StreamEventToolStart {
|
||||
type: 'tool_start';
|
||||
name: string;
|
||||
@@ -114,6 +120,7 @@ export interface StreamEventHandEnd {
|
||||
|
||||
export type StreamChatEvent =
|
||||
| StreamEventDelta
|
||||
| StreamEventThinkingDelta
|
||||
| StreamEventToolStart
|
||||
| StreamEventToolEnd
|
||||
| StreamEventIterationStart
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* SaaS Admin Methods — Mixin
|
||||
*
|
||||
* Installs admin panel API methods onto SaaSClient.prototype.
|
||||
* Uses the same mixin pattern as gateway-api.ts.
|
||||
*
|
||||
* Reserved for future admin UI (Next.js admin dashboard).
|
||||
* These methods are not called by the desktop app but are kept as thin API
|
||||
* wrappers for when the admin panel is built.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderInfo,
|
||||
CreateProviderRequest,
|
||||
UpdateProviderRequest,
|
||||
ModelInfo,
|
||||
CreateModelRequest,
|
||||
UpdateModelRequest,
|
||||
AccountApiKeyInfo,
|
||||
CreateApiKeyRequest,
|
||||
AccountPublic,
|
||||
UpdateAccountRequest,
|
||||
PaginatedResponse,
|
||||
TokenInfo,
|
||||
CreateTokenRequest,
|
||||
OperationLogInfo,
|
||||
DashboardStats,
|
||||
RoleInfo,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
PermissionTemplate,
|
||||
CreateTemplateRequest,
|
||||
} from './saas-types';
|
||||
|
||||
export function installAdminMethods(ClientClass: { prototype: any }): void {
|
||||
const proto = ClientClass.prototype;
|
||||
|
||||
// --- Provider Management (Admin) ---
|
||||
|
||||
/** List all providers */
|
||||
proto.listProviders = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<ProviderInfo[]> {
|
||||
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
|
||||
};
|
||||
|
||||
/** Get provider by ID */
|
||||
proto.getProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new provider (admin only) */
|
||||
proto.createProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
|
||||
};
|
||||
|
||||
/** Update a provider (admin only) */
|
||||
proto.updateProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
|
||||
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a provider (admin only) */
|
||||
proto.deleteProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
|
||||
};
|
||||
|
||||
// --- Model Management (Admin) ---
|
||||
|
||||
/** List models, optionally filtered by provider */
|
||||
proto.listModelsAdmin = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<ModelInfo[]> {
|
||||
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
|
||||
};
|
||||
|
||||
/** Get model by ID */
|
||||
proto.getModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new model (admin only) */
|
||||
proto.createModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('POST', '/api/v1/models', data);
|
||||
};
|
||||
|
||||
/** Update a model (admin only) */
|
||||
proto.updateModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateModelRequest): Promise<ModelInfo> {
|
||||
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a model (admin only) */
|
||||
proto.deleteModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/models/${id}`);
|
||||
};
|
||||
|
||||
// --- Account API Keys ---
|
||||
|
||||
/** List account's API keys */
|
||||
proto.listApiKeys = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, 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 */
|
||||
proto.createApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
|
||||
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
|
||||
};
|
||||
|
||||
/** Rotate an API key */
|
||||
proto.rotateApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, newKeyValue: string): Promise<void> {
|
||||
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
|
||||
};
|
||||
|
||||
/** Revoke an API key */
|
||||
proto.revokeApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
|
||||
};
|
||||
|
||||
// --- Account Management (Admin) ---
|
||||
|
||||
/** List all accounts (admin only) */
|
||||
proto.listAccounts = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, 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) */
|
||||
proto.getAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
|
||||
};
|
||||
|
||||
/** Update account (admin or self) */
|
||||
proto.updateAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
|
||||
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
|
||||
};
|
||||
|
||||
/** Update account status (admin only) */
|
||||
proto.updateAccountStatus = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, 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 */
|
||||
proto.listTokens = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<TokenInfo[]> {
|
||||
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
|
||||
};
|
||||
|
||||
/** Create a new API token */
|
||||
proto.createToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
|
||||
};
|
||||
|
||||
/** Revoke an API token */
|
||||
proto.revokeToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
|
||||
};
|
||||
|
||||
// --- Operation Logs (Admin) ---
|
||||
|
||||
/** List operation logs (admin only) */
|
||||
proto.listOperationLogs = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, 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) */
|
||||
proto.getDashboardStats = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<DashboardStats> {
|
||||
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
|
||||
};
|
||||
|
||||
// --- Role Management (Admin) ---
|
||||
|
||||
/** List all roles */
|
||||
proto.listRoles = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<RoleInfo[]> {
|
||||
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
|
||||
};
|
||||
|
||||
/** Get role by ID */
|
||||
proto.getRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
|
||||
};
|
||||
|
||||
/** Create a new role (admin only) */
|
||||
proto.createRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
|
||||
};
|
||||
|
||||
/** Update a role (admin only) */
|
||||
proto.updateRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
|
||||
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
|
||||
};
|
||||
|
||||
/** Delete a role (admin only) */
|
||||
proto.deleteRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
|
||||
};
|
||||
|
||||
// --- Permission Templates ---
|
||||
|
||||
/** List permission templates */
|
||||
proto.listPermissionTemplates = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<PermissionTemplate[]> {
|
||||
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
|
||||
};
|
||||
|
||||
/** Get permission template by ID */
|
||||
proto.getPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
|
||||
};
|
||||
|
||||
/** Create a permission template (admin only) */
|
||||
proto.createPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTemplateRequest): Promise<PermissionTemplate> {
|
||||
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
|
||||
};
|
||||
|
||||
/** Delete a permission template (admin only) */
|
||||
proto.deletePermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
|
||||
};
|
||||
|
||||
/** Apply permission template to accounts (admin only) */
|
||||
proto.applyPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, 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 });
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
* - saas-errors.ts — SaaSApiError class
|
||||
* - saas-session.ts — session persistence (load/save/clear)
|
||||
* - saas-auth.ts — login/register/TOTP methods (mixin)
|
||||
* - saas-admin.ts — admin panel API methods (mixin)
|
||||
* - saas-relay.ts — relay tasks, chat completion, usage (mixin)
|
||||
* - saas-prompt.ts — prompt OTA methods (mixin)
|
||||
* - saas-telemetry.ts — telemetry reporting methods (mixin)
|
||||
@@ -96,26 +95,6 @@ import type {
|
||||
SaaSErrorResponse,
|
||||
RelayTaskInfo,
|
||||
UsageStats,
|
||||
ProviderInfo,
|
||||
CreateProviderRequest,
|
||||
UpdateProviderRequest,
|
||||
ModelInfo,
|
||||
CreateModelRequest,
|
||||
UpdateModelRequest,
|
||||
AccountApiKeyInfo,
|
||||
CreateApiKeyRequest,
|
||||
AccountPublic,
|
||||
UpdateAccountRequest,
|
||||
PaginatedResponse,
|
||||
TokenInfo,
|
||||
CreateTokenRequest,
|
||||
OperationLogInfo,
|
||||
DashboardStats,
|
||||
RoleInfo,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
PermissionTemplate,
|
||||
CreateTemplateRequest,
|
||||
PromptCheckResult,
|
||||
PromptTemplateInfo,
|
||||
PromptVersionInfo,
|
||||
@@ -128,7 +107,7 @@ import { createLogger } from './logger';
|
||||
|
||||
const saasLog = createLogger('saas-client');
|
||||
import { installAuthMethods } from './saas-auth';
|
||||
import { installAdminMethods } from './saas-admin';
|
||||
|
||||
import { installRelayMethods } from './saas-relay';
|
||||
import { installPromptMethods } from './saas-prompt';
|
||||
import { installTelemetryMethods } from './saas-telemetry';
|
||||
@@ -140,6 +119,25 @@ export class SaaSClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
|
||||
/**
|
||||
* Refresh mutex: shared Promise to prevent concurrent token refresh.
|
||||
* When multiple requests hit 401 simultaneously, they all await the same
|
||||
* refresh Promise instead of triggering N parallel refresh calls.
|
||||
*/
|
||||
private _refreshPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Thread-safe token refresh — coalesces concurrent refresh attempts into one.
|
||||
* First caller triggers the actual refresh; subsequent callers await the same Promise.
|
||||
*/
|
||||
async refreshMutex(): Promise<string> {
|
||||
if (this._refreshPromise) return this._refreshPromise;
|
||||
this._refreshPromise = this.refreshToken().finally(() => {
|
||||
this._refreshPromise = null;
|
||||
});
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -237,7 +235,7 @@ export class SaaSClient {
|
||||
// 401: 尝试刷新 Token 后重试 (防止递归)
|
||||
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
const newToken = await this.refreshMutex();
|
||||
if (newToken) {
|
||||
return this.request<T>(method, path, body, timeoutMs, true);
|
||||
}
|
||||
@@ -394,7 +392,7 @@ export class SaaSClient {
|
||||
* Used for template selection during onboarding.
|
||||
*/
|
||||
async fetchAvailableTemplates(): Promise<AgentTemplateAvailable[]> {
|
||||
return this.request<AgentTemplateAvailable[]>('GET', '/agent-templates/available');
|
||||
return this.request<AgentTemplateAvailable[]>('GET', '/api/v1/agent-templates/available');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,13 +400,12 @@ export class SaaSClient {
|
||||
* Returns all fields needed to create an agent from template.
|
||||
*/
|
||||
async fetchTemplateFull(id: string): Promise<AgentTemplateFull> {
|
||||
return this.request<AgentTemplateFull>('GET', `/agent-templates/${id}/full`);
|
||||
return this.request<AgentTemplateFull>('GET', `/api/v1/agent-templates/${id}/full`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Install mixin methods ===
|
||||
installAuthMethods(SaaSClient);
|
||||
installAdminMethods(SaaSClient);
|
||||
installRelayMethods(SaaSClient);
|
||||
installPromptMethods(SaaSClient);
|
||||
installTelemetryMethods(SaaSClient);
|
||||
@@ -429,57 +426,6 @@ export interface SaaSClient {
|
||||
verifyTotp(code: string): Promise<TotpResultResponse>;
|
||||
disableTotp(password: string): Promise<TotpResultResponse>;
|
||||
|
||||
// --- Admin: Providers (saas-admin.ts) ---
|
||||
listProviders(): Promise<ProviderInfo[]>;
|
||||
getProvider(id: string): Promise<ProviderInfo>;
|
||||
createProvider(data: CreateProviderRequest): Promise<ProviderInfo>;
|
||||
updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo>;
|
||||
deleteProvider(id: string): Promise<void>;
|
||||
|
||||
// --- Admin: Models (saas-admin.ts) ---
|
||||
listModelsAdmin(providerId?: string): Promise<ModelInfo[]>;
|
||||
getModel(id: string): Promise<ModelInfo>;
|
||||
createModel(data: CreateModelRequest): Promise<ModelInfo>;
|
||||
updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo>;
|
||||
deleteModel(id: string): Promise<void>;
|
||||
|
||||
// --- Admin: API Keys (saas-admin.ts) ---
|
||||
listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]>;
|
||||
createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo>;
|
||||
rotateApiKey(id: string, newKeyValue: string): Promise<void>;
|
||||
revokeApiKey(id: string): Promise<void>;
|
||||
|
||||
// --- Admin: Accounts (saas-admin.ts) ---
|
||||
listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>>;
|
||||
getAccount(id: string): Promise<AccountPublic>;
|
||||
updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic>;
|
||||
updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void>;
|
||||
|
||||
// --- Admin: Tokens (saas-admin.ts) ---
|
||||
listTokens(): Promise<TokenInfo[]>;
|
||||
createToken(data: CreateTokenRequest): Promise<TokenInfo>;
|
||||
revokeToken(id: string): Promise<void>;
|
||||
|
||||
// --- Admin: Logs (saas-admin.ts) ---
|
||||
listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]>;
|
||||
|
||||
// --- Admin: Dashboard (saas-admin.ts) ---
|
||||
getDashboardStats(): Promise<DashboardStats>;
|
||||
|
||||
// --- Admin: Roles (saas-admin.ts) ---
|
||||
listRoles(): Promise<RoleInfo[]>;
|
||||
getRole(id: string): Promise<RoleInfo>;
|
||||
createRole(data: CreateRoleRequest): Promise<RoleInfo>;
|
||||
updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo>;
|
||||
deleteRole(id: string): Promise<void>;
|
||||
|
||||
// --- Admin: Permission Templates (saas-admin.ts) ---
|
||||
listPermissionTemplates(): Promise<PermissionTemplate[]>;
|
||||
getPermissionTemplate(id: string): Promise<PermissionTemplate>;
|
||||
createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate>;
|
||||
deletePermissionTemplate(id: string): Promise<void>;
|
||||
applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }>;
|
||||
|
||||
// --- Relay (saas-relay.ts) ---
|
||||
listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]>;
|
||||
getRelayTask(taskId: string): Promise<RelayTaskInfo>;
|
||||
|
||||
@@ -55,6 +55,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
|
||||
_serverReachable: boolean;
|
||||
_isAuthEndpoint(path: string): boolean;
|
||||
refreshToken(): Promise<string>;
|
||||
refreshMutex(): Promise<string>;
|
||||
},
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
@@ -87,7 +88,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
|
||||
// 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();
|
||||
const newToken = await this.refreshMutex();
|
||||
if (newToken) continue; // Retry with refreshed token
|
||||
} catch (e) {
|
||||
logger.debug('Token refresh failed', { error: e });
|
||||
|
||||
@@ -299,36 +299,6 @@ function readLocalStorageBackup(key: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous versions for compatibility with existing code
|
||||
* These use localStorage only and are provided for gradual migration
|
||||
*/
|
||||
export const secureStorageSync = {
|
||||
/**
|
||||
* Synchronously get a value from localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.get() instead
|
||||
*/
|
||||
get(key: string): string | null {
|
||||
return readLocalStorageBackup(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronously set a value in localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.set() instead
|
||||
*/
|
||||
set(key: string, value: string): void {
|
||||
writeLocalStorageBackup(key, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronously delete a value from localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.delete() instead
|
||||
*/
|
||||
delete(key: string): void {
|
||||
clearLocalStorageBackup(key);
|
||||
},
|
||||
};
|
||||
|
||||
// === Device Keys Secure Storage ===
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,7 +47,6 @@ export type { EncryptedData } from './crypto-utils';
|
||||
// Re-export secure storage
|
||||
export {
|
||||
secureStorage,
|
||||
secureStorageSync,
|
||||
isSecureStorageAvailable,
|
||||
storeDeviceKeys,
|
||||
getDeviceKeys,
|
||||
|
||||
Reference in New Issue
Block a user