chore: stabilize Zhinian pilot delivery

This commit is contained in:
inman
2026-05-12 19:44:44 +08:00
parent 45389855e1
commit 20b5aff4ad
174 changed files with 41428 additions and 784 deletions

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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