mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automated AI QA review pipeline and human-verified knowledge base consulted first * Internal MCP review tool: call log viewer, conversation replay, add/edit/publish Q&A, knowledge editor, and analytics * Docs search now preserves follow-up conversation context * **Documentation** * Added “Ask DeepWiki” badge to README * **Chores** * Added local SpacetimeDB background service and internal-tool app scaffolding <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mantrakp04 <mantrakp@gmail.com> Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
689 lines
25 KiB
TypeScript
689 lines
25 KiB
TypeScript
import { clsx } from "clsx";
|
|
import { format, formatDistanceToNow } from "date-fns";
|
|
import { useState, useEffect } from "react";
|
|
import Markdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import type { McpCallLogRow } from "../types";
|
|
import { toDate } from "../utils";
|
|
import { ConversationReplay } from "./ConversationReplay";
|
|
import { markdownComponents } from "./markdown-components";
|
|
|
|
// ─── Shared ────────────────────────────────────────────
|
|
|
|
function CopyButton({ text }: { text: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
return (
|
|
<button
|
|
className="text-xs text-blue-500 hover:text-blue-700 ml-2"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
}, (err) => {
|
|
console.error("Clipboard write failed:", err);
|
|
});
|
|
}}
|
|
>
|
|
{copied ? "copied" : "copy"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ────────────────────────────────────
|
|
|
|
export function CallLogDetail({ row, allRows, onClose, onSaveCorrection, onMarkReviewed }: {
|
|
row: McpCallLogRow;
|
|
allRows: McpCallLogRow[];
|
|
onClose: () => void;
|
|
onSaveCorrection?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => Promise<void> | void;
|
|
onMarkReviewed?: (correlationId: string) => Promise<void> | void;
|
|
}) {
|
|
const [showReplay, setShowReplay] = useState(false);
|
|
const isReviewed = row.humanReviewedAt != null;
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
{showReplay && (
|
|
<ConversationReplay row={row} allRows={allRows} onClose={() => setShowReplay(false)} />
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold text-gray-900">Call Detail</h2>
|
|
{isReviewed && (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800"
|
|
title={`Reviewed ${row.humanReviewedAt ? format(toDate(row.humanReviewedAt), "PPpp") : ""}${row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}`}
|
|
>
|
|
✓ Reviewed{row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{!isReviewed && onMarkReviewed && (
|
|
<button
|
|
onClick={() => void onMarkReviewed(row.correlationId)}
|
|
className="px-2.5 py-1 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 border border-green-200"
|
|
>
|
|
Mark as reviewed
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowReplay(true)}
|
|
className="px-2.5 py-1 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100"
|
|
>
|
|
Replay
|
|
</button>
|
|
<button className="text-gray-400 hover:text-gray-600 text-sm" onClick={onClose}>
|
|
close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 1: MCP Call */}
|
|
<MpcCallCard row={row} />
|
|
|
|
{/* Card 2: AI QA Review */}
|
|
<QaReviewCard row={row} />
|
|
|
|
{/* Card 3: Human Correction */}
|
|
<HumanCorrectionCard row={row} onSave={onSaveCorrection} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Card 1: MCP Call ──────────────────────────────────
|
|
|
|
function MpcCallCard({ row }: { row: McpCallLogRow }) {
|
|
const [toolsExpanded, setToolsExpanded] = useState(true);
|
|
|
|
let toolCalls: Array<{ type: string; toolName: string; toolCallId: string; args: unknown; result: unknown }> = [];
|
|
try {
|
|
toolCalls = JSON.parse(row.innerToolCallsJson) as typeof toolCalls;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
{/* Metadata bar */}
|
|
<div className="px-4 py-2.5 border-b border-gray-100 flex items-center gap-2 flex-wrap text-xs text-gray-500">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded font-medium bg-purple-100 text-purple-800">
|
|
{row.toolName}
|
|
</span>
|
|
<span title={format(toDate(row.createdAt), "PPpp")}>
|
|
{formatDistanceToNow(toDate(row.createdAt), { addSuffix: true })}
|
|
</span>
|
|
<span>{Number(row.durationMs).toLocaleString()}ms</span>
|
|
<span>{row.stepCount} step{row.stepCount !== 1 ? "s" : ""}</span>
|
|
<span className="text-gray-400">{row.modelId}</span>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-3">
|
|
{row.errorMessage && (
|
|
<div className="p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
|
{row.errorMessage}
|
|
</div>
|
|
)}
|
|
|
|
{/* User Prompt */}
|
|
{row.userPrompt && (
|
|
<div>
|
|
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider mb-0.5">User Prompt</h4>
|
|
<p className="text-sm text-gray-700">{row.userPrompt}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reason */}
|
|
<p className="text-xs text-gray-500 italic">{row.reason}</p>
|
|
|
|
{/* Question */}
|
|
<div>
|
|
<div className="flex items-center mb-1">
|
|
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Question</h4>
|
|
<CopyButton text={row.question} />
|
|
</div>
|
|
<p className="text-sm text-gray-900 whitespace-pre-wrap">{row.question}</p>
|
|
</div>
|
|
|
|
{/* Response */}
|
|
<div>
|
|
<div className="flex items-center mb-1">
|
|
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">AI Response</h4>
|
|
<CopyButton text={row.response} />
|
|
</div>
|
|
<div className="bg-gray-50 p-3 rounded max-h-64 overflow-auto text-sm">
|
|
{row.response ? (
|
|
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{row.response}
|
|
</Markdown>
|
|
) : <span className="text-gray-400">(empty)</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tool Calls */}
|
|
{toolCalls.length > 0 && (
|
|
<div>
|
|
<button
|
|
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
|
onClick={() => setToolsExpanded(prev => !prev)}
|
|
>
|
|
<span className="text-[10px]">{toolsExpanded ? "▾" : "▸"}</span>
|
|
Tool Calls ({toolCalls.length})
|
|
</button>
|
|
{toolsExpanded && (
|
|
<div className="mt-2 space-y-2">
|
|
{toolCalls.map((call, i) => (
|
|
<InnerToolCall key={call.toolCallId || String(i)} call={call} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InnerToolCall({ call }: { call: { toolName: string; toolCallId: string; args: unknown; result: unknown } }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<div className="border border-gray-200 rounded">
|
|
<button
|
|
className="w-full text-left px-3 py-2 text-sm flex items-center justify-between hover:bg-gray-50"
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
>
|
|
<span className="font-mono text-xs">
|
|
<span className="text-purple-600">{call.toolName}</span>
|
|
<span className="text-gray-400 ml-2">#{call.toolCallId.slice(0, 8)}</span>
|
|
</span>
|
|
<span className="text-gray-400 text-xs">{expanded ? "collapse" : "expand"}</span>
|
|
</button>
|
|
{expanded && (
|
|
<div className="px-3 pb-2 space-y-2">
|
|
<div>
|
|
<p className="text-xs text-gray-500 mb-1">Args:</p>
|
|
<pre className="text-xs bg-gray-50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(call.args, null, 2)}</pre>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 mb-1">Result:</p>
|
|
<pre className="text-xs bg-gray-50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(call.result, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Card 2: AI QA Review ──────────────────────────────
|
|
|
|
type QaFlag = { type: string; severity: string; explanation: string };
|
|
|
|
function QaReviewCard({ row }: { row: McpCallLogRow }) {
|
|
if (row.qaErrorMessage) {
|
|
return (
|
|
<div className="bg-red-50/50 border border-red-200 rounded-lg p-4">
|
|
<h3 className="text-xs font-semibold text-red-800 uppercase tracking-wider mb-2">AI QA Review</h3>
|
|
<p className="text-sm text-red-700">Error: {row.qaErrorMessage}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (row.qaOverallScore == null) {
|
|
return (
|
|
<div className="bg-indigo-50/30 border border-indigo-200 rounded-lg p-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-xs font-semibold text-indigo-800 uppercase tracking-wider">AI QA Review</h3>
|
|
<span className="inline-block w-3 h-3 border-2 border-indigo-300 border-t-transparent rounded-full animate-spin" />
|
|
<span className="text-xs text-indigo-400">Reviewing...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let flags: QaFlag[] = [];
|
|
try {
|
|
flags = JSON.parse(row.qaFlagsJson ?? "[]") as QaFlag[];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const scoreColor = row.qaOverallScore >= 80
|
|
? "text-green-700 bg-green-100"
|
|
: row.qaOverallScore >= 50
|
|
? "text-yellow-700 bg-yellow-100"
|
|
: "text-red-700 bg-red-100";
|
|
|
|
const severityColors: Record<string, string> = {
|
|
critical: "border-red-400 bg-red-50",
|
|
high: "border-orange-400 bg-orange-50",
|
|
medium: "border-yellow-400 bg-yellow-50",
|
|
low: "border-gray-300 bg-gray-50",
|
|
};
|
|
|
|
return (
|
|
<div className="bg-indigo-50/30 border border-indigo-200 rounded-lg overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-4 py-2.5 border-b border-indigo-100 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-xs font-semibold text-indigo-800 uppercase tracking-wider">AI QA Review</h3>
|
|
{row.qaNeedsHumanReview && !row.humanReviewedAt && (
|
|
<span className="text-[10px] font-medium text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">Needs Review</span>
|
|
)}
|
|
</div>
|
|
<span className={`text-lg font-bold px-2 py-0.5 rounded ${scoreColor}`}>
|
|
{row.qaOverallScore}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-3">
|
|
{/* Badges */}
|
|
<div className="flex gap-2">
|
|
<span className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
row.qaAnswerCorrect ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
|
)}>
|
|
{row.qaAnswerCorrect ? "correct" : "incorrect"}
|
|
</span>
|
|
<span className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
row.qaAnswerRelevant ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
|
)}>
|
|
{row.qaAnswerRelevant ? "relevant" : "off-topic"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Flags */}
|
|
{flags.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
{flags.map((flag, i) => (
|
|
<div key={i} className={`border-l-4 pl-3 py-1.5 rounded-r text-sm ${severityColors[flag.severity] ?? severityColors.low}`}>
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="font-mono text-xs text-gray-600">{flag.type}</span>
|
|
<span className="text-[10px] uppercase text-gray-400">{flag.severity}</span>
|
|
</div>
|
|
<p className="text-gray-700 text-xs">{flag.explanation}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Suggestions */}
|
|
{row.qaImprovementSuggestions && (
|
|
<div>
|
|
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider mb-1">Suggestions</h4>
|
|
<p className="text-xs text-gray-600 whitespace-pre-wrap">{row.qaImprovementSuggestions}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conversation timeline */}
|
|
{row.qaConversationJson && (
|
|
<QaConversationTimeline json={row.qaConversationJson} />
|
|
)}
|
|
|
|
{/* Model */}
|
|
{row.qaReviewModelId && (
|
|
<p className="text-[10px] text-gray-400">by {row.qaReviewModelId}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Card 3: Human Correction ──────────────────────────
|
|
|
|
async function fetchDeepWikiAnswer(questionText: string): Promise<string> {
|
|
const res = await fetch("https://mcp.deepwiki.com/mcp", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "ask_question",
|
|
arguments: {
|
|
repoName: "stack-auth/stack-auth",
|
|
question: questionText,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`DeepWiki error: ${res.status}`);
|
|
}
|
|
|
|
const rawText = await res.text();
|
|
const dataLine = rawText.split("\n").find(line => line.startsWith("data: "));
|
|
if (!dataLine) {
|
|
throw new Error("No data in DeepWiki response");
|
|
}
|
|
|
|
const data = JSON.parse(dataLine.slice(6)) as {
|
|
result?: { content?: Array<{ text?: string }> };
|
|
};
|
|
|
|
return data.result?.content
|
|
?.filter((c): c is { text: string } => typeof c.text === "string")
|
|
.map(c => c.text)
|
|
.join("\n\n") ?? "(no response)";
|
|
}
|
|
|
|
function HumanCorrectionCard({ row, onSave }: {
|
|
row: McpCallLogRow;
|
|
onSave?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => Promise<void> | void;
|
|
}) {
|
|
const [question, setQuestion] = useState(row.humanCorrectedQuestion ?? "");
|
|
const [answer, setAnswer] = useState(row.humanCorrectedAnswer ?? "");
|
|
const [lastAction, setLastAction] = useState<"published" | "saved" | "deepwiki-error" | "error" | null>(null);
|
|
const [deepWikiLoading, setDeepWikiLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setQuestion(row.humanCorrectedQuestion ?? "");
|
|
setAnswer(row.humanCorrectedAnswer ?? "");
|
|
}, [row.humanCorrectedQuestion, row.humanCorrectedAnswer, row.correlationId]);
|
|
|
|
const handleSave = async (publish: boolean) => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await onSave?.(row.correlationId, question, answer, publish);
|
|
setLastAction(publish ? "published" : "saved");
|
|
setTimeout(() => setLastAction(null), 3000);
|
|
} catch {
|
|
setLastAction("error");
|
|
setTimeout(() => setLastAction(null), 3000);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const hasUnsavedChanges =
|
|
question !== (row.humanCorrectedQuestion ?? "") ||
|
|
answer !== (row.humanCorrectedAnswer ?? "");
|
|
|
|
const cardStyle = row.publishedToQa
|
|
? "bg-green-50/50 border-green-200"
|
|
: row.humanCorrectedAnswer
|
|
? "bg-amber-50/50 border-amber-200"
|
|
: "bg-white border-gray-200";
|
|
|
|
return (
|
|
<div className={`border rounded-lg overflow-hidden ${cardStyle}`}>
|
|
{/* Header */}
|
|
<div className="px-4 py-2.5 border-b border-inherit flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wider">Human Correction</h3>
|
|
{row.publishedToQa ? (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800">
|
|
✓ Published
|
|
</span>
|
|
) : row.humanCorrectedAnswer ? (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-800">
|
|
Draft
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[10px] text-gray-400">
|
|
{row.publishedAt && (
|
|
<span>{format(toDate(row.publishedAt), "MMM d, yyyy")}</span>
|
|
)}
|
|
{row.humanReviewedBy && (
|
|
<span>by {row.humanReviewedBy}</span>
|
|
)}
|
|
{row.publishedToQa && (
|
|
<button
|
|
onClick={() => void handleSave(false)}
|
|
className="text-red-500 hover:text-red-700"
|
|
>
|
|
Unpublish
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-3">
|
|
{/* Feedback toast */}
|
|
{lastAction && (
|
|
<div className={clsx(
|
|
"px-3 py-1.5 rounded text-xs font-medium",
|
|
lastAction === "published" ? "bg-green-100 text-green-700" :
|
|
lastAction === "deepwiki-error" || lastAction === "error" ? "bg-red-100 text-red-700" :
|
|
"bg-blue-100 text-blue-700"
|
|
)}>
|
|
{lastAction === "published" ? "Published to /questions" :
|
|
lastAction === "deepwiki-error" ? "Failed to fetch from DeepWiki" :
|
|
lastAction === "error" ? "Failed to save" :
|
|
"Draft saved"}
|
|
</div>
|
|
)}
|
|
|
|
{/* Question */}
|
|
<div>
|
|
<label className="text-[10px] uppercase text-gray-400 font-medium mb-1 block tracking-wider">Question</label>
|
|
<input
|
|
type="text"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
|
value={question}
|
|
onChange={(e) => setQuestion(e.target.value)}
|
|
placeholder="The question..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Answer */}
|
|
<div>
|
|
<label className="text-[10px] uppercase text-gray-400 font-medium mb-1 block tracking-wider">Answer</label>
|
|
<textarea
|
|
className="w-full h-40 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y bg-white"
|
|
value={answer}
|
|
onChange={(e) => setAnswer(e.target.value)}
|
|
placeholder="Write the corrected answer..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setQuestion(row.question);
|
|
setAnswer(row.response);
|
|
}}
|
|
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded bg-white"
|
|
>
|
|
Pre-fill from AI
|
|
</button>
|
|
<button
|
|
disabled={deepWikiLoading}
|
|
onClick={() => {
|
|
const q = question || row.question;
|
|
setDeepWikiLoading(true);
|
|
fetchDeepWikiAnswer(q)
|
|
.then(a => {
|
|
setAnswer(a);
|
|
if (!question) {
|
|
setQuestion(q);
|
|
}
|
|
})
|
|
.catch(() => setLastAction("deepwiki-error"))
|
|
.finally(() => setDeepWikiLoading(false));
|
|
}}
|
|
className={clsx(
|
|
"px-2 py-1 text-xs border rounded bg-white",
|
|
deepWikiLoading ? "text-gray-400 border-gray-200" : "text-indigo-500 hover:text-indigo-700 border-indigo-300"
|
|
)}
|
|
>
|
|
{deepWikiLoading ? "Fetching..." : "Pre-fill from DeepWiki"}
|
|
</button>
|
|
{hasUnsavedChanges && (
|
|
<span className="text-[10px] text-amber-500">unsaved changes</span>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => void handleSave(false)}
|
|
className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
|
|
>
|
|
Save Draft
|
|
</button>
|
|
<button
|
|
onClick={() => void handleSave(true)}
|
|
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
>
|
|
{row.publishedToQa ? "Update & Publish" : "Save & Publish"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── QA Conversation Timeline ──────────────────────────
|
|
|
|
type QaStep = {
|
|
step: number;
|
|
text?: string;
|
|
toolCalls?: Array<{ toolName: string; args: unknown }>;
|
|
toolResults?: Array<{ toolName: string; toolCallId: string; result: unknown }>;
|
|
};
|
|
|
|
function formatByteSize(value: unknown): string {
|
|
const str = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
const bytes = new Blob([str]).size;
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
function QaConversationTimeline({ json }: { json: string }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
let steps: QaStep[];
|
|
try {
|
|
steps = JSON.parse(json) as QaStep[];
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (steps.length === 0) return null;
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
className="text-xs text-indigo-500 hover:text-indigo-700 flex items-center gap-1"
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
>
|
|
<span className="text-[10px]">{expanded ? "▾" : "▸"}</span>
|
|
Reviewer Conversation ({steps.length} step{steps.length !== 1 ? "s" : ""})
|
|
</button>
|
|
{expanded && (
|
|
<div className="mt-3 relative border-l-2 border-indigo-200 ml-1 space-y-4">
|
|
{steps.map((step) => {
|
|
const hasTools = step.toolCalls && step.toolCalls.length > 0;
|
|
return hasTools
|
|
? <QaToolStep key={step.step} step={step} />
|
|
: step.text ? <QaConclusionStep key={step.step} step={step} /> : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QaToolStep({ step }: { step: QaStep }) {
|
|
const pairs = (step.toolCalls ?? []).map((tc, i) => ({
|
|
toolName: tc.toolName,
|
|
args: tc.args,
|
|
result: step.toolResults?.[i]?.result ?? null,
|
|
}));
|
|
|
|
return (
|
|
<div className="relative pl-5">
|
|
<div className="absolute -left-[5px] top-2 w-2 h-2 rounded-full bg-indigo-400" />
|
|
<p className="text-[10px] uppercase text-gray-400 font-medium mb-1.5">
|
|
Step {step.step} — Verification
|
|
</p>
|
|
<div className="space-y-2">
|
|
{pairs.map((pair, i) => (
|
|
<QaToolCard key={i} pair={pair} />
|
|
))}
|
|
</div>
|
|
{step.text && (
|
|
<div className="mt-2 bg-white/50 rounded p-2">
|
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">{step.text}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QaToolCard({ pair }: { pair: { toolName: string; args: unknown; result: unknown } }) {
|
|
const [resultExpanded, setResultExpanded] = useState(false);
|
|
const resultStr = pair.result == null
|
|
? null
|
|
: typeof pair.result === "string" ? pair.result : JSON.stringify(pair.result, null, 2);
|
|
|
|
return (
|
|
<div className="border border-indigo-200 rounded-lg overflow-hidden bg-white/50">
|
|
<div className="px-3 py-1.5 bg-indigo-50">
|
|
<span className="font-mono text-xs text-indigo-700">{pair.toolName}</span>
|
|
</div>
|
|
<div className="px-3 py-2 border-t border-indigo-100">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-[10px] uppercase text-gray-400 font-medium">Args</p>
|
|
<CopyButton text={JSON.stringify(pair.args, null, 2)} />
|
|
</div>
|
|
<pre className="text-xs text-gray-600 overflow-auto max-h-24">{JSON.stringify(pair.args, null, 2)}</pre>
|
|
</div>
|
|
{resultStr != null && (
|
|
<div className="px-3 py-2 border-t border-indigo-100">
|
|
<div className="w-full flex items-center justify-between text-[10px] uppercase text-gray-400 font-medium">
|
|
<button
|
|
className="hover:text-gray-600"
|
|
onClick={() => setResultExpanded(prev => !prev)}
|
|
>
|
|
Result ({formatByteSize(pair.result)}) — {resultExpanded ? "collapse" : "expand"}
|
|
</button>
|
|
{resultExpanded && <CopyButton text={resultStr} />}
|
|
</div>
|
|
{resultExpanded && (
|
|
<pre className="mt-1 text-xs text-gray-600 overflow-auto max-h-64 whitespace-pre-wrap">{resultStr}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QaConclusionStep({ step }: { step: QaStep }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const text = step.text ?? "";
|
|
const truncated = text.length > 150 ? text.slice(0, 150) + "..." : text;
|
|
|
|
return (
|
|
<div className="relative pl-5">
|
|
<div className="absolute -left-[5px] top-2 w-2 h-2 rounded-full bg-gray-400" />
|
|
<p className="text-[10px] uppercase text-gray-400 font-medium mb-1.5">
|
|
Step {step.step} — Conclusion
|
|
</p>
|
|
<div className="bg-white/50 rounded-lg p-3">
|
|
<p className="text-xs text-gray-600 whitespace-pre-wrap">
|
|
{expanded ? text : truncated}
|
|
</p>
|
|
{text.length > 150 && (
|
|
<button
|
|
className="text-xs text-indigo-500 hover:text-indigo-700 mt-1"
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
>
|
|
{expanded ? "show less" : "show full"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|