From eaf4da5dbd03aaf8728d329cd04406c29c48187e Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Mon, 2 Mar 2026 08:25:28 +0000 Subject: [PATCH 1/2] feat: structured diagnosis result cards (closes #30) Parse diagnosis results into structured cards with severity badges, fix options, checklist checkboxes, and export (Markdown/JSON). Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/runtime/types.rs | 6 + src-tauri/src/runtime/zeroclaw/adapter.rs | 18 +++ src-tauri/src/runtime/zeroclaw/tool_intent.rs | 147 +++++++++++++++++- src/components/DiagnosisCard.tsx | 138 ++++++++++++++++ src/components/DoctorChat.tsx | 19 ++- src/lib/types.ts | 8 + 6 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 src/components/DiagnosisCard.tsx diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 725a7388..d1431877 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -96,6 +96,7 @@ pub enum RuntimeEvent { ChatDelta { text: String }, ChatFinal { text: String }, Invoke { payload: Value }, + DiagnosisReport { items: Value }, Error { error: RuntimeError }, Status { text: String }, } @@ -106,6 +107,7 @@ impl RuntimeEvent { Self::ChatDelta { .. } => "chat-delta", Self::ChatFinal { .. } => "chat-final", Self::Invoke { .. } => "invoke", + Self::DiagnosisReport { .. } => "diagnosis-report", Self::Error { .. } => "error", Self::Status { .. } => "status", } @@ -118,6 +120,10 @@ impl RuntimeEvent { pub fn chat_final(text: String) -> Self { Self::ChatFinal { text } } + + pub fn diagnosis_report(items: Value) -> Self { + Self::DiagnosisReport { items } + } } pub trait RuntimeAdapter { diff --git a/src-tauri/src/runtime/zeroclaw/adapter.rs b/src-tauri/src/runtime/zeroclaw/adapter.rs index b9c790d1..b610e422 100644 --- a/src-tauri/src/runtime/zeroclaw/adapter.rs +++ b/src-tauri/src/runtime/zeroclaw/adapter.rs @@ -69,6 +69,16 @@ impl ZeroclawDoctorAdapter { raw } + fn parse_diagnosis(raw: &str) -> Option<(RuntimeEvent, String)> { + let result = crate::runtime::zeroclaw::tool_intent::parse_diagnosis_result(raw)?; + let summary = result + .summary + .clone() + .unwrap_or_else(|| format!("诊断完成,发现 {} 个问题。", result.items.len())); + let items_value = serde_json::to_value(&result.items).ok()?; + Some((RuntimeEvent::diagnosis_report(items_value), summary)) + } + fn parse_tool_intent(raw: &str) -> Option<(RuntimeEvent, String)> { let intent = crate::runtime::zeroclaw::tool_intent::parse_tool_intent(raw)?; let reason = intent @@ -133,6 +143,10 @@ impl RuntimeAdapter for ZeroclawDoctorAdapter { .map(Self::normalize_doctor_output) .map_err(Self::map_error)?; append_history(&session_key, "system", &prompt); + if let Some((report, summary)) = Self::parse_diagnosis(&text) { + append_history(&session_key, "assistant", &summary); + return Ok(vec![RuntimeEvent::chat_final(summary), report]); + } if let Some((invoke, note)) = Self::parse_tool_intent(&text) { append_history(&session_key, "assistant", ¬e); return Ok(vec![RuntimeEvent::chat_final(note), invoke]); @@ -153,6 +167,10 @@ impl RuntimeAdapter for ZeroclawDoctorAdapter { let text = run_zeroclaw_message(&guarded, &key.instance_id, &key.storage_key()) .map(Self::normalize_doctor_output) .map_err(Self::map_error)?; + if let Some((report, summary)) = Self::parse_diagnosis(&text) { + append_history(&session_key, "assistant", &summary); + return Ok(vec![RuntimeEvent::chat_final(summary), report]); + } if let Some((invoke, note)) = Self::parse_tool_intent(&text) { append_history(&session_key, "assistant", ¬e); return Ok(vec![RuntimeEvent::chat_final(note), invoke]); diff --git a/src-tauri/src/runtime/zeroclaw/tool_intent.rs b/src-tauri/src/runtime/zeroclaw/tool_intent.rs index 5171ac56..f5a8c974 100644 --- a/src-tauri/src/runtime/zeroclaw/tool_intent.rs +++ b/src-tauri/src/runtime/zeroclaw/tool_intent.rs @@ -1,7 +1,7 @@ use crate::json_util::extract_json_objects; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolIntent { pub tool: String, pub args: String, @@ -137,6 +137,97 @@ pub fn parse_tool_intent(raw: &str) -> Option { None } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DiagnosisSeverity { + Error, + Warn, + Info, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosisItem { + pub problem: String, + pub severity: DiagnosisSeverity, + pub fix_options: Vec, + #[serde(default)] + pub action: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosisResult { + pub items: Vec, + #[serde(default)] + pub summary: Option, +} + +#[derive(Debug, Deserialize)] +struct DiagnosisPayload { + diagnosis: Vec, + #[serde(default)] + summary: Option, +} + +pub fn parse_diagnosis_result(raw: &str) -> Option { + let trimmed = raw.trim(); + let mut candidates = vec![trimmed.to_string()]; + if let Some(fenced) = extract_fenced_json(trimmed) { + if fenced != trimmed { + candidates.push(fenced); + } + } + for extracted in extract_json_objects(trimmed) { + if extracted != trimmed { + candidates.push(extracted); + } + } + + for candidate in candidates { + let Ok(payload) = serde_json::from_str::(&candidate) else { + continue; + }; + if payload.diagnosis.is_empty() { + continue; + } + return Some(DiagnosisResult { + items: payload.diagnosis, + summary: payload + .summary + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()), + }); + } + None +} + +pub fn export_diagnosis(result: &DiagnosisResult, format: &str) -> String { + match format { + "json" => serde_json::to_string_pretty(result).unwrap_or_default(), + _ => { + let mut md = String::new(); + if let Some(ref summary) = result.summary { + md.push_str(&format!("# Diagnosis Summary\n\n{summary}\n\n")); + } + for (i, item) in result.items.iter().enumerate() { + let sev = match item.severity { + DiagnosisSeverity::Error => "ERROR", + DiagnosisSeverity::Warn => "WARN", + DiagnosisSeverity::Info => "INFO", + }; + md.push_str(&format!("## {} [{sev}] {}\n\n", i + 1, item.problem)); + if !item.fix_options.is_empty() { + md.push_str("**Fix options:**\n\n"); + for opt in &item.fix_options { + md.push_str(&format!("- {opt}\n")); + } + md.push('\n'); + } + } + md + } + } +} + #[cfg(test)] mod tests { use super::{classify_invoke_type, parse_tool_intent}; @@ -194,4 +285,56 @@ mod tests { fn classify_invoke_type_marks_unknown_tool_as_write() { assert_eq!(classify_invoke_type("bash", "-lc \"cat /tmp/x\""), "write"); } + + use super::{export_diagnosis, parse_diagnosis_result, DiagnosisSeverity}; + + #[test] + fn parses_diagnosis_result_from_json() { + let raw = r#"{"diagnosis":[{"problem":"Config missing","severity":"error","fix_options":["Reinstall","Edit config"]}],"summary":"1 issue found"}"#; + let result = parse_diagnosis_result(raw).expect("diagnosis"); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].problem, "Config missing"); + assert_eq!(result.items[0].severity, DiagnosisSeverity::Error); + assert_eq!(result.items[0].fix_options.len(), 2); + assert_eq!(result.summary.as_deref(), Some("1 issue found")); + } + + #[test] + fn parses_diagnosis_result_embedded_in_text() { + let raw = r#"诊断完成。 +{"diagnosis":[{"problem":"Port conflict","severity":"warn","fix_options":["Change port"]}]}"#; + let result = parse_diagnosis_result(raw).expect("diagnosis"); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].severity, DiagnosisSeverity::Warn); + } + + #[test] + fn parse_diagnosis_result_returns_none_for_empty_diagnosis() { + let raw = r#"{"diagnosis":[]}"#; + assert!(parse_diagnosis_result(raw).is_none()); + } + + #[test] + fn parse_diagnosis_result_returns_none_for_non_diagnosis_json() { + let raw = r#"{"tool":"clawpal","args":"health check"}"#; + assert!(parse_diagnosis_result(raw).is_none()); + } + + #[test] + fn export_diagnosis_markdown() { + let raw = r#"{"diagnosis":[{"problem":"Broken","severity":"error","fix_options":["Fix A"]}],"summary":"Summary"}"#; + let result = parse_diagnosis_result(raw).unwrap(); + let md = export_diagnosis(&result, "markdown"); + assert!(md.contains("# Diagnosis Summary")); + assert!(md.contains("[ERROR]")); + assert!(md.contains("Fix A")); + } + + #[test] + fn export_diagnosis_json() { + let raw = r#"{"diagnosis":[{"problem":"Test","severity":"info","fix_options":[]}]}"#; + let result = parse_diagnosis_result(raw).unwrap(); + let json = export_diagnosis(&result, "json"); + assert!(json.contains("\"problem\": \"Test\"")); + } } diff --git a/src/components/DiagnosisCard.tsx b/src/components/DiagnosisCard.tsx new file mode 100644 index 00000000..2b96b19a --- /dev/null +++ b/src/components/DiagnosisCard.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ClipboardCopyIcon, DownloadIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { DiagnosisReportItem } from "@/lib/types"; + +interface DiagnosisCardProps { + items: DiagnosisReportItem[]; +} + +const severityConfig = { + error: { label: "ERROR", variant: "destructive" as const, border: "border-l-destructive" }, + warn: { label: "WARN", variant: "secondary" as const, border: "border-l-yellow-500" }, + info: { label: "INFO", variant: "outline" as const, border: "border-l-blue-500" }, +}; + +function formatMarkdown(items: DiagnosisReportItem[]): string { + return items + .map((item, i) => { + const sev = item.severity.toUpperCase(); + const lines = [`## ${i + 1}. [${sev}] ${item.problem}`]; + if (item.fix_options.length > 0) { + lines.push("", "**Fix options:**", ...item.fix_options.map((o) => `- ${o}`)); + } + return lines.join("\n"); + }) + .join("\n\n"); +} + +function formatJson(items: DiagnosisReportItem[]): string { + return JSON.stringify(items, null, 2); +} + +export function DiagnosisCard({ items }: DiagnosisCardProps) { + const { t } = useTranslation(); + const [checked, setChecked] = useState>({}); + const [exportOpen, setExportOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const toggleCheck = (idx: number) => { + setChecked((prev) => ({ ...prev, [idx]: !prev[idx] })); + }; + + const handleExport = (format: "markdown" | "json") => { + const text = format === "json" ? formatJson(items) : formatMarkdown(items); + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + setExportOpen(false); + }; + + return ( +
+
+ + {t("doctor.diagnosisReport", { defaultValue: "Diagnosis Report" })} ({items.length}) + +
+ + {exportOpen && ( +
+ + +
+ )} +
+
+ + {items.map((item, idx) => { + const cfg = severityConfig[item.severity] ?? severityConfig.info; + return ( + + +
+ toggleCheck(idx)} + className="mt-0.5" + /> +
+
+ + {cfg.label} + + {item.problem} +
+ {item.fix_options.length > 0 && ( +
    + {item.fix_options.map((opt, oi) => ( +
  • + + {opt} +
  • + ))} +
+ )} +
+ {item.action && ( + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/DoctorChat.tsx b/src/components/DoctorChat.tsx index 5ea0de76..8d7b9b81 100644 --- a/src/components/DoctorChat.tsx +++ b/src/components/DoctorChat.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { AgentMessageBubble } from "@/components/AgentMessageBubble"; +import { DiagnosisCard } from "@/components/DiagnosisCard"; import type { DoctorChatMessage } from "@/lib/types"; interface DoctorChatProps { @@ -53,12 +54,18 @@ export function DoctorChat({ )} {messages.map((msg) => ( - +
+ + {msg.diagnosisReport && msg.diagnosisReport.items.length > 0 && ( +
+ +
+ )} +
))} {loading && (
diff --git a/src/lib/types.ts b/src/lib/types.ts index bed4797e..ca664da6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -499,6 +499,13 @@ export interface DoctorInvoke { type: "read" | "write"; } +export interface DiagnosisReportItem { + problem: string; + severity: "error" | "warn" | "info"; + fix_options: string[]; + action?: { tool: string; args: string; instance?: string; reason?: string }; +} + export interface DoctorChatMessage { id: string; role: "assistant" | "user" | "tool-call" | "tool-result"; @@ -507,6 +514,7 @@ export interface DoctorChatMessage { invokeResult?: unknown; invokeId?: string; status?: "pending" | "approved" | "rejected" | "auto"; + diagnosisReport?: { items: DiagnosisReportItem[] }; } export interface ApplyQueueResult { From ad38ffb24a0c095fc8dbfe5f50bda8f01104e7b4 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Mon, 2 Mar 2026 10:41:17 +0000 Subject: [PATCH 2/2] fix: wire DiagnosisReport through bridge and frontend listener Address review feedback on PR #39: 1. doctor_runtime_bridge.rs: add DiagnosisReport arm to both map_runtime_event_name and emit_runtime_event (was a compile error) 2. use-doctor-agent.ts: add doctor:diagnosis-report event listener that attaches diagnosis data to the most recent assistant message, enabling DiagnosisCard rendering in DoctorChat 3. adapter.rs: replace hardcoded Chinese fallback summary with language-neutral English default --- src-tauri/src/doctor_runtime_bridge.rs | 4 +++ src-tauri/src/runtime/zeroclaw/adapter.rs | 11 ++++--- src/lib/use-doctor-agent.ts | 35 ++++++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/doctor_runtime_bridge.rs b/src-tauri/src/doctor_runtime_bridge.rs index b995b660..634eadd6 100644 --- a/src-tauri/src/doctor_runtime_bridge.rs +++ b/src-tauri/src/doctor_runtime_bridge.rs @@ -8,6 +8,7 @@ pub fn map_runtime_event_name(event: &RuntimeEvent) -> &'static str { RuntimeEvent::ChatDelta { .. } => "doctor:chat-delta", RuntimeEvent::ChatFinal { .. } => "doctor:chat-final", RuntimeEvent::Invoke { .. } => "doctor:invoke", + RuntimeEvent::DiagnosisReport { .. } => "doctor:diagnosis-report", RuntimeEvent::Error { .. } => "doctor:error", RuntimeEvent::Status { .. } => "doctor:status", } @@ -35,6 +36,9 @@ pub fn emit_runtime_event(app: &AppHandle, event: RuntimeEvent) { }), ); } + RuntimeEvent::DiagnosisReport { items } => { + let _ = app.emit(name, json!({ "items": items })); + } RuntimeEvent::Status { text } => { let _ = app.emit(name, json!({ "text": text })); } diff --git a/src-tauri/src/runtime/zeroclaw/adapter.rs b/src-tauri/src/runtime/zeroclaw/adapter.rs index b610e422..3d8855ef 100644 --- a/src-tauri/src/runtime/zeroclaw/adapter.rs +++ b/src-tauri/src/runtime/zeroclaw/adapter.rs @@ -71,10 +71,13 @@ impl ZeroclawDoctorAdapter { fn parse_diagnosis(raw: &str) -> Option<(RuntimeEvent, String)> { let result = crate::runtime::zeroclaw::tool_intent::parse_diagnosis_result(raw)?; - let summary = result - .summary - .clone() - .unwrap_or_else(|| format!("诊断完成,发现 {} 个问题。", result.items.len())); + let count = result.items.len(); + let summary = result.summary.clone().unwrap_or_else(|| { + format!( + "Diagnosis complete — found {count} issue{}.", + if count == 1 { "" } else { "s" } + ) + }); let items_value = serde_json::to_value(&result.items).ok()?; Some((RuntimeEvent::diagnosis_report(items_value), summary)) } diff --git a/src/lib/use-doctor-agent.ts b/src/lib/use-doctor-agent.ts index 23d6139f..ad4d7796 100644 --- a/src/lib/use-doctor-agent.ts +++ b/src/lib/use-doctor-agent.ts @@ -3,7 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import i18n from "../i18n"; import { api } from "./api"; import { doctorStartPromptTemplate, renderPromptTemplate } from "./prompt-templates"; -import type { DoctorChatMessage, DoctorInvoke } from "./types"; +import type { DiagnosisReportItem, DoctorChatMessage, DoctorInvoke } from "./types"; let msgCounter = 0; function nextMsgId(): string { @@ -173,6 +173,39 @@ export function useDoctorAgent() { return [...prev, { id: nextMsgId(), role: "assistant", content: text }]; }); }), + bind<{ items: DiagnosisReportItem[] }>("doctor:diagnosis-report", (e) => { + if (!sessionActiveRef.current) return; + const items = e.payload.items; + if (!items || items.length === 0) return; + // Attach the diagnosis report to the most recent assistant message, + // or create a new one if none exists yet. + setMessages((prev) => { + let lastAssistantIdx = -1; + for (let i = prev.length - 1; i >= 0; i--) { + if (prev[i].role === "assistant" && !prev[i].invoke) { + lastAssistantIdx = i; + break; + } + } + if (lastAssistantIdx !== -1) { + const updated = [...prev]; + updated[lastAssistantIdx] = { + ...updated[lastAssistantIdx], + diagnosisReport: { items }, + }; + return updated; + } + return [ + ...prev, + { + id: nextMsgId(), + role: "assistant", + content: "", + diagnosisReport: { items }, + }, + ]; + }); + }), bind("doctor:invoke", (e) => { // Ignore invokes arriving before diagnosis starts. Stale invokes // (replayed by gateway during reconnect) are rejected on the Rust side