Some checks failed
Test / rust-fmt-check (pull_request) Failing after 1m31s
Test / frontend-tests (pull_request) Successful in 1m34s
Test / frontend-typecheck (pull_request) Successful in 1m36s
Test / rust-clippy (pull_request) Successful in 3m5s
PR Review Automation / review (pull_request) Successful in 4m31s
Test / rust-tests (pull_request) Successful in 4m27s
File attachments were embedded into AI messages without any PII scanning, allowing credentials, tokens, and other sensitive data to be forwarded to AI providers in plaintext. Typed chat messages had the same gap: a user could type a password or API key directly and it would be sent unscanned. Changes: - chat_message (Rust): defence-in-depth scan of all attachment body content (between --- Attached: markers); hard rejects if PII found - detect_pii (Rust): fix return type from pii::PiiDetectionResult (spans/original_text) to db::models::PiiDetectionResult (detections/total_pii_found) to match the TypeScript contract; the LogUpload PII review workflow was receiving undefined for detections - scan_text_for_pii (Rust): new command — scans arbitrary text for PII without creating DB records; used for typed message warnings - Triage/index.tsx: PendingFile now carries logFileId; handleSend gates each text attachment through detectPiiCmd (hard block on PII found); typed message text scanned via scanTextForPiiCmd with a one-time warning — second send of same message proceeds as acknowledgment
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { CheckCircle, ChevronRight } from "lucide-react";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
|
import { ChatWindow } from "@/components/ChatWindow";
|
|
import { TriageProgress } from "@/components/TriageProgress";
|
|
import { useSessionStore } from "@/stores/sessionStore";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import {
|
|
chatMessageCmd,
|
|
detectPiiCmd,
|
|
scanTextForPiiCmd,
|
|
getIssueCmd,
|
|
getIssueMessagesCmd,
|
|
uploadLogFileCmd,
|
|
updateIssueCmd,
|
|
addFiveWhyCmd,
|
|
} from "@/lib/tauriCommands";
|
|
import { getDomainPrompt, detectDomain } from "@/lib/domainPrompts";
|
|
import type { TriageMessage } from "@/lib/tauriCommands";
|
|
|
|
const CLOSE_PATTERNS = [
|
|
"close this issue",
|
|
"please close",
|
|
"mark as resolved",
|
|
"mark resolved",
|
|
"issue is fixed",
|
|
"issue is resolved",
|
|
"resolve this",
|
|
"this is resolved",
|
|
];
|
|
|
|
function isCloseIntent(message: string): boolean {
|
|
const lower = message.toLowerCase();
|
|
return CLOSE_PATTERNS.some((p) => lower.includes(p));
|
|
}
|
|
|
|
type PendingFile = { name: string; content: string | null; logFileId: string };
|
|
|
|
export default function Triage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
|
// Track the last user message so we can save it as a resolution step when why level advances
|
|
const lastUserMsgRef = useRef<string>("");
|
|
const initialized = useRef(false);
|
|
// PII warning state: if the user sends a message that triggers a PII warning,
|
|
// store it here. Sending the same message a second time bypasses the warning
|
|
// (explicit acknowledgment). Cleared after each successful send.
|
|
const piiWarnedMessageRef = useRef<string>("");
|
|
|
|
const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, setWhyLevel, setActiveDomain } =
|
|
useSessionStore();
|
|
const { getActiveProvider } = useSettingsStore();
|
|
|
|
useEffect(() => {
|
|
if (!id || initialized.current) return;
|
|
initialized.current = true;
|
|
|
|
Promise.all([getIssueCmd(id), getIssueMessagesCmd(id)])
|
|
.then(([detail, pastMessages]) => {
|
|
startSession(detail.issue);
|
|
setActiveDomain(detail.issue.category);
|
|
|
|
if (pastMessages.length > 0) {
|
|
// Restore conversation history from DB
|
|
pastMessages.forEach((m, i) => {
|
|
addMessage({
|
|
id: `hist-${i}`,
|
|
issue_id: id,
|
|
role: m.role,
|
|
content: m.content,
|
|
why_level: 0,
|
|
created_at: Date.now() - (pastMessages.length - i) * 1000,
|
|
});
|
|
});
|
|
} else if (detail.resolution_steps.length === 0) {
|
|
// Fresh issue — show welcome prompt
|
|
addMessage({
|
|
id: "welcome",
|
|
issue_id: id,
|
|
role: "assistant",
|
|
content: `I'll guide you through a 5-Whys root cause analysis for: **"${detail.issue.title}"**\n\nDomain: **${detail.issue.category || "General"}**\n\nDescribe the symptoms you're observing — error messages, affected services, and when the issue started.`,
|
|
why_level: 0,
|
|
created_at: Date.now(),
|
|
});
|
|
}
|
|
})
|
|
.catch((e) => setError(String(e)));
|
|
}, [id]);
|
|
|
|
const handleAttach = async () => {
|
|
if (!id) return;
|
|
try {
|
|
const selected = await open({
|
|
multiple: true,
|
|
filters: [
|
|
{ name: "Log & Text Files", extensions: ["log", "txt", "json", "xml", "yaml", "yml", "csv", "out", "err"] },
|
|
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
});
|
|
if (!selected) return;
|
|
const paths = Array.isArray(selected) ? selected : [selected];
|
|
for (const filePath of paths) {
|
|
const logFile = await uploadLogFileCmd(id, filePath);
|
|
let content: string | null = null;
|
|
try {
|
|
const raw = await readTextFile(filePath);
|
|
content = raw.slice(0, 8000); // cap at 8 KB to keep context manageable
|
|
} catch {
|
|
// Binary file (image) — include filename only as context
|
|
}
|
|
setPendingFiles((prev) => [...prev, { name: logFile.file_name, content, logFileId: logFile.id }]);
|
|
}
|
|
} catch (e) {
|
|
setError(`Attachment failed: ${String(e)}`);
|
|
}
|
|
};
|
|
|
|
const handleSend = async (message: string) => {
|
|
if (!id) return;
|
|
|
|
// Close intent: works regardless of whether issue is fully loaded in session.
|
|
// Save the user's reason as a resolution step so the Resolution page is never empty.
|
|
if (isCloseIntent(message) && pendingFiles.length === 0) {
|
|
try {
|
|
await addFiveWhyCmd(id, 1, "Resolution", message, "Self-resolved by user");
|
|
await updateIssueCmd(id, { status: "resolved" });
|
|
navigate("/");
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!currentIssue) return;
|
|
|
|
const provider = getActiveProvider();
|
|
if (!provider) {
|
|
setError("No AI provider configured. Go to Settings > AI Providers.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// PII gate: scan each text attachment before it is sent to an AI provider.
|
|
// Images (content === null) have no text to scan.
|
|
for (const f of pendingFiles) {
|
|
if (f.content === null) continue;
|
|
try {
|
|
const result = await detectPiiCmd(f.logFileId);
|
|
if (result.total_pii_found > 0) {
|
|
const types = [...new Set(result.detections.map((d) => d.pii_type))].join(", ");
|
|
setError(
|
|
`PII detected in "${f.name}" (${result.total_pii_found} item${result.total_pii_found !== 1 ? "s" : ""}: ${types}). ` +
|
|
`Open Log Analysis to redact before attaching.`
|
|
);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
setError(`PII scan failed for "${f.name}": ${String(e)}`);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// PII warning for typed message text. Unlike attachments (hard block), typed
|
|
// messages only warn once — sending the same text a second time is treated as
|
|
// explicit acknowledgment and proceeds.
|
|
if (message.trim() && message !== piiWarnedMessageRef.current) {
|
|
try {
|
|
const textResult = await scanTextForPiiCmd(message);
|
|
if (textResult.total_pii_found > 0) {
|
|
const types = [...new Set(textResult.detections.map((d) => d.pii_type))].join(", ");
|
|
piiWarnedMessageRef.current = message;
|
|
setError(
|
|
`Your message may contain sensitive data (${types} detected). ` +
|
|
`Edit your message to remove it, or send again to proceed.`
|
|
);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Non-fatal: if the scan fails, do not block the send
|
|
}
|
|
}
|
|
|
|
// Build AI context: user text + file contents; show only text + filenames in chat UI
|
|
const fileContext = pendingFiles
|
|
.map((f) =>
|
|
f.content
|
|
? `\n\n--- Attached: ${f.name} ---\n${f.content}`
|
|
: `\n\n[Image attached: ${f.name} — describe what you see if relevant]`
|
|
)
|
|
.join("");
|
|
const aiMessage = pendingFiles.length > 0 ? `${message}${fileContext}` : message;
|
|
const displayContent =
|
|
pendingFiles.length > 0
|
|
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
|
: message;
|
|
|
|
const userMsg: TriageMessage = {
|
|
id: `user-${Date.now()}`,
|
|
issue_id: id,
|
|
role: "user",
|
|
content: displayContent,
|
|
why_level: currentWhyLevel,
|
|
created_at: Date.now(),
|
|
};
|
|
lastUserMsgRef.current = message;
|
|
addMessage(userMsg);
|
|
setPendingFiles([]);
|
|
|
|
try {
|
|
// Detect domain from conversation messages
|
|
const messageContents = messages.map((m) => m.content);
|
|
const detectedDomain = detectDomain(messageContents);
|
|
|
|
// Update active domain if it has changed
|
|
if (detectedDomain !== activeDomain && detectedDomain !== "general") {
|
|
setActiveDomain(detectedDomain);
|
|
}
|
|
|
|
// Use the active domain for the system prompt
|
|
const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined;
|
|
const response = await chatMessageCmd(id, aiMessage, provider, systemPrompt);
|
|
const assistantMsg: TriageMessage = {
|
|
id: `asst-${Date.now()}`,
|
|
issue_id: id,
|
|
role: "assistant",
|
|
content: response.content,
|
|
why_level: currentWhyLevel,
|
|
created_at: Date.now(),
|
|
};
|
|
addMessage(assistantMsg);
|
|
|
|
const lower = response.content.toLowerCase();
|
|
let nextLevel = currentWhyLevel;
|
|
if (lower.includes("why 2") || (currentWhyLevel === 1 && lower.includes("why is that"))) nextLevel = 2;
|
|
else if (lower.includes("why 3")) nextLevel = 3;
|
|
else if (lower.includes("why 4")) nextLevel = 4;
|
|
else if (lower.includes("why 5")) nextLevel = 5;
|
|
if (lower.includes("root cause") && (lower.includes("identified") || lower.includes("the root cause is"))) nextLevel = 6;
|
|
|
|
// Auto-save the completed why step as a resolution step
|
|
if (nextLevel > currentWhyLevel && currentWhyLevel >= 1 && currentWhyLevel <= 5) {
|
|
addFiveWhyCmd(
|
|
id,
|
|
currentWhyLevel,
|
|
`Why ${currentWhyLevel}: ${lastUserMsgRef.current}`,
|
|
response.content.slice(0, 500),
|
|
""
|
|
).catch(() => {}); // non-blocking, best-effort
|
|
}
|
|
if (nextLevel !== currentWhyLevel) setWhyLevel(nextLevel);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsLoading(false);
|
|
piiWarnedMessageRef.current = "";
|
|
}
|
|
};
|
|
|
|
const canProceed = currentWhyLevel >= 5 || messages.some(
|
|
(m) => m.role === "assistant" && m.content.toLowerCase().includes("root cause")
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full" data-testid="triage-page">
|
|
<div className="border-b px-6 py-3 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="font-semibold">{currentIssue?.title ?? "Loading..."}</h1>
|
|
<p className="text-sm text-muted-foreground">5-Whys Root Cause Analysis</p>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate(`/issue/${id}/resolution`)}
|
|
disabled={!canProceed}
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50 hover:opacity-90"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
Resolution Steps
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-3 border-b">
|
|
<TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<ChatWindow
|
|
messages={messages}
|
|
onSend={handleSend}
|
|
isLoading={isLoading}
|
|
placeholder="Describe the problem or answer the AI's question..."
|
|
pendingFiles={pendingFiles}
|
|
onAttach={handleAttach}
|
|
onRemoveFile={(i) => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|