2026-03-05 15:36:02 -08:00
|
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { resolveWebChatDir } from "@/lib/workspace";
|
2026-03-05 12:28:08 -08:00
|
|
|
import { trackServer } from "@/lib/telemetry";
|
|
|
|
|
|
2026-03-05 15:36:02 -08:00
|
|
|
export const runtime = "nodejs";
|
|
|
|
|
|
|
|
|
|
type ChatLine = {
|
|
|
|
|
id: string;
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
content: string;
|
|
|
|
|
parts?: Array<Record<string, unknown>>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 21:38:55 -08:00
|
|
|
/**
|
|
|
|
|
* Convert a persisted chat line into a PostHog-compatible message,
|
|
|
|
|
* preserving tool calls, tool results, and reasoning blocks.
|
|
|
|
|
*/
|
|
|
|
|
function toPostHogMessage(line: ChatLine): Record<string, unknown> {
|
|
|
|
|
const msg: Record<string, unknown> = { role: line.role };
|
|
|
|
|
|
|
|
|
|
if (!line.parts || line.parts.length === 0) {
|
|
|
|
|
msg.content = line.content;
|
|
|
|
|
return msg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contentBlocks: unknown[] = [];
|
|
|
|
|
const toolCalls: unknown[] = [];
|
|
|
|
|
|
|
|
|
|
for (const part of line.parts) {
|
|
|
|
|
switch (part.type) {
|
|
|
|
|
case "text":
|
|
|
|
|
if (typeof part.text === "string" && part.text) {
|
|
|
|
|
contentBlocks.push({ type: "text", text: part.text });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "tool-invocation":
|
|
|
|
|
toolCalls.push({
|
|
|
|
|
type: "function",
|
|
|
|
|
id: part.toolCallId,
|
|
|
|
|
function: {
|
|
|
|
|
name: part.toolName,
|
|
|
|
|
arguments:
|
|
|
|
|
typeof part.args === "string"
|
|
|
|
|
? part.args
|
|
|
|
|
: JSON.stringify(part.args ?? {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (part.result && typeof part.result === "object") {
|
|
|
|
|
contentBlocks.push({
|
|
|
|
|
type: "tool_result",
|
|
|
|
|
tool_call_id: part.toolCallId,
|
|
|
|
|
content: (part.result as Record<string, unknown>).text ?? "",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "reasoning":
|
|
|
|
|
if (typeof part.text === "string" && part.text) {
|
|
|
|
|
contentBlocks.push({ type: "thinking", text: part.text });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentBlocks.length === 1 && toolCalls.length === 0 && (contentBlocks[0] as any)?.type === "text") {
|
|
|
|
|
msg.content = (contentBlocks[0] as any).text;
|
|
|
|
|
} else if (contentBlocks.length > 0) {
|
|
|
|
|
msg.content = contentBlocks;
|
|
|
|
|
} else {
|
|
|
|
|
msg.content = line.content || null;
|
2026-03-05 15:36:02 -08:00
|
|
|
}
|
2026-03-05 21:38:55 -08:00
|
|
|
|
|
|
|
|
if (toolCalls.length > 0) {
|
|
|
|
|
msg.tool_calls = toolCalls;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return msg;
|
2026-03-05 15:36:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/feedback
|
|
|
|
|
*
|
|
|
|
|
* When a user submits thumbs up/down feedback, emit an un-redacted
|
|
|
|
|
* $ai_trace event to PostHog so the full conversation is visible
|
|
|
|
|
* in LLM Analytics regardless of the extension's privacy mode.
|
|
|
|
|
*/
|
2026-03-05 12:28:08 -08:00
|
|
|
export async function POST(req: Request) {
|
|
|
|
|
try {
|
2026-03-05 16:09:12 -08:00
|
|
|
const { sessionId, messageId, distinctId } = (await req.json()) as {
|
2026-03-05 12:28:08 -08:00
|
|
|
sessionId?: string;
|
2026-03-05 15:36:02 -08:00
|
|
|
messageId?: string;
|
2026-03-05 16:09:12 -08:00
|
|
|
distinctId?: string;
|
2026-03-05 12:28:08 -08:00
|
|
|
};
|
2026-03-05 15:36:02 -08:00
|
|
|
if (!sessionId) {
|
|
|
|
|
return Response.json({ ok: true });
|
|
|
|
|
}
|
2026-03-05 12:28:08 -08:00
|
|
|
|
2026-03-05 15:36:02 -08:00
|
|
|
const filePath = join(resolveWebChatDir(), `${sessionId}.jsonl`);
|
|
|
|
|
if (!existsSync(filePath)) {
|
2026-03-05 12:28:08 -08:00
|
|
|
return Response.json({ ok: true });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:36:02 -08:00
|
|
|
const lines: ChatLine[] = readFileSync(filePath, "utf-8")
|
|
|
|
|
.trim()
|
|
|
|
|
.split("\n")
|
|
|
|
|
.filter((l) => l.trim())
|
|
|
|
|
.map((l) => {
|
|
|
|
|
try { return JSON.parse(l) as ChatLine; } catch { return null; }
|
|
|
|
|
})
|
|
|
|
|
.filter((m): m is ChatLine => m !== null);
|
|
|
|
|
|
|
|
|
|
let cutoff = lines.length;
|
|
|
|
|
if (messageId) {
|
|
|
|
|
const idx = lines.findIndex((m) => m.id === messageId);
|
|
|
|
|
if (idx >= 0) cutoff = idx + 1;
|
|
|
|
|
}
|
|
|
|
|
const conversation = lines.slice(0, cutoff);
|
|
|
|
|
|
2026-03-05 21:38:55 -08:00
|
|
|
const allMessages = conversation.map(toPostHogMessage);
|
2026-03-05 19:09:19 -08:00
|
|
|
|
2026-03-05 21:38:55 -08:00
|
|
|
const lastAssistantIdx = conversation.findLastIndex((m) => m.role === "assistant");
|
2026-03-05 15:36:02 -08:00
|
|
|
|
2026-03-05 16:09:12 -08:00
|
|
|
trackServer(
|
|
|
|
|
"$ai_trace",
|
|
|
|
|
{
|
|
|
|
|
$ai_trace_id: sessionId,
|
|
|
|
|
$ai_session_id: sessionId,
|
|
|
|
|
$ai_span_name: "chat_session",
|
2026-03-05 21:38:55 -08:00
|
|
|
$ai_input_state: allMessages.length > 0 ? allMessages : undefined,
|
|
|
|
|
$ai_output_state: lastAssistantIdx >= 0
|
|
|
|
|
? [allMessages[lastAssistantIdx]]
|
2026-03-05 19:09:19 -08:00
|
|
|
: undefined,
|
2026-03-05 16:09:12 -08:00
|
|
|
},
|
|
|
|
|
distinctId,
|
|
|
|
|
);
|
2026-03-05 12:28:08 -08:00
|
|
|
} catch {
|
|
|
|
|
// Fail silently -- feedback capture should never block the user.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Response.json({ ok: true });
|
|
|
|
|
}
|