mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
comment changes
This commit is contained in:
parent
8c7bc548b8
commit
7a54be90b0
@ -16,7 +16,6 @@ export const POST = createSmartRouteHandler({
|
||||
question: yupString().defined(),
|
||||
answer: yupString().defined(),
|
||||
publish: yupBoolean().defined(),
|
||||
reviewedBy: yupString().defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
@ -40,13 +39,13 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(503, "SpacetimeDB unavailable");
|
||||
}
|
||||
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me");
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
|
||||
await conn.reducers.addManualQa({
|
||||
token,
|
||||
question: body.question,
|
||||
answer: body.answer,
|
||||
publish: body.publish,
|
||||
reviewedBy: body.reviewedBy,
|
||||
reviewedBy: fullReq.auth.user.display_name ?? fullReq.auth.user.primary_email ?? fullReq.auth.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(503, "SpacetimeDB unavailable");
|
||||
}
|
||||
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me");
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
|
||||
await conn.reducers.deleteQaEntry({
|
||||
token,
|
||||
correlationId: body.correlationId,
|
||||
|
||||
@ -14,7 +14,6 @@ export const POST = createSmartRouteHandler({
|
||||
}).defined(),
|
||||
body: yupObject({
|
||||
correlationId: yupString().defined(),
|
||||
reviewedBy: yupString().defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
@ -38,11 +37,11 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(503, "SpacetimeDB unavailable");
|
||||
}
|
||||
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me");
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
|
||||
await conn.reducers.markHumanReviewed({
|
||||
token,
|
||||
correlationId: body.correlationId,
|
||||
reviewedBy: body.reviewedBy,
|
||||
reviewedBy: fullReq.auth.user.display_name ?? fullReq.auth.user.primary_email ?? fullReq.auth.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -17,7 +17,6 @@ export const POST = createSmartRouteHandler({
|
||||
correctedQuestion: yupString().defined(),
|
||||
correctedAnswer: yupString().defined(),
|
||||
publish: yupBoolean().defined(),
|
||||
reviewedBy: yupString().defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
@ -41,14 +40,14 @@ export const POST = createSmartRouteHandler({
|
||||
throw new StatusError(503, "SpacetimeDB unavailable");
|
||||
}
|
||||
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me");
|
||||
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
|
||||
await conn.reducers.updateHumanCorrection({
|
||||
token,
|
||||
correlationId: body.correlationId,
|
||||
correctedQuestion: body.correctedQuestion,
|
||||
correctedAnswer: body.correctedAnswer,
|
||||
publish: body.publish,
|
||||
reviewedBy: body.reviewedBy,
|
||||
reviewedBy: fullReq.auth.user.display_name ?? fullReq.auth.user.primary_email ?? fullReq.auth.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -113,8 +113,8 @@ You are Stack Auth's AI assistant. You help users with Stack Auth - a complete a
|
||||
Think step by step about what to say. Being wrong is 100x worse than saying you don't know.
|
||||
|
||||
## PRIORITY ORDER:
|
||||
1. **FIRST**, check the Human-Verified Knowledge Base (appended at the end of this prompt, if any). If the user's question matches or is similar to a verified Q&A, use that answer exactly — do not search docs or use any other source.
|
||||
2. **THEN**, use \`search_docs\` with relevant keywords to find related documentation
|
||||
1. **FIRST**, check the Human-Verified Knowledge Base (appended at the end of this prompt, if any). If the user's question is an exact or near-exact match to a verified Q&A, you may use that answer verbatim without searching docs.
|
||||
2. **OTHERWISE**, use \`search_docs\` with relevant keywords to find related documentation — this is mandatory when there is no exact verified-QA match.
|
||||
3. **THEN**, use \`get_docs_by_id\` to retrieve the full content of the most relevant pages
|
||||
4. Base your answer on the actual documentation content retrieved
|
||||
5. When referring to API endpoints, **always cite the actual endpoint** (e.g., "GET /users/me") not the documentation URL
|
||||
|
||||
@ -111,7 +111,7 @@ export async function reviewMcpCall(entry: {
|
||||
});
|
||||
|
||||
const conversation = result.steps.map((step, i) => {
|
||||
const toolCalls = step.toolCalls.map(tc => ({ toolName: tc.toolName, args: tc.input }));
|
||||
const toolCalls = step.toolCalls.map(tc => ({ toolName: tc.toolName, toolCallId: tc.toolCallId, args: tc.input }));
|
||||
const toolResults = step.toolResults.map(tr => ({
|
||||
toolName: tr.toolName,
|
||||
toolCallId: tr.toolCallId,
|
||||
@ -129,7 +129,17 @@ export async function reviewMcpCall(entry: {
|
||||
if (!jsonMatch) {
|
||||
throw new Error("No JSON found in QA review response");
|
||||
}
|
||||
const parsed = JSON.parse(jsonMatch[0]) as {
|
||||
const raw = JSON.parse(jsonMatch[0]);
|
||||
if (
|
||||
typeof raw.needsHumanReview !== "boolean" ||
|
||||
typeof raw.answerCorrect !== "boolean" ||
|
||||
typeof raw.answerRelevant !== "boolean" ||
|
||||
!Array.isArray(raw.flags) ||
|
||||
typeof raw.overallScore !== "number"
|
||||
) {
|
||||
throw new Error(`Invalid QA review response shape: ${JSON.stringify(raw).slice(0, 200)}`);
|
||||
}
|
||||
const parsed = raw as {
|
||||
needsHumanReview: boolean,
|
||||
answerCorrect: boolean,
|
||||
answerRelevant: boolean,
|
||||
@ -137,6 +147,7 @@ export async function reviewMcpCall(entry: {
|
||||
improvementSuggestions: string,
|
||||
overallScore: number,
|
||||
};
|
||||
parsed.overallScore = Math.max(0, Math.min(100, Math.round(parsed.overallScore)));
|
||||
|
||||
update = {
|
||||
qaNeedsHumanReview: parsed.needsHumanReview,
|
||||
|
||||
@ -23,29 +23,34 @@ function getDocsToolsBaseUrl(): string {
|
||||
async function postDocsToolAction(action: Record<string, unknown>): Promise<string> {
|
||||
const base = getDocsToolsBaseUrl();
|
||||
|
||||
const res = await fetch(`${base}/api/internal/docs-tools`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(action),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${base}/api/internal/docs-tools`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(action),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
captureError("docs-tools-http-error", new Error(`Stack Auth docs tools error (${res.status}): ${errBody}`));
|
||||
return `Stack Auth docs tools error (${res.status}): ${errBody}`;
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
captureError("docs-tools-http-error", new Error(`Stack Auth docs tools error (${res.status}): ${errBody}`));
|
||||
return `Stack Auth docs tools error (${res.status}): ${errBody}`;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as DocsToolHttpResult;
|
||||
const text = data.content
|
||||
?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n") ?? "";
|
||||
|
||||
if (data.isError === true) {
|
||||
return text || "Unknown docs tool error";
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch (err) {
|
||||
captureError("docs-tools-transport-error", err instanceof Error ? err : new Error(String(err)));
|
||||
return `Stack Auth docs tools error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as DocsToolHttpResult;
|
||||
const text = data.content
|
||||
?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n") ?? "";
|
||||
|
||||
if (data.isError === true) {
|
||||
return text || "Unknown docs tool error";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,15 +22,19 @@ if (target === "prod" && !process.env.STACK_MCP_LOG_TOKEN) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Inject token
|
||||
const inject = spawnSync("node", ["scripts/spacetime-token.mjs", "inject"], { stdio: "inherit" });
|
||||
if (inject.status !== 0) {
|
||||
process.exit(inject.status ?? 1);
|
||||
}
|
||||
|
||||
let exitCode = 1;
|
||||
try {
|
||||
const publish = spawnSync("spacetime", args, { stdio: "inherit" });
|
||||
process.exitCode = publish.status ?? 1;
|
||||
const inject = spawnSync("node", ["scripts/spacetime-token.mjs", "inject"], { stdio: "inherit" });
|
||||
if (inject.status !== 0) {
|
||||
exitCode = inject.status ?? 1;
|
||||
} else {
|
||||
const publish = spawnSync("spacetime", args, { stdio: "inherit" });
|
||||
exitCode = publish.status ?? 1;
|
||||
}
|
||||
} finally {
|
||||
spawnSync("node", ["scripts/spacetime-token.mjs", "restore"], { stdio: "inherit" });
|
||||
const restore = spawnSync("node", ["scripts/spacetime-token.mjs", "restore"], { stdio: "inherit" });
|
||||
if (restore.status !== 0 && exitCode === 0) {
|
||||
exitCode = restore.status ?? 1;
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
@ -13,12 +13,19 @@ const action = process.argv[2];
|
||||
|
||||
if (action === "inject") {
|
||||
const token = process.env.STACK_MCP_LOG_TOKEN || "change-me";
|
||||
if (existsSync(BACKUP)) {
|
||||
console.error("Refusing to inject: backup already exists. Run restore first.");
|
||||
process.exit(1);
|
||||
}
|
||||
const content = readFileSync(TARGET, "utf8");
|
||||
writeFileSync(BACKUP, content, "utf8");
|
||||
writeFileSync(TARGET, content.replaceAll(PLACEHOLDER, token), "utf8");
|
||||
const escapedToken = JSON.stringify(token).slice(1, -1);
|
||||
writeFileSync(TARGET, content.replaceAll(PLACEHOLDER, escapedToken), "utf8");
|
||||
} else if (action === "restore") {
|
||||
if (existsSync(BACKUP)) {
|
||||
unlinkSync(TARGET);
|
||||
if (existsSync(TARGET)) {
|
||||
unlinkSync(TARGET);
|
||||
}
|
||||
renameSync(BACKUP, TARGET);
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -174,7 +174,7 @@ export const update_human_correction = spacetimedb.reducer(
|
||||
humanReviewedAt: row.humanReviewedAt ?? ctx.timestamp,
|
||||
humanReviewedBy: row.humanReviewedBy ?? args.reviewedBy,
|
||||
publishedToQa: args.publish,
|
||||
publishedAt: args.publish ? (row.publishedAt ?? ctx.timestamp) : row.publishedAt,
|
||||
publishedAt: args.publish ? (row.publishedAt ?? ctx.timestamp) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -63,7 +63,6 @@ export default function App() {
|
||||
: null;
|
||||
|
||||
const currentUser = user;
|
||||
const reviewedBy = currentUser.displayName ?? currentUser.primaryEmail ?? "unknown";
|
||||
|
||||
async function getApi() {
|
||||
const { accessToken, refreshToken } = await currentUser.getAuthJson();
|
||||
@ -132,10 +131,9 @@ export default function App() {
|
||||
{showAddQa && (
|
||||
<AddManualQa
|
||||
onClose={() => setShowAddQa(false)}
|
||||
onSave={(question, answer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.addManual({ question, answer, publish, reviewedBy }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
onSave={async (question, answer, publish) => {
|
||||
const api = await getApi();
|
||||
await api.addManual({ question, answer, publish });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -158,12 +156,12 @@ export default function App() {
|
||||
onClose={() => setSelectedRow(null)}
|
||||
onSaveCorrection={(correlationId, correctedQuestion, correctedAnswer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish, reviewedBy }))
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
onMarkReviewed={(correlationId) => {
|
||||
getApi()
|
||||
.then(api => api.markReviewed({ correlationId, reviewedBy }))
|
||||
.then(api => api.markReviewed({ correlationId }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
/>
|
||||
@ -178,7 +176,7 @@ export default function App() {
|
||||
rows={rows}
|
||||
onSave={(correlationId, question, answer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish, reviewedBy }))
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
onDelete={(correlationId) => {
|
||||
|
||||
@ -3,26 +3,36 @@ import { clsx } from "clsx";
|
||||
|
||||
export function AddManualQa({ onClose, onSave }: {
|
||||
onClose: () => void;
|
||||
onSave: (question: string, answer: string, publish: boolean) => void;
|
||||
onSave: (question: string, answer: string, publish: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSave = question.trim().length > 0 && answer.trim().length > 0;
|
||||
const canSave = question.trim().length > 0 && answer.trim().length > 0 && !isSaving;
|
||||
|
||||
const handleSave = (publish: boolean) => {
|
||||
const handleSave = async (publish: boolean) => {
|
||||
if (!canSave) return;
|
||||
onSave(question.trim(), answer.trim(), publish);
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
setQuestion("");
|
||||
setAnswer("");
|
||||
if (publish) {
|
||||
onClose();
|
||||
}
|
||||
}, 1500);
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(question.trim(), answer.trim(), publish);
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
setQuestion("");
|
||||
setAnswer("");
|
||||
if (publish) {
|
||||
onClose();
|
||||
}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -43,6 +53,11 @@ export function AddManualQa({ onClose, onSave }: {
|
||||
Saved successfully
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 rounded text-xs font-medium bg-red-50 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase text-gray-400 font-medium mb-1 block tracking-wider">Question</label>
|
||||
|
||||
@ -71,7 +71,7 @@ export function Analytics({ rows }: { rows: McpCallLogRow[] }) {
|
||||
// Duration stats
|
||||
const durations = rows.map(r => Number(r.durationMs)).filter(d => d > 0).sort((a, b) => a - b);
|
||||
const avgDuration = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
||||
const p95Duration = durations.length > 0 ? durations[Math.floor(durations.length * 0.95)] : 0;
|
||||
const p95Duration = durations.length > 0 ? durations[Math.min(Math.floor(durations.length * 0.95), durations.length - 1)] : 0;
|
||||
const maxDuration = durations.length > 0 ? durations[durations.length - 1] : 0;
|
||||
|
||||
// Tool usage
|
||||
|
||||
@ -16,9 +16,12 @@ function CopyButton({ text }: { text: string }) {
|
||||
<button
|
||||
className="text-xs text-blue-500 hover:text-blue-700 ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, (err) => {
|
||||
console.error("Clipboard write failed:", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? "copied" : "copy"}
|
||||
@ -32,8 +35,8 @@ export function CallLogDetail({ row, allRows, onClose, onSaveCorrection, onMarkR
|
||||
row: McpCallLogRow;
|
||||
allRows: McpCallLogRow[];
|
||||
onClose: () => void;
|
||||
onSaveCorrection?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => void;
|
||||
onMarkReviewed?: (correlationId: string) => 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;
|
||||
@ -373,22 +376,32 @@ async function fetchDeepWikiAnswer(questionText: string): Promise<string> {
|
||||
|
||||
function HumanCorrectionCard({ row, onSave }: {
|
||||
row: McpCallLogRow;
|
||||
onSave?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => void;
|
||||
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" | null>(null);
|
||||
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 = (publish: boolean) => {
|
||||
onSave?.(row.correlationId, question, answer, publish);
|
||||
setLastAction(publish ? "published" : "saved");
|
||||
setTimeout(() => setLastAction(null), 3000);
|
||||
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 =
|
||||
@ -441,12 +454,13 @@ function HumanCorrectionCard({ row, onSave }: {
|
||||
<div className={clsx(
|
||||
"px-3 py-1.5 rounded text-xs font-medium",
|
||||
lastAction === "published" ? "bg-green-100 text-green-700" :
|
||||
lastAction === "deepwiki-error" ? "bg-red-100 text-red-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" :
|
||||
"Draft saved"}
|
||||
lastAction === "error" ? "Failed to save" :
|
||||
"Draft saved"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -627,16 +641,15 @@ function QaToolCard({ pair }: { pair: { toolName: string; args: unknown; result:
|
||||
</div>
|
||||
{resultStr != null && (
|
||||
<div className="px-3 py-2 border-t border-indigo-100">
|
||||
<button
|
||||
className="w-full flex items-center justify-between text-[10px] uppercase text-gray-400 font-medium hover:text-gray-600"
|
||||
onClick={() => setResultExpanded(prev => !prev)}
|
||||
>
|
||||
<span>Result ({formatByteSize(pair.result)})</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{resultExpanded && <CopyButton text={resultStr} />}
|
||||
<span>{resultExpanded ? "collapse" : "expand"}</span>
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -27,9 +27,12 @@ function CopyButton({ text }: { text: string }) {
|
||||
className="shrink-0 rounded p-0.5 transition-colors text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, (err) => {
|
||||
console.error("Clipboard write failed:", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">{copied ? "copied" : "copy"}</span>
|
||||
|
||||
@ -8,9 +8,12 @@ function CopyBtn({ text, size = "xs" }: { text: string; size?: "xs" | "sm" }) {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, (err) => {
|
||||
console.error("Clipboard write failed:", err);
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
"shrink-0 rounded transition-colors",
|
||||
@ -66,8 +69,6 @@ const CodeBlock = memo(function CodeBlock({ children, className }: { children?:
|
||||
});
|
||||
|
||||
const SmartLink = memo(function SmartLink({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
const displayText = String(children || href || "");
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@ -75,7 +76,7 @@ const SmartLink = memo(function SmartLink({ href, children }: { href?: string; c
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{displayText}
|
||||
{children ?? href ?? ""}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
@ -6,8 +6,13 @@ const IS_DEV = process.env.NODE_ENV === "development";
|
||||
const PLACEHOLDER = "REPLACE_ME";
|
||||
const rawHost = process.env.NEXT_PUBLIC_SPACETIMEDB_HOST;
|
||||
const rawDbName = process.env.NEXT_PUBLIC_SPACETIMEDB_DB_NAME;
|
||||
const HOST = (!rawHost || rawHost === PLACEHOLDER) ? (IS_DEV ? "ws://localhost:8139" : "") : rawHost;
|
||||
const DB_NAME = (!rawDbName || rawDbName === PLACEHOLDER) ? (IS_DEV ? "stack-auth-llm" : "") : rawDbName;
|
||||
function resolveEnv(raw: string | undefined, devDefault: string, name: string): string {
|
||||
if (raw && raw !== PLACEHOLDER) return raw;
|
||||
if (IS_DEV) return devDefault;
|
||||
throw new Error(`${name} is not configured. Set it in .env.local or hosting platform env.`);
|
||||
}
|
||||
const HOST = resolveEnv(rawHost, "ws://localhost:8139", "NEXT_PUBLIC_SPACETIMEDB_HOST");
|
||||
const DB_NAME = resolveEnv(rawDbName, "stack-auth-llm", "NEXT_PUBLIC_SPACETIMEDB_DB_NAME");
|
||||
const TOKEN_KEY = `spacetimedb_${HOST}/${DB_NAME}/auth_token`;
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
@ -39,76 +44,70 @@ export function useMcpCallLogs() {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (!cancelled) {
|
||||
connect().catch(() => {});
|
||||
connect();
|
||||
}
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
const conn = DbConnection.builder()
|
||||
.withUri(HOST)
|
||||
.withDatabaseName(DB_NAME)
|
||||
.withToken(localStorage.getItem(TOKEN_KEY) || undefined)
|
||||
.onConnect((connInstance: DbConnection, _identity: unknown, token: string) => {
|
||||
function connect() {
|
||||
const conn = DbConnection.builder()
|
||||
.withUri(HOST)
|
||||
.withDatabaseName(DB_NAME)
|
||||
.withToken(localStorage.getItem(TOKEN_KEY) || undefined)
|
||||
.onConnect((connInstance: DbConnection, _identity: unknown, token: string) => {
|
||||
if (cancelled) return;
|
||||
console.log("[SpacetimeDB] Connected successfully");
|
||||
retryCount = 0;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
connRef.current = connInstance;
|
||||
|
||||
connInstance.subscriptionBuilder()
|
||||
.onApplied((ctx: SubscriptionEventContext) => {
|
||||
if (cancelled) return;
|
||||
const initialRows: McpCallLogRow[] = [];
|
||||
for (const row of ctx.db.mcpCallLog.iter()) {
|
||||
initialRows.push(row);
|
||||
}
|
||||
initialRows.sort((a, b) => Number(b.id - a.id));
|
||||
console.log("[SpacetimeDB] Loaded", initialRows.length, "rows");
|
||||
setRows(initialRows);
|
||||
setConnectionState("connected");
|
||||
})
|
||||
.subscribe(`SELECT * FROM mcp_call_log`);
|
||||
|
||||
connInstance.db.mcpCallLog.onInsert((_ctx: EventContext, row: McpCallLogRow) => {
|
||||
if (cancelled) return;
|
||||
console.log("[SpacetimeDB] Connected successfully");
|
||||
retryCount = 0;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
connRef.current = connInstance;
|
||||
|
||||
connInstance.subscriptionBuilder()
|
||||
.onApplied((ctx: SubscriptionEventContext) => {
|
||||
if (cancelled) return;
|
||||
const initialRows: McpCallLogRow[] = [];
|
||||
for (const row of ctx.db.mcpCallLog.iter()) {
|
||||
initialRows.push(row);
|
||||
}
|
||||
initialRows.sort((a, b) => Number(b.id - a.id));
|
||||
console.log("[SpacetimeDB] Loaded", initialRows.length, "rows");
|
||||
setRows(initialRows);
|
||||
setConnectionState("connected");
|
||||
})
|
||||
.subscribe(`SELECT * FROM mcp_call_log`);
|
||||
|
||||
connInstance.db.mcpCallLog.onInsert((_ctx: EventContext, row: McpCallLogRow) => {
|
||||
if (cancelled) return;
|
||||
setRows(prev => {
|
||||
const existing = prev.findIndex(r => r.id === row.id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = row;
|
||||
return updated;
|
||||
}
|
||||
return [row, ...prev];
|
||||
});
|
||||
setRows(prev => {
|
||||
const existing = prev.findIndex(r => r.id === row.id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = row;
|
||||
return updated;
|
||||
}
|
||||
return [row, ...prev];
|
||||
});
|
||||
});
|
||||
|
||||
connInstance.db.mcpCallLog.onDelete((_ctx: EventContext, row: McpCallLogRow) => {
|
||||
if (cancelled) return;
|
||||
setRows(prev => prev.filter(r => r.id !== row.id));
|
||||
});
|
||||
})
|
||||
.onConnectError((_ctx: unknown, err: unknown) => {
|
||||
console.error("[SpacetimeDB] Connection error:", err);
|
||||
// Clear stale token if present
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (storedToken) {
|
||||
console.log("[SpacetimeDB] Clearing stale token");
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
retry();
|
||||
})
|
||||
.build();
|
||||
connInstance.db.mcpCallLog.onDelete((_ctx: EventContext, row: McpCallLogRow) => {
|
||||
if (cancelled) return;
|
||||
setRows(prev => prev.filter(r => r.id !== row.id));
|
||||
});
|
||||
})
|
||||
.onConnectError((_ctx: unknown, err: unknown) => {
|
||||
console.error("[SpacetimeDB] Connection error:", err);
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (storedToken) {
|
||||
console.log("[SpacetimeDB] Clearing stale token");
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
retry();
|
||||
})
|
||||
.build();
|
||||
|
||||
connRef.current = conn;
|
||||
} catch (err) {
|
||||
console.error("[SpacetimeDB] Failed to build connection:", err);
|
||||
retry();
|
||||
}
|
||||
connRef.current = conn;
|
||||
}
|
||||
|
||||
connect().catch(() => {});
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@ -36,7 +36,7 @@ async function post(path: string, body: unknown, authHeaders: Record<string, str
|
||||
|
||||
export function makeMcpReviewApi(authHeaders: Record<string, string>) {
|
||||
return {
|
||||
markReviewed: (body: { correlationId: string; reviewedBy: string }) =>
|
||||
markReviewed: (body: { correlationId: string }) =>
|
||||
post("mark-reviewed", body, authHeaders),
|
||||
|
||||
updateCorrection: (body: {
|
||||
@ -44,14 +44,12 @@ export function makeMcpReviewApi(authHeaders: Record<string, string>) {
|
||||
correctedQuestion: string;
|
||||
correctedAnswer: string;
|
||||
publish: boolean;
|
||||
reviewedBy: string;
|
||||
}) => post("update-correction", body, authHeaders),
|
||||
|
||||
addManual: (body: {
|
||||
question: string;
|
||||
answer: string;
|
||||
publish: boolean;
|
||||
reviewedBy: string;
|
||||
}) => post("add-manual", body, authHeaders),
|
||||
|
||||
delete: (body: { correlationId: string }) =>
|
||||
|
||||
@ -5,7 +5,10 @@
|
||||
export function toDate(ts: unknown): Date {
|
||||
if (ts instanceof Date) return ts;
|
||||
if (typeof ts === "object" && ts !== null && "__timestamp_micros_since_unix_epoch__" in ts) {
|
||||
const micros = (ts as { __timestamp_micros_since_unix_epoch__: bigint }).__timestamp_micros_since_unix_epoch__;
|
||||
const micros = (ts as Record<string, unknown>).__timestamp_micros_since_unix_epoch__;
|
||||
if (typeof micros !== "bigint") {
|
||||
throw new TypeError(`Expected __timestamp_micros_since_unix_epoch__ to be bigint, got ${typeof micros}`);
|
||||
}
|
||||
return new Date(Number(micros / 1000n));
|
||||
}
|
||||
if (typeof ts === "bigint") {
|
||||
@ -14,5 +17,5 @@ export function toDate(ts: unknown): Date {
|
||||
if (typeof ts === "number") {
|
||||
return new Date(ts);
|
||||
}
|
||||
return new Date(0);
|
||||
throw new TypeError(`Cannot convert ${typeof ts} to Date`);
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ async function extractOpenApiDetails(
|
||||
text: errorText,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user