Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src-tauri/src/doctor_runtime_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down Expand Up @@ -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 }));
}
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/runtime/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}
Expand All @@ -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",
}
Expand All @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/runtime/zeroclaw/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ impl ZeroclawDoctorAdapter {
raw
}

fn parse_diagnosis(raw: &str) -> Option<(RuntimeEvent, String)> {
let result = crate::runtime::zeroclaw::tool_intent::parse_diagnosis_result(raw)?;
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))
}

fn parse_tool_intent(raw: &str) -> Option<(RuntimeEvent, String)> {
let intent = crate::runtime::zeroclaw::tool_intent::parse_tool_intent(raw)?;
let reason = intent
Expand Down Expand Up @@ -133,6 +146,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", &note);
return Ok(vec![RuntimeEvent::chat_final(note), invoke]);
Expand All @@ -153,6 +170,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", &note);
return Ok(vec![RuntimeEvent::chat_final(note), invoke]);
Expand Down
147 changes: 145 additions & 2 deletions src-tauri/src/runtime/zeroclaw/tool_intent.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -137,6 +137,97 @@ pub fn parse_tool_intent(raw: &str) -> Option<ToolIntent> {
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<String>,
#[serde(default)]
pub action: Option<ToolIntent>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosisResult {
pub items: Vec<DiagnosisItem>,
#[serde(default)]
pub summary: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DiagnosisPayload {
diagnosis: Vec<DiagnosisItem>,
#[serde(default)]
summary: Option<String>,
}

pub fn parse_diagnosis_result(raw: &str) -> Option<DiagnosisResult> {
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::<DiagnosisPayload>(&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};
Expand Down Expand Up @@ -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\""));
}
}
138 changes: 138 additions & 0 deletions src/components/DiagnosisCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<number, boolean>>({});
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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t("doctor.diagnosisReport", { defaultValue: "Diagnosis Report" })} ({items.length})
</span>
<div className="relative">
<Button
variant="ghost"
size="xs"
onClick={() => setExportOpen(!exportOpen)}
>
<DownloadIcon className="size-3.5 mr-1" />
{copied
? t("doctor.copied", { defaultValue: "Copied!" })
: t("doctor.export", { defaultValue: "Export" })}
</Button>
{exportOpen && (
<div className="absolute right-0 top-full mt-1 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[120px]">
<button
className="w-full text-left text-xs px-2 py-1.5 rounded hover:bg-accent"
onClick={() => handleExport("markdown")}
>
<ClipboardCopyIcon className="size-3 inline mr-1.5" />
Markdown
</button>
<button
className="w-full text-left text-xs px-2 py-1.5 rounded hover:bg-accent"
onClick={() => handleExport("json")}
>
<ClipboardCopyIcon className="size-3 inline mr-1.5" />
JSON
</button>
</div>
)}
</div>
</div>

{items.map((item, idx) => {
const cfg = severityConfig[item.severity] ?? severityConfig.info;
return (
<Card
key={idx}
className={`border-l-[3px] ${cfg.border} bg-[oklch(0.96_0_0)] dark:bg-muted/50 py-3`}
>
<CardContent className="px-4 py-0 space-y-2">
<div className="flex items-start gap-2">
<Checkbox
checked={!!checked[idx]}
onCheckedChange={() => toggleCheck(idx)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={cfg.variant} className="text-[10px] px-1.5 py-0">
{cfg.label}
</Badge>
<span className="text-sm font-medium">{item.problem}</span>
</div>
{item.fix_options.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{item.fix_options.map((opt, oi) => (
<li key={oi} className="text-xs text-muted-foreground flex gap-1.5">
<span className="text-muted-foreground/60">•</span>
{opt}
</li>
))}
</ul>
)}
</div>
{item.action && (
<Button variant="outline" size="xs" className="shrink-0">
{t("doctor.autoFix", { defaultValue: "Auto-fix" })}
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
Loading