feat(desktop): integrate SaaS llm_routing, template API, and onboarding template selection
- Add AgentTemplateAvailable/AgentTemplateFull types and fetchAvailableTemplates/fetchTemplateFull API methods to saas-client - Add llm_routing field to SaaSAccountInfo for admin-configured routing priority - Add availableTemplates state and fetchAvailableTemplates action to saasStore with background fetch on login - Add admin llm_routing priority check in connectionStore connect() to force relay or local mode - Add createFromTemplate action to agentStore with SOUL.md persistence - Add Step 0 template selection to AgentOnboardingWizard with grid layout for template browsing
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
LayoutGrid,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useAgentStore, type CloneCreateOptions } from '../store/agentStore';
|
import { useAgentStore, type CloneCreateOptions } from '../store/agentStore';
|
||||||
@@ -28,6 +29,12 @@ import type { Clone } from '../store/agentStore';
|
|||||||
import { intelligenceClient } from '../lib/intelligence-client';
|
import { intelligenceClient } from '../lib/intelligence-client';
|
||||||
import { generateSoulContent, generateUserContent } from '../lib/personality-presets';
|
import { generateSoulContent, generateUserContent } from '../lib/personality-presets';
|
||||||
import { createLogger } from '../lib/logger';
|
import { createLogger } from '../lib/logger';
|
||||||
|
import {
|
||||||
|
type AgentTemplateAvailable,
|
||||||
|
type AgentTemplateFull,
|
||||||
|
saasClient,
|
||||||
|
} from '../lib/saas-client';
|
||||||
|
import { useSaaSStore } from '../store/saasStore';
|
||||||
|
|
||||||
const log = createLogger('AgentOnboardingWizard');
|
const log = createLogger('AgentOnboardingWizard');
|
||||||
|
|
||||||
@@ -72,6 +79,7 @@ const initialFormData: WizardFormData = {
|
|||||||
// === Step Configuration ===
|
// === Step Configuration ===
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
|
{ id: 0, title: '行业模板', description: '选择预设或自定义', icon: LayoutGrid },
|
||||||
{ id: 1, title: '认识用户', description: '让我们了解一下您', icon: User },
|
{ id: 1, title: '认识用户', description: '让我们了解一下您', icon: User },
|
||||||
{ id: 2, title: 'Agent 身份', description: '给助手起个名字', icon: Bot },
|
{ id: 2, title: 'Agent 身份', description: '给助手起个名字', icon: Bot },
|
||||||
{ id: 3, title: '人格风格', description: '选择沟通风格', icon: Sparkles },
|
{ id: 3, title: '人格风格', description: '选择沟通风格', icon: Sparkles },
|
||||||
@@ -82,19 +90,21 @@ const steps = [
|
|||||||
// === Component ===
|
// === Component ===
|
||||||
|
|
||||||
export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboardingWizardProps) {
|
export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboardingWizardProps) {
|
||||||
const { createClone, updateClone, clones, isLoading, error, clearError } = useAgentStore();
|
const { createClone, createFromTemplate, updateClone, clones, isLoading, error, clearError } = useAgentStore();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [formData, setFormData] = useState<WizardFormData>(initialFormData);
|
const [formData, setFormData] = useState<WizardFormData>(initialFormData);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<AgentTemplateFull | null>(null);
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setFormData(initialFormData);
|
setFormData(initialFormData);
|
||||||
setCurrentStep(1);
|
setCurrentStep(0);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setSubmitStatus('idle');
|
setSubmitStatus('idle');
|
||||||
|
setSelectedTemplate(null);
|
||||||
clearError();
|
clearError();
|
||||||
}
|
}
|
||||||
}, [isOpen, clearError]);
|
}, [isOpen, clearError]);
|
||||||
@@ -111,11 +121,33 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle template selection
|
||||||
|
const handleSelectTemplate = async (t: AgentTemplateAvailable) => {
|
||||||
|
try {
|
||||||
|
const full = await saasClient.fetchTemplateFull(t.id);
|
||||||
|
setSelectedTemplate(full);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
agentName: full.name,
|
||||||
|
emoji: full.emoji || prev.emoji,
|
||||||
|
personality: full.personality || prev.personality,
|
||||||
|
scenarios: full.scenarios.length > 0 ? full.scenarios : prev.scenarios,
|
||||||
|
}));
|
||||||
|
setCurrentStep(1);
|
||||||
|
} catch {
|
||||||
|
// If fetch fails, still allow manual creation
|
||||||
|
setCurrentStep(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Validate current step
|
// Validate current step
|
||||||
const validateStep = useCallback((step: number): boolean => {
|
const validateStep = useCallback((step: number): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
|
case 0:
|
||||||
|
// Template selection is always valid (blank agent is an option)
|
||||||
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
if (!formData.userName.trim()) {
|
if (!formData.userName.trim()) {
|
||||||
newErrors.userName = '请输入您的名字';
|
newErrors.userName = '请输入您的名字';
|
||||||
@@ -157,7 +189,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
|
|
||||||
// Navigate to previous step
|
// Navigate to previous step
|
||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
@@ -169,59 +201,76 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
setSubmitStatus('idle');
|
setSubmitStatus('idle');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const personalityUpdates = {
|
|
||||||
name: formData.agentName,
|
|
||||||
role: formData.agentRole || undefined,
|
|
||||||
nickname: formData.agentNickname || undefined,
|
|
||||||
userName: formData.userName,
|
|
||||||
userRole: formData.userRole || undefined,
|
|
||||||
scenarios: formData.scenarios,
|
|
||||||
workspaceDir: formData.workspaceDir || undefined,
|
|
||||||
restrictFiles: formData.restrictFiles,
|
|
||||||
emoji: formData.emoji,
|
|
||||||
personality: formData.personality,
|
|
||||||
notes: formData.notes || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
let clone: Clone | undefined;
|
let clone: Clone | undefined;
|
||||||
|
|
||||||
// If there's an existing clone, update it instead of creating a new one
|
// Template-based creation path
|
||||||
if (clones && clones.length > 0) {
|
if (selectedTemplate && clones.length === 0) {
|
||||||
clone = await updateClone(clones[0].id, personalityUpdates);
|
clone = await createFromTemplate(selectedTemplate);
|
||||||
|
|
||||||
|
// Persist USER.md for template-created agents
|
||||||
|
if (clone) {
|
||||||
|
try {
|
||||||
|
const userContent = generateUserContent({
|
||||||
|
userName: formData.userName,
|
||||||
|
userRole: formData.userRole,
|
||||||
|
scenarios: formData.scenarios,
|
||||||
|
});
|
||||||
|
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to persist USER.md for template agent:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const createOptions: CloneCreateOptions = {
|
// Manual creation / update path
|
||||||
...personalityUpdates,
|
const personalityUpdates = {
|
||||||
privacyOptIn: formData.privacyOptIn,
|
name: formData.agentName,
|
||||||
|
role: formData.agentRole || undefined,
|
||||||
|
nickname: formData.agentNickname || undefined,
|
||||||
|
userName: formData.userName,
|
||||||
|
userRole: formData.userRole || undefined,
|
||||||
|
scenarios: formData.scenarios,
|
||||||
|
workspaceDir: formData.workspaceDir || undefined,
|
||||||
|
restrictFiles: formData.restrictFiles,
|
||||||
|
emoji: formData.emoji,
|
||||||
|
personality: formData.personality,
|
||||||
|
notes: formData.notes || undefined,
|
||||||
};
|
};
|
||||||
clone = await createClone(createOptions);
|
|
||||||
|
if (clones && clones.length > 0) {
|
||||||
|
clone = await updateClone(clones[0].id, personalityUpdates);
|
||||||
|
} else {
|
||||||
|
const createOptions: CloneCreateOptions = {
|
||||||
|
...personalityUpdates,
|
||||||
|
privacyOptIn: formData.privacyOptIn,
|
||||||
|
};
|
||||||
|
clone = await createClone(createOptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clone) {
|
if (clone) {
|
||||||
// Persist SOUL.md and USER.md to the identity system
|
// Persist SOUL.md and USER.md to the identity system (manual path only)
|
||||||
try {
|
if (!selectedTemplate) {
|
||||||
const soulContent = generateSoulContent({
|
try {
|
||||||
agentName: formData.agentName,
|
const soulContent = generateSoulContent({
|
||||||
emoji: formData.emoji,
|
agentName: formData.agentName,
|
||||||
personality: formData.personality,
|
emoji: formData.emoji,
|
||||||
scenarios: formData.scenarios,
|
personality: formData.personality,
|
||||||
});
|
scenarios: formData.scenarios,
|
||||||
|
});
|
||||||
|
|
||||||
const userContent = generateUserContent({
|
const userContent = generateUserContent({
|
||||||
userName: formData.userName,
|
userName: formData.userName,
|
||||||
userRole: formData.userRole,
|
userRole: formData.userRole,
|
||||||
scenarios: formData.scenarios,
|
scenarios: formData.scenarios,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write SOUL.md (agent personality)
|
await intelligenceClient.identity.updateFile(clone.id, 'soul', soulContent);
|
||||||
await intelligenceClient.identity.updateFile(clone.id, 'soul', soulContent);
|
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
|
||||||
|
|
||||||
// Write USER.md (user profile)
|
log.debug('SOUL.md and USER.md persisted for agent:', clone.id);
|
||||||
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
|
} catch (err) {
|
||||||
|
log.warn('Failed to persist identity files:', err);
|
||||||
log.debug('SOUL.md and USER.md persisted for agent:', clone.id);
|
}
|
||||||
} catch (err) {
|
|
||||||
log.warn('Failed to persist identity files:', err);
|
|
||||||
// Don't fail the whole onboarding if identity persistence fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitStatus('success');
|
setSubmitStatus('success');
|
||||||
@@ -239,7 +288,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const CurrentStepIcon = steps[currentStep - 1]?.icon || Bot;
|
const CurrentStepIcon = steps[currentStep]?.icon || Bot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
@@ -262,7 +311,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
创建新 Agent
|
创建新 Agent
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
步骤 {currentStep}/{steps.length}: {steps[currentStep - 1]?.title}
|
步骤 {currentStep + 1}/{steps.length}: {steps[currentStep]?.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,6 +370,36 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
|
{/* Step 0: 行业模板 */}
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">选择一个行业预设快速开始,或创建空白 Agent</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSelectedTemplate(null); setCurrentStep(1); }}
|
||||||
|
className="p-4 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors text-center"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-2">✨</div>
|
||||||
|
<div className="font-medium text-sm">空白 Agent</div>
|
||||||
|
<div className="text-xs text-muted-foreground">从零配置</div>
|
||||||
|
</button>
|
||||||
|
{useSaaSStore.getState().availableTemplates.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectTemplate(t)}
|
||||||
|
className="p-4 rounded-lg border-2 hover:border-primary/50 transition-colors text-center"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-2">{t.emoji || '🤖'}</div>
|
||||||
|
<div className="font-medium text-sm">{t.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-2">{t.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 1: 认识用户 */}
|
{/* Step 1: 认识用户 */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<>
|
<>
|
||||||
@@ -627,7 +706,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
disabled={currentStep === 1}
|
disabled={currentStep === 0}
|
||||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
462
desktop/src/lib/saas-types.ts
Normal file
462
desktop/src/lib/saas-types.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* SaaS Type Definitions
|
||||||
|
*
|
||||||
|
* All type/interface definitions for the ZCLAW SaaS client.
|
||||||
|
* Extracted from saas-client.ts for modularity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === Account & Auth Types ===
|
||||||
|
|
||||||
|
/** Public account info returned by the SaaS backend */
|
||||||
|
export interface SaaSAccountInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
role: 'super_admin' | 'admin' | 'user';
|
||||||
|
status: 'active' | 'disabled' | 'suspended';
|
||||||
|
totp_enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
llm_routing?: 'relay' | 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight template info for listing available templates */
|
||||||
|
export interface AgentTemplateAvailable {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
emoji?: string;
|
||||||
|
description?: string;
|
||||||
|
source_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full template details for creating an agent from template */
|
||||||
|
export interface AgentTemplateFull {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
emoji?: string;
|
||||||
|
personality?: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
soul_content?: string;
|
||||||
|
scenarios: string[];
|
||||||
|
welcome_message?: string;
|
||||||
|
quick_commands: Array<{ label: string; command: string }>;
|
||||||
|
communication_style?: string;
|
||||||
|
model?: string;
|
||||||
|
tools: string[];
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
source_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model available for relay through the SaaS backend */
|
||||||
|
export interface SaaSModelInfo {
|
||||||
|
id: string;
|
||||||
|
provider_id: string;
|
||||||
|
alias: string;
|
||||||
|
context_window: number;
|
||||||
|
max_output_tokens: number;
|
||||||
|
supports_streaming: boolean;
|
||||||
|
supports_vision: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config item from the SaaS backend */
|
||||||
|
export interface SaaSConfigItem {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
key_path: string;
|
||||||
|
value_type: string;
|
||||||
|
current_value: string | null;
|
||||||
|
default_value: string | null;
|
||||||
|
source: string;
|
||||||
|
description: string | null;
|
||||||
|
requires_restart: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SaaS API error shape */
|
||||||
|
export interface SaaSErrorResponse {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login response from POST /api/v1/auth/login */
|
||||||
|
export interface SaaSLoginResponse {
|
||||||
|
token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
account: SaaSAccountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh response from POST /api/v1/auth/refresh */
|
||||||
|
export interface SaaSRefreshResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TOTP setup response from POST /api/v1/auth/totp/setup */
|
||||||
|
export interface TotpSetupResponse {
|
||||||
|
otpauth_uri: string;
|
||||||
|
secret: string;
|
||||||
|
issuer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TOTP verify/disable response */
|
||||||
|
export interface TotpResultResponse {
|
||||||
|
ok: boolean;
|
||||||
|
totp_enabled: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device info stored on the SaaS backend */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string;
|
||||||
|
device_id: string;
|
||||||
|
device_name: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
app_version: string | null;
|
||||||
|
last_seen_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Relay & Config Types ===
|
||||||
|
|
||||||
|
/** Relay task info from GET /api/v1/relay/tasks */
|
||||||
|
export interface RelayTaskInfo {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
provider_id: string;
|
||||||
|
model_id: string;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
attempt_count: number;
|
||||||
|
max_attempts: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
error_message: string | null;
|
||||||
|
queued_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config diff request for POST /api/v1/config/diff and /sync */
|
||||||
|
export interface SyncConfigRequest {
|
||||||
|
client_fingerprint: string;
|
||||||
|
action: 'push' | 'merge';
|
||||||
|
config_keys: string[];
|
||||||
|
client_values: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single config diff entry */
|
||||||
|
export interface ConfigDiffItem {
|
||||||
|
key_path: string;
|
||||||
|
client_value: string | null;
|
||||||
|
saas_value: string | null;
|
||||||
|
conflict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config diff response */
|
||||||
|
export interface ConfigDiffResponse {
|
||||||
|
items: ConfigDiffItem[];
|
||||||
|
total_keys: number;
|
||||||
|
conflicts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config sync result */
|
||||||
|
export interface ConfigSyncResult {
|
||||||
|
updated: number;
|
||||||
|
created: number;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Providers ===
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Models ===
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: API Keys ===
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Usage & Accounts ===
|
||||||
|
|
||||||
|
/** 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: 'super_admin' | 'admin' | 'user';
|
||||||
|
status: 'active' | 'disabled' | 'suspended';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Tokens ===
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Logs & Dashboard ===
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Admin Types: Roles & Permissions ===
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { GatewayClient } from '../lib/gateway-client';
|
import type { GatewayClient } from '../lib/gateway-client';
|
||||||
|
import type { AgentTemplateFull } from '../lib/saas-client';
|
||||||
import { useChatStore } from './chatStore';
|
import { useChatStore } from './chatStore';
|
||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
@@ -33,6 +34,7 @@ export interface Clone {
|
|||||||
communicationStyle?: string; // 沟通风格描述
|
communicationStyle?: string; // 沟通风格描述
|
||||||
notes?: string; // 用户备注
|
notes?: string; // 用户备注
|
||||||
onboardingCompleted?: boolean; // 是否完成首次引导
|
onboardingCompleted?: boolean; // 是否完成首次引导
|
||||||
|
source_template_id?: string; // 模板来源 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageStats {
|
export interface UsageStats {
|
||||||
@@ -83,6 +85,7 @@ export interface AgentStateSlice {
|
|||||||
export interface AgentActionsSlice {
|
export interface AgentActionsSlice {
|
||||||
loadClones: () => Promise<void>;
|
loadClones: () => Promise<void>;
|
||||||
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
|
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
|
||||||
|
createFromTemplate: (template: AgentTemplateFull) => Promise<Clone | undefined>;
|
||||||
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
|
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
|
||||||
deleteClone: (id: string) => Promise<void>;
|
deleteClone: (id: string) => Promise<void>;
|
||||||
loadUsageStats: () => Promise<void>;
|
loadUsageStats: () => Promise<void>;
|
||||||
@@ -173,6 +176,43 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createFromTemplate: async (template: AgentTemplateFull) => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await client.createClone({
|
||||||
|
name: template.name,
|
||||||
|
emoji: template.emoji,
|
||||||
|
personality: template.personality,
|
||||||
|
scenarios: template.scenarios,
|
||||||
|
communicationStyle: template.communication_style,
|
||||||
|
model: template.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneId = result?.clone?.id;
|
||||||
|
|
||||||
|
// Persist SOUL.md via identity system
|
||||||
|
if (cloneId && template.soul_content) {
|
||||||
|
try {
|
||||||
|
const { intelligenceClient } = await import('../lib/intelligence-client');
|
||||||
|
await intelligenceClient.identity.updateFile(cloneId, 'soul', template.soul_content);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to persist soul_content:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().loadClones();
|
||||||
|
return result?.clone;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
updateClone: async (id: string, updates: Partial<Clone>) => {
|
updateClone: async (id: string, updates: Partial<Clone>) => {
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|||||||
@@ -350,6 +350,70 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
|||||||
try {
|
try {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
|
|
||||||
|
// === Admin Routing Priority ===
|
||||||
|
// Admin-configured llm_routing takes priority over localStorage connectionMode.
|
||||||
|
// This allows admins to force all clients to use relay or local mode.
|
||||||
|
let adminForceLocal = false;
|
||||||
|
try {
|
||||||
|
const storedAccount = JSON.parse(localStorage.getItem('zclaw-saas-account') || '{}');
|
||||||
|
const adminRouting = storedAccount?.account?.llm_routing;
|
||||||
|
|
||||||
|
if (adminRouting === 'relay') {
|
||||||
|
// Force SaaS Relay mode — admin override
|
||||||
|
// Set connection mode to 'saas' so the SaaS relay section below activates
|
||||||
|
localStorage.setItem('zclaw-connection-mode', 'saas');
|
||||||
|
log.debug('Admin llm_routing=relay: forcing SaaS relay mode');
|
||||||
|
} else if (adminRouting === 'local' && isTauriRuntime()) {
|
||||||
|
// Force local Kernel mode — skip SaaS relay entirely
|
||||||
|
adminForceLocal = true;
|
||||||
|
localStorage.setItem('zclaw-connection-mode', 'tauri');
|
||||||
|
log.debug('Admin llm_routing=local: forcing local Kernel mode');
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors, fall through to default logic */ }
|
||||||
|
|
||||||
|
// === Internal Kernel Mode: Admin forced local ===
|
||||||
|
// If admin forced local mode, skip directly to Tauri Kernel section
|
||||||
|
if (adminForceLocal) {
|
||||||
|
const kernelClient = getKernelClient();
|
||||||
|
const modelConfig = await getDefaultModelConfigAsync();
|
||||||
|
|
||||||
|
if (!modelConfig) {
|
||||||
|
throw new Error('请先在"模型与 API"设置页面添加自定义模型配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelConfig.apiKey) {
|
||||||
|
throw new Error(`模型 ${modelConfig.model} 未配置 API Key,请在"模型与 API"设置页面配置`);
|
||||||
|
}
|
||||||
|
|
||||||
|
kernelClient.setConfig({
|
||||||
|
provider: modelConfig.provider,
|
||||||
|
model: modelConfig.model,
|
||||||
|
apiKey: modelConfig.apiKey,
|
||||||
|
baseUrl: modelConfig.baseUrl,
|
||||||
|
apiProtocol: modelConfig.apiProtocol,
|
||||||
|
});
|
||||||
|
|
||||||
|
kernelClient.onStateChange = (state: ConnectionState) => {
|
||||||
|
set({ connectionState: state });
|
||||||
|
};
|
||||||
|
|
||||||
|
kernelClient.onLog = (level, message) => {
|
||||||
|
set((s) => ({
|
||||||
|
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ client: kernelClient });
|
||||||
|
|
||||||
|
const { initializeStores } = await import('./index');
|
||||||
|
initializeStores();
|
||||||
|
|
||||||
|
await kernelClient.connect();
|
||||||
|
set({ gatewayVersion: '0.1.0-internal' });
|
||||||
|
log.debug('Connected to internal ZCLAW Kernel (admin forced local)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// === SaaS Relay Mode ===
|
// === SaaS Relay Mode ===
|
||||||
// Check connection mode from localStorage (set by saasStore).
|
// Check connection mode from localStorage (set by saasStore).
|
||||||
// When SaaS is unreachable, gracefully degrade to local kernel mode
|
// When SaaS is unreachable, gracefully degrade to local kernel mode
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type SaaSLoginResponse,
|
type SaaSLoginResponse,
|
||||||
type TotpSetupResponse,
|
type TotpSetupResponse,
|
||||||
type SyncConfigRequest,
|
type SyncConfigRequest,
|
||||||
|
type AgentTemplateAvailable,
|
||||||
} from '../lib/saas-client';
|
} from '../lib/saas-client';
|
||||||
import { createLogger } from '../lib/logger';
|
import { createLogger } from '../lib/logger';
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +71,8 @@ export interface SaaSStateSlice {
|
|||||||
totpSetupData: TotpSetupResponse | null;
|
totpSetupData: TotpSetupResponse | null;
|
||||||
/** Whether SaaS backend is currently reachable */
|
/** Whether SaaS backend is currently reachable */
|
||||||
saasReachable: boolean;
|
saasReachable: boolean;
|
||||||
|
/** Agent templates available for onboarding */
|
||||||
|
availableTemplates: AgentTemplateAvailable[];
|
||||||
/** Consecutive heartbeat/health-check failures */
|
/** Consecutive heartbeat/health-check failures */
|
||||||
_consecutiveFailures: number;
|
_consecutiveFailures: number;
|
||||||
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
||||||
@@ -86,6 +89,7 @@ export interface SaaSActionsSlice {
|
|||||||
syncConfigFromSaaS: () => Promise<void>;
|
syncConfigFromSaaS: () => Promise<void>;
|
||||||
pushConfigToSaaS: () => Promise<void>;
|
pushConfigToSaaS: () => Promise<void>;
|
||||||
registerCurrentDevice: () => Promise<void>;
|
registerCurrentDevice: () => Promise<void>;
|
||||||
|
fetchAvailableTemplates: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
restoreSession: () => void;
|
restoreSession: () => void;
|
||||||
setupTotp: () => Promise<TotpSetupResponse>;
|
setupTotp: () => Promise<TotpSetupResponse>;
|
||||||
@@ -140,6 +144,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
totpRequired: false,
|
totpRequired: false,
|
||||||
totpSetupData: null,
|
totpSetupData: null,
|
||||||
saasReachable: true,
|
saasReachable: true,
|
||||||
|
availableTemplates: [],
|
||||||
_consecutiveFailures: 0,
|
_consecutiveFailures: 0,
|
||||||
|
|
||||||
// === Actions ===
|
// === Actions ===
|
||||||
@@ -189,6 +194,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
log.warn('Failed to register device:', err);
|
log.warn('Failed to register device:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch available templates in background (non-blocking)
|
||||||
|
get().fetchAvailableTemplates().catch((err: unknown) => {
|
||||||
|
log.warn('Failed to fetch templates after login:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch available models in background (non-blocking)
|
// Fetch available models in background (non-blocking)
|
||||||
get().fetchAvailableModels().catch((err: unknown) => {
|
get().fetchAvailableModels().catch((err: unknown) => {
|
||||||
log.warn('Failed to fetch models after login:', err);
|
log.warn('Failed to fetch models after login:', err);
|
||||||
@@ -587,6 +597,16 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchAvailableTemplates: async () => {
|
||||||
|
try {
|
||||||
|
const templates = await saasClient.fetchAvailableTemplates();
|
||||||
|
set({ availableTemplates: templates });
|
||||||
|
} catch {
|
||||||
|
// Graceful degradation - don't block login
|
||||||
|
set({ availableTemplates: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => {
|
clearError: () => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user