cc-haha/desktop/src/components/doctor/DoctorPanel.tsx
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

107 lines
3.6 KiB
TypeScript

import { Stethoscope } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../shared/Button'
import { useTranslation } from '../../i18n'
import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair'
import { useUIStore } from '../../stores/uiStore'
type DoctorPanelProps = {
compact?: boolean
}
export function DoctorPanel({ compact = false }: DoctorPanelProps) {
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<DoctorRepairResult | null>(null)
const handleRunDoctor = async () => {
setIsRunning(true)
try {
const nextResult = await runDoctorRepair()
setResult(nextResult)
addToast({
type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning',
message: getDoctorToastMessage(t, nextResult),
})
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
})
} finally {
setIsRunning(false)
}
}
const statusText = result ? getDoctorStatusMessage(t, result) : null
return (
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'} `}>
<div className={`flex ${compact ? 'flex-col gap-3' : 'items-start justify-between gap-4'}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.doctorTitle')}</div>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorDescription')}
</p>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorProtectedData')}
</p>
</div>
<div className={`flex ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
<Button
size="sm"
onClick={handleRunDoctor}
loading={isRunning}
icon={<Stethoscope className="h-4 w-4" aria-hidden="true" />}
>
{t('settings.diagnostics.runDoctor')}
</Button>
</div>
</div>
<div className="mt-2 text-[11px] leading-relaxed text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorSafeKeys')}
</div>
{statusText ? (
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
{statusText}
</div>
) : null}
</section>
)
}
function getDoctorToastMessage(
t: ReturnType<typeof useTranslation>,
result: DoctorRepairResult,
): string {
if (result.local.failedKeys.length > 0) {
return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) })
}
return t('settings.diagnostics.doctorCompleted')
}
function getDoctorStatusMessage(
t: ReturnType<typeof useTranslation>,
result: DoctorRepairResult,
): string {
const clearedCount = result.local.removedKeys.length
const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) })
if (result.local.failedKeys.length > 0) {
return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}`
}
if (result.server) {
return `${base} ${t('settings.diagnostics.doctorServerRan')}`
}
if (result.serverError) {
return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}`
}
return base
}