chore: stabilize Zhinian pilot delivery
This commit is contained in:
@@ -30,7 +30,9 @@ export function AppCenter() {
|
||||
const init = useAppCenterStore((state) => state.init);
|
||||
const items = useAppCenterStore((state) => state.items);
|
||||
const selectedTagKey = useAppCenterStore((state) => state.selectedTagKey);
|
||||
const selectedItemId = useAppCenterStore((state) => state.selectedItemId);
|
||||
const selectTag = useAppCenterStore((state) => state.selectTag);
|
||||
const selectItem = useAppCenterStore((state) => state.selectItem);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
@@ -70,7 +72,7 @@ export function AppCenter() {
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-slate-950 dark:text-slate-50 md:text-3xl">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,44 +116,56 @@ export function AppCenter() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(156px,1fr))] gap-3 pb-1">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = getAppIcon(item.icon);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openItem(item);
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex min-h-[164px] flex-col items-center overflow-hidden rounded-lg border bg-white px-3 pb-10 pt-4 text-center shadow-none transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:border-[#7DBADB] hover:bg-white hover:shadow-[0_16px_36px_rgba(15,23,42,0.08)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E3A8A]/30',
|
||||
'border-slate-200/80 dark:border-white/10 dark:bg-slate-950/75 dark:hover:shadow-[0_10px_24px_rgba(0,0,0,0.24)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-[#0369A1] text-white shadow-[0_8px_18px_rgba(3,105,161,0.18)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-3 min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-950 dark:text-slate-50">
|
||||
{t(item.nameKey)}
|
||||
<div className="min-h-0 overflow-y-auto px-2 py-2">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed border-slate-200 bg-white/60 text-sm text-muted-foreground dark:border-white/10 dark:bg-white/5">
|
||||
{t('empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,260px))] justify-center gap-4 pb-2 sm:justify-start">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = getAppIcon(item.icon);
|
||||
const isSelected = selectedItemId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectItem(item.id);
|
||||
openItem(item);
|
||||
}}
|
||||
data-testid={`app-center-item-${item.id}`}
|
||||
className={cn(
|
||||
'group relative flex min-h-[216px] w-full flex-col items-center overflow-hidden rounded-lg border bg-white px-4 pb-4 pt-5 text-center shadow-none transition-all duration-200',
|
||||
'hover:border-[#7DBADB] hover:bg-white hover:shadow-[0_18px_42px_rgba(15,23,42,0.09)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E3A8A]/30',
|
||||
'dark:border-white/10 dark:bg-slate-950/75 dark:hover:shadow-[0_10px_24px_rgba(0,0,0,0.24)]',
|
||||
isSelected
|
||||
? 'border-[#0369A1] bg-[#F6FBFE] shadow-[0_14px_30px_rgba(3,105,161,0.10)]'
|
||||
: 'border-slate-200/80',
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-[linear-gradient(180deg,rgba(229,244,250,0.85),rgba(255,255,255,0))] dark:bg-[linear-gradient(180deg,rgba(30,58,138,0.18),rgba(15,23,42,0))]" />
|
||||
<div className="relative flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-[#0369A1] text-white shadow-[0_12px_24px_rgba(3,105,161,0.18)]">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="mx-auto mt-1 line-clamp-2 max-w-[13rem] text-xs leading-4 text-muted-foreground">
|
||||
{t(item.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute bottom-3 left-1/2 inline-flex h-7 -translate-x-1/2 items-center gap-1 rounded-lg bg-[#0369A1] px-2.5 text-[11px] font-medium text-white opacity-0 shadow-[0_8px_16px_rgba(3,105,161,0.16)] transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
{t('actions.open')}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-[#0369A1]/[0.06] to-transparent opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:from-blue-300/[0.08]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mt-4 min-w-0">
|
||||
<div className="truncate text-base font-semibold tracking-normal text-slate-950 dark:text-slate-50">
|
||||
{t(item.nameKey)}
|
||||
</div>
|
||||
<p className="mx-auto mt-2 line-clamp-3 max-w-[13rem] text-xs leading-5 text-muted-foreground">
|
||||
{t(item.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="relative mt-auto inline-flex h-8 items-center gap-1.5 rounded-lg border border-[#D5E8F3] bg-[#F4FAFD] px-3 text-xs font-medium text-[#075985] opacity-0 transition-all duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 dark:border-white/10 dark:bg-white/5 dark:text-blue-200">
|
||||
{t('actions.open')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</YinianPanel>
|
||||
|
||||
@@ -505,6 +505,7 @@ export function ChatInput({
|
||||
type="button"
|
||||
onClick={() => toggleQuickTask(task.id)}
|
||||
disabled={disabled || sending}
|
||||
data-testid={`chat-quick-task-${task.id}`}
|
||||
className={cn(
|
||||
'inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] font-medium shadow-sm transition-colors',
|
||||
selected
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||
import { sanitizeAssistantMessages } from '@/stores/chat/assistant-output-sanitizer';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
@@ -85,6 +86,7 @@ export function Chat() {
|
||||
const streamingTools = useChatStore((s) => s.streamingTools);
|
||||
const pendingFinal = useChatStore((s) => s.pendingFinal);
|
||||
const activeRunId = useChatStore((s) => s.activeRunId);
|
||||
const activeRunSessionKey = useChatStore((s) => s.activeRunSessionKey);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const abortRun = useChatStore((s) => s.abortRun);
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
@@ -166,7 +168,7 @@ export function Chat() {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { sessionId: completion.sessionId, messages: result.messages || [] };
|
||||
return { sessionId: completion.sessionId, messages: sanitizeAssistantMessages(result.messages || []) };
|
||||
} catch (error) {
|
||||
console.warn('Failed to load child transcript:', {
|
||||
agentId: completion.agentId,
|
||||
@@ -193,24 +195,33 @@ export function Chat() {
|
||||
};
|
||||
}, [messages, childTranscripts]);
|
||||
|
||||
const streamMsg = streamingMessage && typeof streamingMessage === 'object'
|
||||
? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
|
||||
const currentSessionRunning = sending && activeRunSessionKey === currentSessionKey;
|
||||
const visibleStreamingMessage = currentSessionRunning ? streamingMessage : null;
|
||||
const visibleStreamingTools = currentSessionRunning ? streamingTools : [];
|
||||
const visiblePendingFinal = currentSessionRunning ? pendingFinal : false;
|
||||
const visibleActiveRunId = currentSessionRunning ? activeRunId : null;
|
||||
const visibleError = sending && activeRunSessionKey && activeRunSessionKey !== currentSessionKey
|
||||
? null
|
||||
: error;
|
||||
|
||||
const streamMsg = visibleStreamingMessage && typeof visibleStreamingMessage === 'object'
|
||||
? visibleStreamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
|
||||
: null;
|
||||
const streamTimestamp = typeof streamMsg?.timestamp === 'number' ? streamMsg.timestamp : 0;
|
||||
useEffect(() => {
|
||||
if (!sending) {
|
||||
if (!currentSessionRunning) {
|
||||
streamingTimestampStore.delete(currentSessionKey);
|
||||
return;
|
||||
}
|
||||
if (!streamingTimestampStore.has(currentSessionKey)) {
|
||||
streamingTimestampStore.set(currentSessionKey, streamTimestamp || Date.now() / 1000);
|
||||
}
|
||||
}, [currentSessionKey, sending, streamTimestamp]);
|
||||
}, [currentSessionKey, currentSessionRunning, streamTimestamp]);
|
||||
|
||||
const streamingTimestamp = sending
|
||||
const streamingTimestamp = currentSessionRunning
|
||||
? (streamingTimestampStore.get(currentSessionKey) ?? streamTimestamp)
|
||||
: 0;
|
||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
|
||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof visibleStreamingMessage === 'string' ? visibleStreamingMessage : '');
|
||||
const hasStreamText = streamText.trim().length > 0;
|
||||
// Whether the streaming chunk currently carries a `thinking` block. Used as
|
||||
// a liveness signal so the run stays "active" (and the ExecutionGraphCard
|
||||
@@ -225,12 +236,12 @@ export function Chat() {
|
||||
const hasStreamTools = streamTools.length > 0;
|
||||
const streamImages = streamMsg ? extractImages(streamMsg) : [];
|
||||
const hasStreamImages = streamImages.length > 0;
|
||||
const hasStreamToolStatus = streamingTools.length > 0;
|
||||
const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running');
|
||||
const shouldRenderStreaming = sending && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||
const hasStreamToolStatus = visibleStreamingTools.length > 0;
|
||||
const hasRunningStreamToolStatus = visibleStreamingTools.some((tool) => tool.status === 'running');
|
||||
const shouldRenderStreaming = currentSessionRunning && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
||||
|
||||
const isEmpty = messages.length === 0 && !sending;
|
||||
const isEmpty = messages.length === 0 && !currentSessionRunning;
|
||||
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
|
||||
// Build an index of the *next* real user message after each position.
|
||||
// Gateway history may contain `role: 'user'` messages that are actually
|
||||
@@ -311,15 +322,15 @@ export function Chat() {
|
||||
// (which clears activeRunId), we must NOT keep the run "open" — so we
|
||||
// gate it on activeRunId being present.
|
||||
const isLatestOpenRun = nextUserIndex === -1
|
||||
&& (sending || pendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!activeRunId));
|
||||
&& (currentSessionRunning || visiblePendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!visibleActiveRunId));
|
||||
const replyIndexOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun);
|
||||
const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset;
|
||||
|
||||
const buildSteps = (omitLastStreamingMessageSegment: boolean): TaskStep[] => {
|
||||
let builtSteps = deriveTaskSteps({
|
||||
messages: segmentMessages,
|
||||
streamingMessage: isLatestOpenRun ? streamingMessage : null,
|
||||
streamingTools: isLatestOpenRun ? streamingTools : [],
|
||||
streamingMessage: isLatestOpenRun ? visibleStreamingMessage : null,
|
||||
streamingTools: isLatestOpenRun ? visibleStreamingTools : [],
|
||||
omitLastStreamingMessageSegment: isLatestOpenRun ? omitLastStreamingMessageSegment : false,
|
||||
});
|
||||
|
||||
@@ -384,9 +395,9 @@ export function Chat() {
|
||||
// `suppressThinking` coupling below — not here. With the coupling
|
||||
// fixed, the three-signal gate gives the correct bubble placement for
|
||||
// both narration and final reply.
|
||||
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
|
||||
const allToolsCompleted = visibleStreamingTools.length > 0 && !hasRunningStreamToolStatus;
|
||||
const rawStreamingReplyCandidate = isLatestOpenRun
|
||||
&& (pendingFinal || allToolsCompleted || hasToolActivity)
|
||||
&& (visiblePendingFinal || allToolsCompleted || hasToolActivity)
|
||||
&& (hasStreamText || hasStreamImages)
|
||||
&& streamTools.length === 0
|
||||
&& !hasRunningStreamToolStatus;
|
||||
@@ -701,17 +712,17 @@ export function Chat() {
|
||||
})()}
|
||||
textOverride={streamingReplyText ?? undefined}
|
||||
isStreaming
|
||||
streamingTools={streamingReplyText != null ? [] : streamingTools}
|
||||
streamingTools={streamingReplyText != null ? [] : visibleStreamingTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity indicator: waiting for next AI turn after tool execution */}
|
||||
{sending && pendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && (
|
||||
{currentSessionRunning && visiblePendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && (
|
||||
<ActivityIndicator phase="tool_processing" />
|
||||
)}
|
||||
|
||||
{/* Typing indicator when sending but no stream content yet */}
|
||||
{sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
|
||||
{currentSessionRunning && !visiblePendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
|
||||
<TypingIndicator />
|
||||
)}
|
||||
</>
|
||||
@@ -723,12 +734,12 @@ export function Chat() {
|
||||
</div>
|
||||
|
||||
{/* Error bar */}
|
||||
{error && (
|
||||
{visibleError && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||
<p className="text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
{visibleError}
|
||||
</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
@@ -745,14 +756,14 @@ export function Chat() {
|
||||
onSend={sendMessage}
|
||||
onStop={abortRun}
|
||||
disabled={!isGatewayRunning}
|
||||
sending={sending || hasActiveExecutionGraph}
|
||||
sending={currentSessionRunning || hasActiveExecutionGraph}
|
||||
isEmpty={isEmpty}
|
||||
knowledgeDocuments={knowledgeDocuments}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{/* Transparent loading overlay */}
|
||||
{minLoading && !sending && (
|
||||
{minLoading && !currentSessionRunning && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/20 backdrop-blur-[1px] rounded-xl pointer-events-auto">
|
||||
<div className="bg-background shadow-lg rounded-full p-2.5 border border-border">
|
||||
<LoadingSpinner size="md" />
|
||||
|
||||
@@ -10,8 +10,16 @@ import type { RawMessage, ContentBlock } from '@/stores/chat';
|
||||
* Strips: [media attached: ... | ...], [message_id: ...],
|
||||
* and the timestamp prefix [Day Date Time Timezone].
|
||||
*/
|
||||
const KNOWLEDGE_CONTEXT_MARKER = '[知识库上下文]';
|
||||
|
||||
function stripInjectedKnowledgeContext(text: string): string {
|
||||
const markerIndex = text.indexOf(KNOWLEDGE_CONTEXT_MARKER);
|
||||
if (markerIndex < 0) return text;
|
||||
return text.slice(0, markerIndex).trimEnd();
|
||||
}
|
||||
|
||||
function cleanUserText(text: string): string {
|
||||
return text
|
||||
return stripInjectedKnowledgeContext(text)
|
||||
// Remove [media attached: path (mime) | path] references
|
||||
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||
// Remove [message_id: uuid]
|
||||
|
||||
@@ -298,7 +298,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
|
||||
|| selectedChannel?.defaultAccountId
|
||||
|| deliveryAccountOptions[0]?.accountId
|
||||
|| '';
|
||||
const showsAccountSelector = (selectedChannel?.accounts.length ?? 0) > 0;
|
||||
const showsAccountSelector = (selectedChannel?.accounts?.length ?? 0) > 0;
|
||||
const selectedResolvedAccountId = effectiveDeliveryAccountId || undefined;
|
||||
const availableTargetOptions = currentDeliveryTargetOption
|
||||
? [currentDeliveryTargetOption, ...channelTargetOptions.filter((option) => option.value !== deliveryTarget)]
|
||||
@@ -856,7 +856,11 @@ export function Cron() {
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load delivery channels');
|
||||
}
|
||||
setConfiguredChannels(response.channels || []);
|
||||
const channels = Array.isArray(response.channels) ? response.channels : [];
|
||||
setConfiguredChannels(channels.map((group) => ({
|
||||
...group,
|
||||
accounts: Array.isArray(group.accounts) ? group.accounts : [],
|
||||
})));
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to load delivery channels:', fetchError);
|
||||
setConfiguredChannels([]);
|
||||
@@ -901,14 +905,14 @@ export function Cron() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||
<div data-testid="cron-page" className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div data-testid="cron-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div className="mx-auto flex h-full w-full max-w-5xl flex-col p-6 pt-8 md:p-10 md:pt-12">
|
||||
{/* Header */}
|
||||
<div className="yinian-visual-band mb-8 flex shrink-0 flex-col justify-between gap-4 rounded-lg border border-[#D5E8F3]/75 p-5 md:flex-row md:items-start">
|
||||
|
||||
@@ -97,7 +97,7 @@ export function Knowledge() {
|
||||
|
||||
const refreshFiles = async () => {
|
||||
const result = await hostApiFetch<{ documents: KnowledgeFile[] }>(`/api/knowledge/files?workspaceId=${encodeURIComponent(workspaceId)}`);
|
||||
setFiles(result.documents);
|
||||
setFiles(Array.isArray(result.documents) ? result.documents : []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,11 +133,14 @@ export function Knowledge() {
|
||||
|
||||
await refreshFiles();
|
||||
|
||||
if (result.documents.length > 0) {
|
||||
toast.success(t('knowledge.toast.saved', { count: result.documents.length }));
|
||||
const importedDocuments = Array.isArray(result.documents) ? result.documents : [];
|
||||
const rejectedFiles = Array.isArray(result.rejected) ? result.rejected : [];
|
||||
|
||||
if (importedDocuments.length > 0) {
|
||||
toast.success(t('knowledge.toast.saved', { count: importedDocuments.length }));
|
||||
}
|
||||
if (result.rejected.length > 0) {
|
||||
toast.warning(t('knowledge.toast.rejected', { count: result.rejected.length, reason: result.rejected[0].reason }));
|
||||
if (rejectedFiles.length > 0) {
|
||||
toast.warning(t('knowledge.toast.rejected', { count: rejectedFiles.length, reason: rejectedFiles[0].reason }));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('knowledge.toast.uploadFailed', { message: toUserMessage(error) }));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { AlertTriangle, ArrowLeft, Film, FolderClock, Loader2, RefreshCcw, WalletCards } from 'lucide-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';
|
||||
@@ -9,7 +9,7 @@ import { hostApiFetch } from '@/lib/host-api';
|
||||
|
||||
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
|
||||
|
||||
type NianxxPlayRoute = '/' | '/projects' | '/billing';
|
||||
type NianxxPlayRoute = '/' | '/projects' | '/planning';
|
||||
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
|
||||
|
||||
type NianxxPlayServiceStatus = {
|
||||
@@ -31,7 +31,7 @@ const NIANXX_PLAY_NAV: Array<{
|
||||
}> = [
|
||||
{ path: '/', labelKey: 'host.nav.create', icon: Film },
|
||||
{ path: '/projects', labelKey: 'host.nav.projects', icon: FolderClock },
|
||||
{ path: '/billing', labelKey: 'host.nav.billing', icon: WalletCards },
|
||||
{ path: '/planning', labelKey: 'host.nav.planning', icon: Sparkles },
|
||||
];
|
||||
|
||||
function buildEmbeddedSrc(baseUrl: string, route: NianxxPlayRoute, reloadKey: number) {
|
||||
@@ -136,6 +136,7 @@ export function NianxxPlay() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/app-center')}
|
||||
data-testid="nianxx-play-back"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -167,6 +168,7 @@ export function NianxxPlay() {
|
||||
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
|
||||
|
||||
@@ -68,8 +68,8 @@ type ModelConfigDiagnostics = {
|
||||
modelId: string | null;
|
||||
};
|
||||
runtime: {
|
||||
pricingEnabled: boolean | null;
|
||||
pricingCatalogFetchDisabled: boolean;
|
||||
heartbeatEvery: string | null;
|
||||
heartbeatDisabled: boolean;
|
||||
};
|
||||
providers: Array<{
|
||||
key: string;
|
||||
@@ -104,6 +104,37 @@ type ModelConfigDiagnostics = {
|
||||
};
|
||||
};
|
||||
|
||||
type OfficeRuntimeDiagnostics = {
|
||||
capturedAt: number;
|
||||
ok: boolean;
|
||||
repairAttempted: boolean;
|
||||
python: {
|
||||
executable: string | null;
|
||||
packages: Array<{
|
||||
packageName: string;
|
||||
importName: string;
|
||||
installed: boolean;
|
||||
}>;
|
||||
};
|
||||
node: {
|
||||
modules: Array<{
|
||||
name: string;
|
||||
installed: boolean;
|
||||
resolvedPath: string | null;
|
||||
}>;
|
||||
};
|
||||
dotnet: {
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
};
|
||||
checks: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
detail: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SettingsTab = 'account' | 'channels' | 'skills' | 'preferences' | 'runtime';
|
||||
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'preferences', 'runtime']);
|
||||
|
||||
@@ -181,6 +212,9 @@ export function Settings() {
|
||||
const [modelDiagnostics, setModelDiagnostics] = useState<ModelConfigDiagnostics | null>(null);
|
||||
const [modelDiagnosticsLoading, setModelDiagnosticsLoading] = useState(false);
|
||||
const [modelDiagnosticsError, setModelDiagnosticsError] = useState<string | null>(null);
|
||||
const [officeDiagnostics, setOfficeDiagnostics] = useState<OfficeRuntimeDiagnostics | null>(null);
|
||||
const [officeDiagnosticsLoading, setOfficeDiagnosticsLoading] = useState(false);
|
||||
const [officeDiagnosticsError, setOfficeDiagnosticsError] = useState<string | null>(null);
|
||||
|
||||
const isWindows = window.electron.platform === 'win32';
|
||||
const showCliTools = true;
|
||||
@@ -219,6 +253,25 @@ export function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadOfficeDiagnostics = async (repair = false) => {
|
||||
setOfficeDiagnosticsLoading(true);
|
||||
setOfficeDiagnosticsError(null);
|
||||
try {
|
||||
const query = repair ? '?repair=1' : '';
|
||||
const diagnostics = await hostApiFetch<OfficeRuntimeDiagnostics>(`/api/diagnostics/office-runtime${query}`);
|
||||
setOfficeDiagnostics(diagnostics);
|
||||
if (repair) {
|
||||
toast.success(t('developer.officeRuntimeRepaired'));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error);
|
||||
setOfficeDiagnosticsError(message);
|
||||
toast.error(t('developer.officeRuntimeLoadFailed', { message }));
|
||||
} finally {
|
||||
setOfficeDiagnosticsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopUserNameDraft(yinianConfig?.user.name ?? '');
|
||||
}, [yinianConfig?.user.name]);
|
||||
@@ -231,7 +284,10 @@ export function Settings() {
|
||||
if (activeTab === 'runtime' && devModeUnlocked && !modelDiagnostics && !modelDiagnosticsLoading) {
|
||||
void loadModelDiagnostics();
|
||||
}
|
||||
}, [activeTab, devModeUnlocked, modelDiagnostics, modelDiagnosticsLoading]);
|
||||
if (activeTab === 'runtime' && devModeUnlocked && !officeDiagnostics && !officeDiagnosticsLoading) {
|
||||
void loadOfficeDiagnostics();
|
||||
}
|
||||
}, [activeTab, devModeUnlocked, modelDiagnostics, modelDiagnosticsLoading, officeDiagnostics, officeDiagnosticsLoading]);
|
||||
|
||||
const handleRefreshService = async () => {
|
||||
try {
|
||||
@@ -1115,8 +1171,8 @@ export function Settings() {
|
||||
<span className="break-all font-mono">{modelDiagnostics.model.providerKey || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{t('developer.modelConfigPricing')}</span>
|
||||
<span>{modelDiagnostics.runtime.pricingCatalogFetchDisabled ? t('common:status.disabled') : t('common:status.enabled')}</span>
|
||||
<span>{t('developer.modelConfigHeartbeat')}</span>
|
||||
<span>{modelDiagnostics.runtime.heartbeatDisabled ? t('common:status.disabled') : (modelDiagnostics.runtime.heartbeatEvery || t('common:status.enabled'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1135,7 +1191,7 @@ export function Settings() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground">
|
||||
{provider.api || '-'} · {provider.timeoutSeconds ? `${provider.timeoutSeconds}s` : '-'}
|
||||
{provider.api || '-'} · {provider.modelCount} models
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1175,6 +1231,104 @@ export function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devModeUnlocked && (
|
||||
<div className="yinian-panel p-4" data-testid="settings-office-runtime-diagnostics">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<Label className="text-[15px] font-medium text-foreground">{t('developer.officeRuntime')}</Label>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
{t('developer.officeRuntimeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadOfficeDiagnostics(false)}
|
||||
disabled={officeDiagnosticsLoading}
|
||||
className="h-9 rounded-lg border-slate-200 bg-transparent px-4 dark:border-white/10"
|
||||
>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', officeDiagnosticsLoading && 'animate-spin')} />
|
||||
{t('common:actions.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadOfficeDiagnostics(true)}
|
||||
disabled={officeDiagnosticsLoading}
|
||||
className="h-9 rounded-lg border-slate-200 bg-transparent px-4 dark:border-white/10"
|
||||
>
|
||||
{t('developer.officeRuntimeRepair')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{officeDiagnosticsError && (
|
||||
<div className="mt-4 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">
|
||||
{officeDiagnosticsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{officeDiagnostics && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{officeDiagnostics.checks.map((check) => (
|
||||
<Badge
|
||||
key={check.id}
|
||||
variant={check.status === 'error' ? 'destructive' : 'outline'}
|
||||
className={cn(
|
||||
'gap-1.5 rounded-full px-3 py-1 text-[12px]',
|
||||
check.status === 'ok' && 'border-green-200 bg-green-50 text-green-700 dark:border-green-900/40 dark:bg-green-950/20 dark:text-green-300',
|
||||
check.status === 'warning' && 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300',
|
||||
)}
|
||||
title={check.detail}
|
||||
>
|
||||
{check.status === 'ok' ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{check.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-[12px] text-muted-foreground">{t('developer.officeRuntimePython')}</div>
|
||||
<div className="mt-1 break-all font-mono text-[12px] text-foreground">
|
||||
{officeDiagnostics.python.executable || '-'}
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-muted-foreground">
|
||||
{officeDiagnostics.python.packages.filter((pkg) => pkg.installed).length}/{officeDiagnostics.python.packages.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-[12px] text-muted-foreground">{t('developer.officeRuntimeNode')}</div>
|
||||
<div className="mt-1 text-[13px] text-foreground">
|
||||
{officeDiagnostics.node.modules.filter((mod) => mod.installed).length}/{officeDiagnostics.node.modules.length}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-[12px] text-muted-foreground">
|
||||
{officeDiagnostics.node.modules.map((mod) => mod.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-[12px] text-muted-foreground">{t('developer.officeRuntimeDotnet')}</div>
|
||||
<div className="mt-1 text-[13px] text-foreground">
|
||||
{officeDiagnostics.dotnet.available ? officeDiagnostics.dotnet.version || t('developer.modelConfigConfigured') : t('developer.modelConfigMissing')}
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-muted-foreground">
|
||||
{t('developer.officeRuntimeDotnetDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ export function YinianSkills() {
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
data-testid={`yinian-skills-tab-${tab.key}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
|
||||
Reference in New Issue
Block a user