Refine desktop setup and remove bundled app center apps
This commit is contained in:
@@ -20,8 +20,6 @@ import { Settings } from './pages/Settings';
|
||||
import { Setup } from './pages/Setup';
|
||||
import { Knowledge } from './pages/Knowledge';
|
||||
import { AppCenter } from './pages/AppCenter';
|
||||
import { NianxxPlay } from './pages/NianxxPlay';
|
||||
import { ProductCenter } from './pages/ProductCenter';
|
||||
import { YinianLogin } from './pages/YinianLogin';
|
||||
import { useSettingsStore } from './stores/settings';
|
||||
import { useGatewayStore } from './stores/gateway';
|
||||
@@ -433,8 +431,6 @@ function App() {
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/skills" element={<Navigate to="/settings/skills" replace />} />
|
||||
<Route path="/app-center" element={<AppCenter />} />
|
||||
<Route path="/app-center/product-center" element={<ProductCenter />} />
|
||||
<Route path="/app-center/nianxx-play" element={<NianxxPlay />} />
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/cron" element={<Navigate to="/tasks?tab=scheduled" replace />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
|
||||
391
src/components/settings/AgentSystemDocumentsSettings.tsx
Normal file
391
src/components/settings/AgentSystemDocumentsSettings.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { toUserMessage } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DOCUMENT_ORDER = ['soul', 'identity', 'user', 'agent', 'tool', 'heartbeat', 'boot'] as const;
|
||||
|
||||
type AgentSystemDocumentKind = typeof DOCUMENT_ORDER[number];
|
||||
type AgentSystemDocumentSource = 'workspace' | 'template' | 'empty';
|
||||
|
||||
type AgentSystemDocumentAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
type AgentSystemDocument = {
|
||||
kind: AgentSystemDocumentKind;
|
||||
fileName: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
source: AgentSystemDocumentSource;
|
||||
content: string;
|
||||
size: number;
|
||||
updatedAt: number | null;
|
||||
templateAvailable: boolean;
|
||||
templatePath: string;
|
||||
};
|
||||
|
||||
type AgentSystemDocumentsSnapshot = {
|
||||
success: true;
|
||||
selectedAgentId: string;
|
||||
defaultAgentId: string;
|
||||
agents: AgentSystemDocumentAgent[];
|
||||
documents: AgentSystemDocument[];
|
||||
paths: {
|
||||
workspace: string;
|
||||
templateDir: string;
|
||||
};
|
||||
};
|
||||
|
||||
const EMPTY_DRAFTS: Record<AgentSystemDocumentKind, string> = {
|
||||
soul: '',
|
||||
identity: '',
|
||||
user: '',
|
||||
agent: '',
|
||||
tool: '',
|
||||
heartbeat: '',
|
||||
boot: '',
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: number | null): string {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function documentMapFromSnapshot(snapshot: AgentSystemDocumentsSnapshot | null): Map<AgentSystemDocumentKind, AgentSystemDocument> {
|
||||
return new Map((snapshot?.documents ?? []).map((document) => [document.kind, document]));
|
||||
}
|
||||
|
||||
export function AgentSystemDocumentsSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [snapshot, setSnapshot] = useState<AgentSystemDocumentsSnapshot | null>(null);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState('');
|
||||
const [selectedKind, setSelectedKind] = useState<AgentSystemDocumentKind>('soul');
|
||||
const [drafts, setDrafts] = useState<Record<AgentSystemDocumentKind, string>>(EMPTY_DRAFTS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const documentMap = useMemo(() => documentMapFromSnapshot(snapshot), [snapshot]);
|
||||
const selectedDocument = documentMap.get(selectedKind) ?? null;
|
||||
const selectedAgent = snapshot?.agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||
const currentDraft = drafts[selectedKind] ?? selectedDocument?.content ?? '';
|
||||
const isDirty = selectedDocument ? currentDraft !== selectedDocument.content : false;
|
||||
|
||||
const applySnapshot = (nextSnapshot: AgentSystemDocumentsSnapshot) => {
|
||||
const nextDrafts = { ...EMPTY_DRAFTS };
|
||||
for (const document of nextSnapshot.documents) {
|
||||
nextDrafts[document.kind] = document.content;
|
||||
}
|
||||
setSnapshot(nextSnapshot);
|
||||
setSelectedAgentId(nextSnapshot.selectedAgentId);
|
||||
setDrafts(nextDrafts);
|
||||
if (!nextSnapshot.documents.some((document) => document.kind === selectedKind)) {
|
||||
setSelectedKind('soul');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDocuments = async (agentId?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : '';
|
||||
const nextSnapshot = await hostApiFetch<AgentSystemDocumentsSnapshot>(`/api/agent-system-documents${query}`);
|
||||
applySnapshot(nextSnapshot);
|
||||
} catch (loadError) {
|
||||
const message = toUserMessage(loadError);
|
||||
setError(message);
|
||||
toast.error(t('systemDocuments.toast.loadFailed', { message }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDocuments();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAgentChange = (agentId: string) => {
|
||||
setSelectedAgentId(agentId);
|
||||
void loadDocuments(agentId);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedDocument) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextSnapshot = await hostApiFetch<AgentSystemDocumentsSnapshot>(
|
||||
`/api/agent-system-documents/${selectedKind}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
agentId: selectedAgentId,
|
||||
content: currentDraft,
|
||||
}),
|
||||
},
|
||||
);
|
||||
applySnapshot(nextSnapshot);
|
||||
toast.success(t('systemDocuments.toast.saved', { fileName: selectedDocument.fileName }));
|
||||
} catch (saveError) {
|
||||
const message = toUserMessage(saveError);
|
||||
setError(message);
|
||||
toast.error(t('systemDocuments.toast.saveFailed', { message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!selectedDocument) return;
|
||||
setResetting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextSnapshot = await hostApiFetch<AgentSystemDocumentsSnapshot>(
|
||||
`/api/agent-system-documents/${selectedKind}/reset`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agentId: selectedAgentId }),
|
||||
},
|
||||
);
|
||||
applySnapshot(nextSnapshot);
|
||||
toast.success(t('systemDocuments.toast.reset', { fileName: selectedDocument.fileName }));
|
||||
} catch (resetError) {
|
||||
const message = toUserMessage(resetError);
|
||||
setError(message);
|
||||
toast.error(t('systemDocuments.toast.resetFailed', { message }));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="agent-system-documents-settings" className="space-y-5">
|
||||
<div className="yinian-panel overflow-hidden">
|
||||
<div className="border-b border-slate-200/80 p-5 dark:border-white/10 sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal text-foreground">
|
||||
{t('systemDocuments.title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t('systemDocuments.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-2 sm:min-w-[320px]">
|
||||
<Label htmlFor="agent-system-document-agent" className="text-[13px] font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('systemDocuments.agent')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
id="agent-system-document-agent"
|
||||
data-testid="agent-system-document-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => handleAgentChange(event.target.value)}
|
||||
disabled={loading || !snapshot}
|
||||
className="h-10 rounded-lg border-slate-200 bg-white dark:border-white/10 dark:bg-slate-950"
|
||||
>
|
||||
{(snapshot?.agents ?? []).map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}{agent.isDefault ? ` · ${t('systemDocuments.defaultAgent')}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadDocuments(selectedAgentId || undefined)}
|
||||
disabled={loading}
|
||||
className="h-10 shrink-0 rounded-lg border-slate-200 bg-transparent px-3 dark:border-white/10"
|
||||
aria-label={t('common:actions.refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<Badge variant="outline" className="gap-1.5 rounded-full px-3 py-1">
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
{selectedAgent.id}
|
||||
</Badge>
|
||||
<span className="break-all font-mono">{snapshot?.paths.workspace || selectedAgent.workspace}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-5 mt-5 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300 sm:mx-6">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 gap-0 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div className="border-b border-slate-200/80 p-4 dark:border-white/10 lg:border-b-0 lg:border-r">
|
||||
<div className="space-y-2">
|
||||
{DOCUMENT_ORDER.map((kind) => {
|
||||
const document = documentMap.get(kind);
|
||||
const active = selectedKind === kind;
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
data-testid={`agent-system-document-tab-${kind}`}
|
||||
onClick={() => setSelectedKind(kind)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors',
|
||||
active
|
||||
? 'border-[#B7D9EA] bg-[#EAF5FA] text-[#075985] dark:border-blue-300/30 dark:bg-blue-400/15 dark:text-blue-50'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950/40 dark:text-slate-200 dark:hover:bg-white/[0.08]',
|
||||
)}
|
||||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[14px] font-semibold">
|
||||
{t(`systemDocuments.documents.${kind}.title`)}
|
||||
</span>
|
||||
<span className="mt-1 block text-[12px] text-muted-foreground">
|
||||
{document?.fileName ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
{document?.exists ? (
|
||||
<CheckCircle2 className="ml-auto mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 p-4 sm:p-5">
|
||||
{selectedDocument ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-semibold tracking-normal text-foreground">
|
||||
{selectedDocument.fileName}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-[11px]',
|
||||
selectedDocument.source === 'workspace' && 'border-green-200 bg-green-50 text-green-700 dark:border-green-900/40 dark:bg-green-950/20 dark:text-green-300',
|
||||
selectedDocument.source === 'template' && 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300',
|
||||
)}
|
||||
>
|
||||
{t(`systemDocuments.source.${selectedDocument.source}`)}
|
||||
</Badge>
|
||||
{isDirty && (
|
||||
<Badge variant="outline" className="rounded-full border-blue-200 bg-blue-50 px-2.5 py-0.5 text-[11px] text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300">
|
||||
{t('systemDocuments.unsaved')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
{t(`systemDocuments.documents.${selectedKind}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleReset()}
|
||||
disabled={resetting || loading || !selectedDocument.templateAvailable}
|
||||
className="h-9 rounded-lg border-slate-200 bg-transparent px-4 dark:border-white/10"
|
||||
data-testid="agent-system-document-reset"
|
||||
>
|
||||
<RotateCcw className={cn('mr-2 h-4 w-4', resetting && 'animate-spin')} />
|
||||
{t('systemDocuments.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || loading || !isDirty}
|
||||
className="h-9 rounded-lg bg-[#1E3A8A] px-4 text-white hover:bg-[#172E6C]"
|
||||
data-testid="agent-system-document-save"
|
||||
>
|
||||
<Save className={cn('mr-2 h-4 w-4', saving && 'animate-pulse')} />
|
||||
{saving ? t('common:status.saving') : t('systemDocuments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
data-testid="agent-system-document-editor"
|
||||
value={currentDraft}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[selectedKind]: value,
|
||||
}));
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="min-h-[52vh] resize-y rounded-lg border-slate-200 bg-white font-mono text-[13px] leading-6 dark:border-white/10 dark:bg-slate-950"
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-[12px] text-muted-foreground dark:border-white/10 dark:bg-white/5 md:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-foreground">{t('systemDocuments.path')}</span>
|
||||
<span className="ml-2 break-all font-mono">{selectedDocument.path}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{t('systemDocuments.updatedAt')}</span>
|
||||
<span className="ml-2">{formatUpdatedAt(selectedDocument.updatedAt)}</span>
|
||||
<span className="ml-3">{formatSize(selectedDocument.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[360px] items-center justify-center text-sm text-muted-foreground">
|
||||
{loading ? t('common:status.loading') : t('systemDocuments.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSystemDocumentsSettings;
|
||||
@@ -7,14 +7,9 @@
|
||||
"description": "App Center opens fixed business apps. Capability Packs extend what the assistant can do.",
|
||||
"count": "{{count}} items"
|
||||
},
|
||||
"categories": {
|
||||
"procurement": "Resource Procurement",
|
||||
"video": "Video Creation"
|
||||
},
|
||||
"categories": {},
|
||||
"tags": {
|
||||
"all": "All",
|
||||
"procurement": "Resource Procurement",
|
||||
"video": "Video Creation",
|
||||
"managed": "Server Managed",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
@@ -28,16 +23,6 @@
|
||||
"server": "Server issued",
|
||||
"local": "Local"
|
||||
},
|
||||
"items": {
|
||||
"productCenter": {
|
||||
"name": "Travel Resource Ordering",
|
||||
"description": "A tourism resource procurement workspace for hotel businesses, covering attraction tickets, shuttle products, order refunds, balances, and reconciliation reports."
|
||||
},
|
||||
"nianxxPlay": {
|
||||
"name": "Quick Video Creation",
|
||||
"description": "An AI video creation workspace for businesses and stores, with fast promo video generation, creative remixing, asset upload, project tracking, and result management."
|
||||
}
|
||||
},
|
||||
"managed": {
|
||||
"account": {
|
||||
"title": "Account & Permissions",
|
||||
@@ -53,34 +38,6 @@
|
||||
},
|
||||
"footer": "Future updates will ship with Zhinian Assistant."
|
||||
},
|
||||
"host": {
|
||||
"title": "Quick Video Creation",
|
||||
"subtitle": "Hosted by Zhinian Assistant. Accounts, permissions, secrets, and billing are managed by the server.",
|
||||
"back": "Back",
|
||||
"reload": "Reload",
|
||||
"browser": "Open in Browser",
|
||||
"nav": {
|
||||
"label": "Quick Video Creation navigation",
|
||||
"create": "Create",
|
||||
"projects": "Projects",
|
||||
"planning": "Planning"
|
||||
},
|
||||
"frameTitle": "Quick Video Creation app window",
|
||||
"loadFailed": "App failed to load",
|
||||
"serviceStarting": "Starting Quick Video Creation",
|
||||
"serviceStartingDesc": "The desktop app is checking and starting the local video assistant service.",
|
||||
"serviceFailed": "Quick Video Creation failed to start"
|
||||
},
|
||||
"productCenter": {
|
||||
"title": "Travel Resource Ordering",
|
||||
"badge": "Hotel tourism resource procurement",
|
||||
"back": "Back",
|
||||
"reload": "Reload",
|
||||
"openExternal": "Open in Browser",
|
||||
"frameTitle": "Travel Resource Ordering app window",
|
||||
"loading": "Opening Travel Resource Ordering",
|
||||
"loadingDesc": "The desktop app is loading the tourism resource procurement workspace. Organization SSO will be connected later."
|
||||
},
|
||||
"actions": {
|
||||
"open": "Open App",
|
||||
"openExternal": "Open in Browser"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"account": "Account & Updates",
|
||||
"channels": "Channels",
|
||||
"skills": "Capability Packs",
|
||||
"systemDocs": "System Docs",
|
||||
"preferences": "Preferences",
|
||||
"runtime": "Operations"
|
||||
},
|
||||
@@ -153,6 +154,60 @@
|
||||
"requestingCode": "Requesting secure login code..."
|
||||
}
|
||||
},
|
||||
"systemDocuments": {
|
||||
"title": "Agent System Documents",
|
||||
"description": "Manage SOUL, IDENTITY, USER, AGENTS, TOOLS, HEARTBEAT, and BOOT documents in each Agent workspace.",
|
||||
"agent": "Target Agent",
|
||||
"defaultAgent": "Default",
|
||||
"save": "Save document",
|
||||
"reset": "Restore template",
|
||||
"path": "File",
|
||||
"updatedAt": "Updated",
|
||||
"unsaved": "Unsaved",
|
||||
"empty": "No editable system documents",
|
||||
"source": {
|
||||
"workspace": "Saved in workspace",
|
||||
"template": "From default template",
|
||||
"empty": "Empty document"
|
||||
},
|
||||
"documents": {
|
||||
"soul": {
|
||||
"title": "Soul",
|
||||
"description": "Persona, boundaries, tone, and long-running relationship style."
|
||||
},
|
||||
"identity": {
|
||||
"title": "Identity",
|
||||
"description": "Agent name, avatar, signature, and self-identity record."
|
||||
},
|
||||
"user": {
|
||||
"title": "User",
|
||||
"description": "User profile, preferred name, timezone, preferences, and long-running context."
|
||||
},
|
||||
"agent": {
|
||||
"title": "Agent",
|
||||
"description": "Operating instructions, session startup, memory, and collaboration rules."
|
||||
},
|
||||
"tool": {
|
||||
"title": "Tool",
|
||||
"description": "Local tools, devices, account aliases, and environment conventions."
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "Heartbeat",
|
||||
"description": "Lightweight checklist for periodic background heartbeat runs."
|
||||
},
|
||||
"boot": {
|
||||
"title": "Boot",
|
||||
"description": "Short startup instructions run when internal hooks are enabled."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load system documents: {{message}}",
|
||||
"saved": "{{fileName}} saved",
|
||||
"saveFailed": "Save failed: {{message}}",
|
||||
"reset": "{{fileName}} restored from template",
|
||||
"resetFailed": "Template restore failed: {{message}}"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"title": "Gateway",
|
||||
"description": "Agent service settings",
|
||||
@@ -307,9 +362,10 @@
|
||||
"modelConfigRuntime": "Runtime",
|
||||
"modelConfigProvider": "Provider",
|
||||
"modelConfigHeartbeat": "Background heartbeat",
|
||||
"modelConfigProviders": "Model services",
|
||||
"modelConfigCredentials": "Credentials",
|
||||
"modelConfigCredentialsEmpty": "No local credentials found",
|
||||
"modelConfigProviders": "Current model service",
|
||||
"modelConfigProvidersEmpty": "The current default model does not reference a model service",
|
||||
"modelConfigCredentials": "Current credentials",
|
||||
"modelConfigCredentialsEmpty": "No local credentials found for the current model service",
|
||||
"modelConfigConfigured": "Configured",
|
||||
"modelConfigMissing": "Missing",
|
||||
"modelConfigOpenclawPath": "Runtime config",
|
||||
|
||||
@@ -7,14 +7,9 @@
|
||||
"description": "应用中心负责打开固定业务应用,能力包负责增强助手能力。",
|
||||
"count": "{{count}} 个"
|
||||
},
|
||||
"categories": {
|
||||
"procurement": "资源采购",
|
||||
"video": "视频创作"
|
||||
},
|
||||
"categories": {},
|
||||
"tags": {
|
||||
"all": "全部",
|
||||
"procurement": "资源采购",
|
||||
"video": "视频创作",
|
||||
"managed": "服务端管理",
|
||||
"pinned": "常用"
|
||||
},
|
||||
@@ -28,16 +23,6 @@
|
||||
"server": "服务端下发",
|
||||
"local": "本地添加"
|
||||
},
|
||||
"items": {
|
||||
"productCenter": {
|
||||
"name": "旅游资源订购",
|
||||
"description": "面向酒店企业的旅游资源底价订购工作台,支持景区门票、观光车等商品采购、订单退款、余额与报表对账。"
|
||||
},
|
||||
"nianxxPlay": {
|
||||
"name": "快速视频创作",
|
||||
"description": "面向企业与门店的 AI 视频创作工作台,支持快速生成宣传片、创意复刻、素材上传、项目追踪与结果管理。"
|
||||
}
|
||||
},
|
||||
"managed": {
|
||||
"account": {
|
||||
"title": "账号与权限",
|
||||
@@ -53,34 +38,6 @@
|
||||
},
|
||||
"footer": "后续更新会随智念助手一起下发。"
|
||||
},
|
||||
"host": {
|
||||
"title": "快速视频创作",
|
||||
"subtitle": "由智念助手承载运行,账号、权限、密钥与计费由服务端统一管理。",
|
||||
"back": "返回",
|
||||
"reload": "刷新",
|
||||
"browser": "浏览器打开",
|
||||
"nav": {
|
||||
"label": "快速视频创作导航",
|
||||
"create": "创作",
|
||||
"projects": "历史项目",
|
||||
"planning": "策划定制"
|
||||
},
|
||||
"frameTitle": "快速视频创作应用窗口",
|
||||
"loadFailed": "应用加载失败",
|
||||
"serviceStarting": "正在启动快速视频创作",
|
||||
"serviceStartingDesc": "桌面端正在检查并启动本机视频创作服务,请稍等。",
|
||||
"serviceFailed": "快速视频创作启动失败"
|
||||
},
|
||||
"productCenter": {
|
||||
"title": "旅游资源订购",
|
||||
"badge": "酒店旅游资源底价采购",
|
||||
"back": "返回",
|
||||
"reload": "刷新",
|
||||
"openExternal": "浏览器打开",
|
||||
"frameTitle": "旅游资源订购应用窗口",
|
||||
"loading": "正在打开旅游资源订购",
|
||||
"loadingDesc": "桌面端正在载入旅游资源采购工作台。后续将接入组织空间单点登录。"
|
||||
},
|
||||
"actions": {
|
||||
"open": "打开应用",
|
||||
"openExternal": "浏览器打开"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"account": "账号与更新",
|
||||
"channels": "渠道管理",
|
||||
"skills": "能力包",
|
||||
"systemDocs": "系统文档",
|
||||
"preferences": "使用偏好",
|
||||
"runtime": "运行维护"
|
||||
},
|
||||
@@ -153,6 +154,60 @@
|
||||
"requestingCode": "正在获取安全登录码..."
|
||||
}
|
||||
},
|
||||
"systemDocuments": {
|
||||
"title": "Agent 系统文档",
|
||||
"description": "管理各 Agent workspace 中的 SOUL、IDENTITY、USER、AGENTS、TOOLS、HEARTBEAT、BOOT 文档。",
|
||||
"agent": "目标 Agent",
|
||||
"defaultAgent": "默认",
|
||||
"save": "保存文档",
|
||||
"reset": "恢复模板",
|
||||
"path": "文件",
|
||||
"updatedAt": "更新时间",
|
||||
"unsaved": "未保存",
|
||||
"empty": "没有可编辑的系统文档",
|
||||
"source": {
|
||||
"workspace": "已写入 workspace",
|
||||
"template": "来自默认模板",
|
||||
"empty": "空文档"
|
||||
},
|
||||
"documents": {
|
||||
"soul": {
|
||||
"title": "Soul",
|
||||
"description": "人格、边界、语气与长期相处方式。"
|
||||
},
|
||||
"identity": {
|
||||
"title": "Identity",
|
||||
"description": "Agent 的名字、头像、签名和自我身份记录。"
|
||||
},
|
||||
"user": {
|
||||
"title": "User",
|
||||
"description": "用户画像、称呼、时区、偏好和长期背景。"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Agent",
|
||||
"description": "运行指令、会话启动、记忆和协作规则。"
|
||||
},
|
||||
"tool": {
|
||||
"title": "Tool",
|
||||
"description": "本地工具、设备、账号别名和环境约定。"
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "Heartbeat",
|
||||
"description": "后台心跳时要检查或推进的轻量任务清单。"
|
||||
},
|
||||
"boot": {
|
||||
"title": "Boot",
|
||||
"description": "启用内部 hook 后,后台服务启动时执行的短指令。"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "系统文档加载失败:{{message}}",
|
||||
"saved": "{{fileName}} 已保存",
|
||||
"saveFailed": "保存失败:{{message}}",
|
||||
"reset": "{{fileName}} 已恢复模板",
|
||||
"resetFailed": "恢复模板失败:{{message}}"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"title": "后台服务",
|
||||
"description": "智念助手后台服务设置",
|
||||
@@ -307,9 +362,10 @@
|
||||
"modelConfigRuntime": "运行参数",
|
||||
"modelConfigProvider": "服务来源",
|
||||
"modelConfigHeartbeat": "后台心跳",
|
||||
"modelConfigProviders": "模型服务",
|
||||
"modelConfigCredentials": "调用凭据",
|
||||
"modelConfigCredentialsEmpty": "未发现本地调用凭据",
|
||||
"modelConfigProviders": "当前模型服务",
|
||||
"modelConfigProvidersEmpty": "当前默认模型没有引用模型服务",
|
||||
"modelConfigCredentials": "当前调用凭据",
|
||||
"modelConfigCredentialsEmpty": "当前模型服务未发现本地调用凭据",
|
||||
"modelConfigConfigured": "已配置",
|
||||
"modelConfigMissing": "缺失",
|
||||
"modelConfigOpenclawPath": "运行配置",
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface ProviderAccount {
|
||||
|
||||
import { providerIcons } from '@/assets/providers';
|
||||
|
||||
const MINIMAX_DEFAULT_MODEL_ID = 'MiniMax-M3';
|
||||
|
||||
/** All supported provider types with UI metadata */
|
||||
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
||||
{
|
||||
@@ -174,12 +176,12 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
||||
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
|
||||
},
|
||||
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' },
|
||||
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' },
|
||||
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: MINIMAX_DEFAULT_MODEL_ID, showModelId: true, modelIdPlaceholder: MINIMAX_DEFAULT_MODEL_ID, apiKeyUrl: 'https://platform.minimaxi.com/' },
|
||||
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.6', docsUrl: 'https://platform.moonshot.cn/' },
|
||||
{ id: 'moonshot-global', name: 'Moonshot (Global)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.ai/v1', defaultModelId: 'kimi-k2.6', docsUrl: 'https://platform.moonshot.ai/' },
|
||||
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' },
|
||||
{ id: 'deepseek', name: 'DeepSeek', icon: '🐋', placeholder: 'sk-...', model: 'DeepSeek', requiresApiKey: true, defaultBaseUrl: 'https://api.deepseek.com/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-v4-pro', defaultModelId: 'deepseek-v4-pro', apiKeyUrl: 'https://platform.deepseek.com/api_keys', docsUrl: 'https://api-docs.deepseek.com/', docsUrlZh: 'https://api-docs.deepseek.com/zh-cn/' },
|
||||
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' },
|
||||
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: MINIMAX_DEFAULT_MODEL_ID, showModelId: true, modelIdPlaceholder: MINIMAX_DEFAULT_MODEL_ID, apiKeyUrl: 'https://platform.minimax.io' },
|
||||
{ id: 'modelstudio', name: 'Model Studio', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: true, defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1', showBaseUrl: true, defaultModelId: 'qwen3.5-plus', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'qwen3.5-plus', apiKeyUrl: 'https://bailian.console.aliyun.com/', hidden: true },
|
||||
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' },
|
||||
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Clapperboard,
|
||||
LayoutGrid,
|
||||
ShoppingBag,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -17,9 +15,7 @@ import { useAppCenterStore } from '@/stores/app-center';
|
||||
import type { AppCenterItem } from '@/types/app-center';
|
||||
|
||||
const APP_ICONS = {
|
||||
Clapperboard,
|
||||
LayoutGrid,
|
||||
ShoppingBag,
|
||||
};
|
||||
|
||||
function getAppIcon(icon: string) {
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { AlertTriangle, ArrowLeft, Film, FolderClock, Loader2, RefreshCcw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
|
||||
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
|
||||
|
||||
type NianxxPlayRoute = '/' | '/projects' | '/planning';
|
||||
type EmbeddedLanguage = 'zh' | 'en';
|
||||
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
|
||||
|
||||
type NianxxPlayServiceStatus = {
|
||||
success: boolean;
|
||||
running: boolean;
|
||||
starting: boolean;
|
||||
managed: boolean;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
projectDir?: string;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const NIANXX_PLAY_NAV: Array<{
|
||||
path: NianxxPlayRoute;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ path: '/', labelKey: 'host.nav.create', icon: Film },
|
||||
{ path: '/projects', labelKey: 'host.nav.projects', icon: FolderClock },
|
||||
{ path: '/planning', labelKey: 'host.nav.planning', icon: Sparkles },
|
||||
];
|
||||
|
||||
export function resolveNianxxPlayEmbeddedLanguage(language?: string): EmbeddedLanguage {
|
||||
return language?.toLowerCase().startsWith('en') ? 'en' : 'zh';
|
||||
}
|
||||
|
||||
export function buildEmbeddedSrc(
|
||||
baseUrl: string,
|
||||
route: NianxxPlayRoute,
|
||||
reloadKey: number,
|
||||
language: string = 'zh',
|
||||
) {
|
||||
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(language);
|
||||
try {
|
||||
const url = new URL(route, baseUrl);
|
||||
url.searchParams.set('zhinianEmbed', '1');
|
||||
url.searchParams.set('zhinianHostReload', String(reloadKey));
|
||||
url.searchParams.set('zhinianLang', embeddedLanguage);
|
||||
return url.toString();
|
||||
} catch {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}&zhinianLang=${embeddedLanguage}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function NianxxPlay() {
|
||||
const { t, i18n } = useTranslation('appCenter');
|
||||
const navigate = useNavigate();
|
||||
const [webviewKey, setWebviewKey] = useState(0);
|
||||
const [currentRoute, setCurrentRoute] = useState<NianxxPlayRoute>('/');
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const configuredAppUrl = useMemo(() => (
|
||||
import.meta.env.VITE_NIANXX_PLAY_URL?.trim() || ''
|
||||
), []);
|
||||
const [serviceState, setServiceState] = useState<ServiceState>(configuredAppUrl ? 'running' : 'checking');
|
||||
const [serviceError, setServiceError] = useState<string | null>(null);
|
||||
const [appUrl, setAppUrl] = useState(configuredAppUrl || DEFAULT_NIANXX_PLAY_URL);
|
||||
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(i18n.resolvedLanguage || i18n.language);
|
||||
const initialSrc = useMemo(
|
||||
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey, embeddedLanguage),
|
||||
[appUrl, currentRoute, webviewKey, embeddedLanguage],
|
||||
);
|
||||
|
||||
const ensureService = useCallback(async () => {
|
||||
if (configuredAppUrl) {
|
||||
setAppUrl(configuredAppUrl);
|
||||
setServiceState('running');
|
||||
setServiceError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
setServiceState('checking');
|
||||
setServiceError(null);
|
||||
try {
|
||||
const status = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/status');
|
||||
if (status.running) {
|
||||
setAppUrl(status.baseUrl);
|
||||
setServiceState('running');
|
||||
return true;
|
||||
}
|
||||
|
||||
setServiceState('starting');
|
||||
const started = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
setAppUrl(started.baseUrl || DEFAULT_NIANXX_PLAY_URL);
|
||||
if (started.running) {
|
||||
setServiceState('running');
|
||||
setServiceError(null);
|
||||
return true;
|
||||
}
|
||||
setServiceState('error');
|
||||
setServiceError(started.error || t('host.serviceFailed'));
|
||||
return false;
|
||||
} catch (error) {
|
||||
setServiceState('error');
|
||||
setServiceError(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}, [configuredAppUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const ok = await ensureService();
|
||||
if (cancelled || !ok) return;
|
||||
setWebviewKey((value) => value + 1);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ensureService]);
|
||||
|
||||
const reloadApp = async () => {
|
||||
setLoadError(null);
|
||||
const ok = await ensureService();
|
||||
if (ok) {
|
||||
setWebviewKey((value) => value + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const openRoute = (route: NianxxPlayRoute) => {
|
||||
setLoadError(null);
|
||||
setCurrentRoute(route);
|
||||
setWebviewKey((value) => value + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="nianxx-play-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
||||
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/app-center')}
|
||||
data-testid="nianxx-play-back"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('host.back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={reloadApp}
|
||||
aria-label={t('host.reload')}
|
||||
title={t('host.reload')}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold tracking-normal">
|
||||
{t('host.title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex shrink-0 items-center gap-1 justify-self-center rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5" aria-label={t('host.nav.label')}>
|
||||
{NIANXX_PLAY_NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = currentRoute === item.path;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
onClick={() => openRoute(item.path)}
|
||||
data-testid={`nianxx-play-nav-${item.path === '/' ? 'create' : item.path.slice(1)}`}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-white text-[#075985] shadow-sm dark:bg-slate-950 dark:text-blue-200'
|
||||
: 'text-slate-600 hover:bg-white/70 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div aria-hidden className="min-w-0" />
|
||||
</header>
|
||||
|
||||
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
|
||||
{serviceState === 'running' && (
|
||||
<iframe
|
||||
key={webviewKey}
|
||||
src={initialSrc}
|
||||
title={t('host.title')}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
|
||||
onLoad={() => setLoadError(null)}
|
||||
onError={() => setLoadError(t('host.loadFailed'))}
|
||||
/>
|
||||
)}
|
||||
{serviceState !== 'running' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
||||
<div className="max-w-md">
|
||||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
|
||||
{serviceState === 'error'
|
||||
? <AlertTriangle className="h-5 w-5" />
|
||||
: <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
</div>
|
||||
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
|
||||
{serviceState === 'error' ? t('host.serviceFailed') : t('host.serviceStarting')}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{serviceError || t('host.serviceStartingDesc')}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('host.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{serviceState === 'running' && loadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
||||
<div className="max-w-md">
|
||||
<div className="text-base font-semibold text-slate-950 dark:text-slate-50">
|
||||
{t('host.loadFailed')}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{loadError}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('host.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NianxxPlay;
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ExternalLink, Loader2, RefreshCcw, ShoppingBag } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import { buildProductCenterSrc, getProductCenterBaseUrl } from './url';
|
||||
|
||||
const PRODUCT_CENTER_PARTITION = 'persist:yinian-travel-resource-ordering';
|
||||
|
||||
type ProductCenterWebviewElement = HTMLElement & {
|
||||
reload?: () => void;
|
||||
};
|
||||
|
||||
export function ProductCenter() {
|
||||
const { t } = useTranslation('appCenter');
|
||||
const navigate = useNavigate();
|
||||
const session = useYinianStore((state) => state.session);
|
||||
const webviewRef = useRef<ProductCenterWebviewElement | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const baseUrl = useMemo(() => getProductCenterBaseUrl(), []);
|
||||
const embeddedSrc = useMemo(() => buildProductCenterSrc({
|
||||
baseUrl,
|
||||
reloadKey,
|
||||
session,
|
||||
}), [baseUrl, reloadKey, session]);
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) return;
|
||||
|
||||
const handleStart = () => setIsLoading(true);
|
||||
const handleStop = () => setIsLoading(false);
|
||||
|
||||
webview.addEventListener('did-start-loading', handleStart);
|
||||
webview.addEventListener('did-stop-loading', handleStop);
|
||||
webview.addEventListener('did-fail-load', handleStop);
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('did-start-loading', handleStart);
|
||||
webview.removeEventListener('did-stop-loading', handleStop);
|
||||
webview.removeEventListener('did-fail-load', handleStop);
|
||||
};
|
||||
}, [reloadKey]);
|
||||
|
||||
const reloadApp = () => {
|
||||
setIsLoading(true);
|
||||
if (typeof webviewRef.current?.reload === 'function') {
|
||||
webviewRef.current.reload();
|
||||
return;
|
||||
}
|
||||
setReloadKey((value) => value + 1);
|
||||
};
|
||||
|
||||
const openExternal = () => {
|
||||
void window.electron.openExternal(baseUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="product-center-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
||||
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/app-center')}
|
||||
data-testid="product-center-back"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('productCenter.back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={reloadApp}
|
||||
aria-label={t('productCenter.reload')}
|
||||
title={t('productCenter.reload')}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold tracking-normal">
|
||||
{t('productCenter.title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-self-center rounded-lg border border-slate-200/70 bg-white/60 px-3 py-1.5 text-xs font-medium text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
|
||||
<ShoppingBag className="mr-1.5 h-3.5 w-3.5 text-[#075985] dark:text-blue-200" />
|
||||
<span className="truncate">{t('productCenter.badge')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openExternal}
|
||||
data-testid="product-center-open-external"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t('productCenter.openExternal')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
|
||||
<webview
|
||||
key={reloadKey}
|
||||
ref={webviewRef}
|
||||
data-testid="product-center-frame"
|
||||
src={embeddedSrc}
|
||||
partition={PRODUCT_CENTER_PARTITION}
|
||||
title={t('productCenter.frameTitle')}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
|
||||
allowpopups={false}
|
||||
webpreferences="contextIsolation=yes,nodeIntegration=no"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white/92 p-6 text-center dark:bg-slate-950/92">
|
||||
<div className="max-w-md">
|
||||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
|
||||
{t('productCenter.loading')}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{t('productCenter.loadingDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductCenter;
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { YinianSessionState } from '../../../shared/yinian';
|
||||
|
||||
const DEFAULT_PRODUCT_CENTER_URL = 'https://ticket.nianxx.cn/';
|
||||
|
||||
export interface ProductCenterLaunchOptions {
|
||||
baseUrl?: string;
|
||||
reloadKey: number;
|
||||
session: YinianSessionState;
|
||||
// Future SSO should use a short-lived one-time ticket, never a long-lived desktop credential.
|
||||
ssoToken?: string;
|
||||
}
|
||||
|
||||
export function getProductCenterBaseUrl() {
|
||||
return import.meta.env.VITE_PRODUCT_CENTER_URL?.trim() || DEFAULT_PRODUCT_CENTER_URL;
|
||||
}
|
||||
|
||||
export function buildProductCenterSrc(options: ProductCenterLaunchOptions) {
|
||||
const baseUrl = options.baseUrl?.trim() || DEFAULT_PRODUCT_CENTER_URL;
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('zhinianEmbed', '1');
|
||||
url.searchParams.set('zhinianApp', 'product-center');
|
||||
url.searchParams.set('zhinianHostReload', String(options.reloadKey));
|
||||
|
||||
if (options.session.authenticated) {
|
||||
url.searchParams.set('workspaceId', options.session.currentHotelId);
|
||||
url.searchParams.set('userId', options.session.user.id);
|
||||
}
|
||||
|
||||
if (options.ssoToken) {
|
||||
url.searchParams.set('ssoToken', options.ssoToken);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const params = new URLSearchParams({
|
||||
zhinianEmbed: '1',
|
||||
zhinianApp: 'product-center',
|
||||
zhinianHostReload: String(options.reloadKey),
|
||||
});
|
||||
|
||||
if (options.session.authenticated) {
|
||||
params.set('workspaceId', options.session.currentHotelId);
|
||||
params.set('userId', options.session.user.id);
|
||||
}
|
||||
|
||||
if (options.ssoToken) {
|
||||
params.set('ssoToken', options.ssoToken);
|
||||
}
|
||||
|
||||
return `${normalizedBase}/?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import { AgentSystemDocumentsSettings } from '@/components/settings/AgentSystemDocumentsSettings';
|
||||
import {
|
||||
getGatewayWsDiagnosticEnabled,
|
||||
invokeIpc,
|
||||
@@ -136,8 +137,8 @@ type OfficeRuntimeDiagnostics = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type SettingsTab = 'account' | 'channels' | 'skills' | 'preferences' | 'runtime';
|
||||
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'preferences', 'runtime']);
|
||||
type SettingsTab = 'account' | 'channels' | 'skills' | 'system-docs' | 'preferences' | 'runtime';
|
||||
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'system-docs', 'preferences', 'runtime']);
|
||||
|
||||
const preferencesPanelClass = 'yinian-panel max-w-4xl overflow-hidden';
|
||||
const preferencesRowClass = 'p-5 sm:p-6';
|
||||
@@ -714,7 +715,7 @@ export function Settings() {
|
||||
<div data-testid="settings-page" className="flex h-[calc(100vh-2.5rem)] flex-col overflow-hidden -m-6 bg-[#F7FAFC] text-slate-950 dark:bg-background dark:text-slate-50">
|
||||
<div className="w-full max-w-6xl mx-auto flex flex-col h-full p-6">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-5 grid h-auto w-full shrink-0 grid-cols-2 gap-1 md:grid-cols-5">
|
||||
<TabsList className="mb-5 grid h-auto w-full shrink-0 grid-cols-2 gap-1 md:grid-cols-6">
|
||||
<TabsTrigger value="account" className="h-10 gap-2 rounded-md text-[14px]">
|
||||
<BriefcaseBusiness className="h-4 w-4" />
|
||||
{t('tabs.account')}
|
||||
@@ -727,6 +728,10 @@ export function Settings() {
|
||||
<Puzzle className="h-4 w-4" />
|
||||
{t('tabs.skills')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system-docs" className="h-10 gap-2 rounded-md text-[14px]">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('tabs.systemDocs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preferences" className="h-10 gap-2 rounded-md text-[14px]">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
{t('tabs.preferences')}
|
||||
@@ -893,6 +898,10 @@ export function Settings() {
|
||||
<YinianSkills />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system-docs" className="mt-0 h-full min-h-0 overflow-y-auto pr-2 pb-8 -mr-2">
|
||||
<AgentSystemDocumentsSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preferences" className="mt-0 h-full min-h-0 overflow-y-auto pr-2 pb-8 -mr-2">
|
||||
<div className={preferencesPanelClass}>
|
||||
<div className="border-b border-slate-200/80 p-5 dark:border-white/10 sm:p-6">
|
||||
@@ -1208,19 +1217,25 @@ export function Settings() {
|
||||
<div className="space-y-2">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">{t('developer.modelConfigProviders')}</div>
|
||||
<div className="space-y-2">
|
||||
{modelDiagnostics.providers.map((provider) => (
|
||||
<div key={provider.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-[12px] dark:border-white/10 dark:bg-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-foreground">{provider.key}</span>
|
||||
<Badge variant={provider.configured ? 'outline' : 'destructive'} className="rounded-full px-2 py-0 text-[11px]">
|
||||
{provider.configured ? t('developer.modelConfigConfigured') : t('developer.modelConfigMissing')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground">
|
||||
{provider.api || '-'} · {provider.modelCount} models
|
||||
</div>
|
||||
{modelDiagnostics.providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3 text-[12px] text-muted-foreground dark:border-white/10 dark:bg-card">
|
||||
{t('developer.modelConfigProvidersEmpty')}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
modelDiagnostics.providers.map((provider) => (
|
||||
<div key={provider.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-[12px] dark:border-white/10 dark:bg-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-foreground">{provider.key}</span>
|
||||
<Badge variant={provider.configured ? 'outline' : 'destructive'} className="rounded-full px-2 py-0 text-[11px]">
|
||||
{provider.configured ? t('developer.modelConfigConfigured') : t('developer.modelConfigMissing')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground">
|
||||
{provider.api || '-'} · {provider.modelCount} models
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
calculateInitializationProgress,
|
||||
describeInitializationFailure,
|
||||
mapInitializationSteps,
|
||||
type InitializationResult,
|
||||
type InstallStatus,
|
||||
type SkillInstallState,
|
||||
} from './initialization';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
@@ -91,6 +99,7 @@ export function Setup() {
|
||||
// Setup state
|
||||
// Installation state for the Installing step
|
||||
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
|
||||
const [installedModel, setInstalledModel] = useState<string | undefined>();
|
||||
const steps = getSteps(t);
|
||||
const safeStepIndex = Number.isInteger(currentStep)
|
||||
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
|
||||
@@ -136,8 +145,9 @@ export function Setup() {
|
||||
};
|
||||
|
||||
// Auto-proceed when installation is complete
|
||||
const handleInstallationComplete = useCallback((skills: string[]) => {
|
||||
const handleInstallationComplete = useCallback((skills: string[], model?: string) => {
|
||||
setInstalledSkills(skills);
|
||||
setInstalledModel(model);
|
||||
// Auto-proceed to next step after a short delay
|
||||
setTimeout(() => {
|
||||
setCurrentStep((i) => i + 1);
|
||||
@@ -210,6 +220,7 @@ export function Setup() {
|
||||
{safeStepIndex === STEP.COMPLETE && (
|
||||
<CompleteContent
|
||||
installedSkills={installedSkills}
|
||||
installedModel={installedModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -637,20 +648,9 @@ void RuntimeContent;
|
||||
// NOTE: ProviderContent component removed - configure providers via Settings > AI Providers
|
||||
|
||||
|
||||
// Initialization status for each first-run task
|
||||
type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
|
||||
|
||||
interface SkillInstallState {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: InstallStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface InstallingContentProps {
|
||||
skills: DefaultSkill[];
|
||||
onComplete: (installedSkills: string[]) => void;
|
||||
onComplete: (installedSkills: string[], model?: string) => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
@@ -661,7 +661,7 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
|
||||
[
|
||||
{ id: 'runtime', name: '安装 OpenClaw', description: '重装内置运行环境,避免客户本机残留版本影响启动', status: 'pending' as InstallStatus },
|
||||
{ id: 'workspace', name: '准备本地工作区', description: '创建智念助手所需的本地工作区与运行目录', status: 'pending' as InstallStatus },
|
||||
{ id: 'model', name: '写入内测模型', description: '使用当前开发环境的默认模型配置', status: 'pending' as InstallStatus },
|
||||
{ id: 'model', name: '准备模型 API 配置', description: '模型 API 可在设置中自定义配置', status: 'pending' as InstallStatus },
|
||||
{ id: 'python', name: '准备文档能力', description: '准备知识库与文档处理所需的本地环境标记', status: 'pending' as InstallStatus },
|
||||
]
|
||||
);
|
||||
@@ -679,44 +679,24 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
|
||||
setSkillStates(prev => prev.map((s, index) => ({ ...s, status: index === 0 ? 'installing' : 'pending' })));
|
||||
setOverallProgress(10);
|
||||
|
||||
const result = await invokeIpc('yinian:setup:initialize') as {
|
||||
initialized: boolean;
|
||||
openclawDir?: string;
|
||||
model?: string;
|
||||
steps?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const mappedSteps = (result.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
name: step.label,
|
||||
description: step.message || '',
|
||||
status: step.status === 'success'
|
||||
? 'completed' as const
|
||||
: step.status === 'error'
|
||||
? 'failed' as const
|
||||
: step.status === 'running'
|
||||
? 'installing' as const
|
||||
: 'pending' as const,
|
||||
message: step.message,
|
||||
}));
|
||||
const result = await invokeIpc('yinian:setup:initialize') as InitializationResult;
|
||||
const mappedSteps = mapInitializationSteps(result.steps);
|
||||
|
||||
if (mappedSteps.length > 0) {
|
||||
setSkillStates(mappedSteps);
|
||||
setOverallProgress(result.initialized ? 100 : calculateInitializationProgress(mappedSteps));
|
||||
}
|
||||
|
||||
if (result.initialized) {
|
||||
setOverallProgress(100);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
onComplete((result.steps ?? []).map((s) => s.id));
|
||||
onComplete((result.steps ?? []).map((s) => s.id), result.model);
|
||||
} else {
|
||||
setSkillStates(prev => prev.map(s => s.status === 'completed' ? s : { ...s, status: 'failed' }));
|
||||
setErrorMessage('初始化未完成,请查看失败项后重试。');
|
||||
if (mappedSteps.length === 0) {
|
||||
setSkillStates(prev => prev.map(s => s.status === 'installing' ? { ...s, status: 'failed' } : s));
|
||||
}
|
||||
setErrorMessage(describeInitializationFailure(result));
|
||||
toast.error('初始化失败');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -852,10 +832,12 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
|
||||
}
|
||||
interface CompleteContentProps {
|
||||
installedSkills: string[];
|
||||
installedModel?: string;
|
||||
}
|
||||
|
||||
function CompleteContent({ installedSkills }: CompleteContentProps) {
|
||||
function CompleteContent({ installedSkills, installedModel }: CompleteContentProps) {
|
||||
const { t } = useTranslation(['setup', 'settings']);
|
||||
const modelLabel = installedModel?.split('/').pop() || installedModel || '可在设置中配置';
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
@@ -864,7 +846,7 @@ function CompleteContent({ installedSkills }: CompleteContentProps) {
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('complete.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
智念助手已经完成运行环境、工作区和内测模型配置,下一步请登录账号。
|
||||
智念助手已经完成运行环境和工作区准备,模型 API 可在设置中自定义配置。
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 text-left max-w-md mx-auto">
|
||||
@@ -873,8 +855,8 @@ function CompleteContent({ installedSkills }: CompleteContentProps) {
|
||||
<span className="text-green-400">{installedSkills.length || 4} 项已完成</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<span>默认模型</span>
|
||||
<span className="text-green-400">MiniMax M2.7</span>
|
||||
<span>模型 API</span>
|
||||
<span className="text-green-400">{modelLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
58
src/pages/Setup/initialization.ts
Normal file
58
src/pages/Setup/initialization.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type InitializationStepStatus = 'pending' | 'running' | 'success' | 'error';
|
||||
export type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
|
||||
|
||||
export interface InitializationStepResult {
|
||||
id: string;
|
||||
label: string;
|
||||
status: InitializationStepStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface InitializationResult {
|
||||
initialized: boolean;
|
||||
openclawDir?: string;
|
||||
model?: string;
|
||||
steps?: InitializationStepResult[];
|
||||
}
|
||||
|
||||
export interface SkillInstallState {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: InstallStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function mapInitializationSteps(steps: InitializationStepResult[] = []): SkillInstallState[] {
|
||||
return steps.map((step) => ({
|
||||
id: step.id,
|
||||
name: step.label,
|
||||
description: step.message || '',
|
||||
status: step.status === 'success'
|
||||
? 'completed'
|
||||
: step.status === 'error'
|
||||
? 'failed'
|
||||
: step.status === 'running'
|
||||
? 'installing'
|
||||
: 'pending',
|
||||
message: step.message,
|
||||
}));
|
||||
}
|
||||
|
||||
export function calculateInitializationProgress(steps: SkillInstallState[], fallbackProgress = 10): number {
|
||||
if (steps.length === 0) return fallbackProgress;
|
||||
const completed = steps.filter((step) => step.status === 'completed').length;
|
||||
const runningBonus = steps.some((step) => step.status === 'installing') ? 0.5 : 0;
|
||||
return Math.max(fallbackProgress, Math.min(99, Math.round(((completed + runningBonus) / steps.length) * 100)));
|
||||
}
|
||||
|
||||
export function describeInitializationFailure(result: InitializationResult): string {
|
||||
const failedSteps = mapInitializationSteps(result.steps).filter((step) => step.status === 'failed');
|
||||
if (failedSteps.length === 0) {
|
||||
return '初始化未完成,请查看失败项后重试。';
|
||||
}
|
||||
|
||||
return failedSteps
|
||||
.map((step) => `${step.name}:${step.message || '未完成'}`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -1,57 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AppCenterItem } from '@/types/app-center';
|
||||
|
||||
const BUILT_IN_APPS: AppCenterItem[] = [
|
||||
{
|
||||
id: 'product-center',
|
||||
nameKey: 'items.productCenter.name',
|
||||
descriptionKey: 'items.productCenter.description',
|
||||
categoryKey: 'categories.procurement',
|
||||
tagKeys: ['tags.procurement', 'tags.managed', 'tags.pinned'],
|
||||
icon: 'ShoppingBag',
|
||||
type: 'webview',
|
||||
route: '/app-center/product-center',
|
||||
url: 'https://ticket.nianxx.cn/',
|
||||
pinned: true,
|
||||
source: 'built-in',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'nianxx-play',
|
||||
nameKey: 'items.nianxxPlay.name',
|
||||
descriptionKey: 'items.nianxxPlay.description',
|
||||
categoryKey: 'categories.video',
|
||||
tagKeys: ['tags.video', 'tags.managed', 'tags.pinned'],
|
||||
icon: 'Clapperboard',
|
||||
type: 'native',
|
||||
route: '/app-center/nianxx-play',
|
||||
pinned: true,
|
||||
source: 'built-in',
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
const BUILT_IN_APPS: AppCenterItem[] = [];
|
||||
|
||||
interface AppCenterState {
|
||||
items: AppCenterItem[];
|
||||
selectedItemId: string | null;
|
||||
selectedTagKey: string;
|
||||
initialized: boolean;
|
||||
init: () => void;
|
||||
selectItem: (itemId: string | null) => void;
|
||||
selectTag: (tagKey: string) => void;
|
||||
}
|
||||
|
||||
export const useAppCenterStore = create<AppCenterState>((set, get) => ({
|
||||
items: BUILT_IN_APPS,
|
||||
selectedItemId: null,
|
||||
selectedTagKey: 'all',
|
||||
initialized: false,
|
||||
|
||||
init: () => {
|
||||
if (get().initialized) return;
|
||||
set({ items: BUILT_IN_APPS, selectedItemId: null, initialized: true });
|
||||
set({ items: BUILT_IN_APPS, initialized: true });
|
||||
},
|
||||
|
||||
selectItem: (itemId) => set({ selectedItemId: itemId }),
|
||||
selectTag: (tagKey) => set({ selectedTagKey: tagKey }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user