feat(chat): add problem context tools

This commit is contained in:
cfngc4594 2026-05-29 12:46:46 +08:00
parent 4d97ed602d
commit 65edacea8c
5 changed files with 282 additions and 43 deletions

View File

@ -1,11 +1,17 @@
import { streamText } from "ai";
import { streamText, tool } from "ai";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { model } from "@/lib/ai";
import { auth } from "@/lib/auth";
import { Locale, ProblemContentType } from "@/generated/client";
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const { messages, problemId, locale, submissionId } = await req.json();
const userId = (await auth())?.user?.id;
const activeLocale = locale === Locale.en ? Locale.en : Locale.zh;
const system = `You are a patient programming tutor for Judge4c, a platform for learning C and C++.
@ -13,6 +19,8 @@ Help users understand problems, debug code, and improve solutions with clear, re
Prefer concise answers for simple questions. For debugging or refactoring, be specific and practical: point to the likely cause, suggest a minimal fix, and mention any important trade-offs. Avoid insults, sarcasm, profanity, or comments that shame the user.
Use tools when the user's question depends on current problem context. Use getCurrentCode for the latest editor code, getEditorDiagnostics for current editor errors and warnings, getProblemDescription for the statement, getProblemSolution for the official solution, getTestcases for configured test cases, getSubmissionHistory for the user's submissions, and getSubmissionDetail for one submission's code and judge result.
When providing code, keep it directly relevant to the user's question and avoid unnecessary rewrites. If the user is working on an algorithmic problem, do not reveal the full solution immediately unless they ask for it; start with hints or targeted guidance.
Reply in the user's language.`;
@ -21,7 +29,166 @@ Reply in the user's language.`;
model,
system: system,
messages: messages,
tools: {
getCurrentCode: {
description:
"Get the latest code and language from the user's current editor.",
parameters: z.object({}),
},
getEditorDiagnostics: {
description:
"Get the latest errors and warnings from the user's current editor diagnostics.",
parameters: z.object({}),
},
getProblemDescription: tool({
description: "Get the current problem statement.",
parameters: z.object({}),
execute: async () =>
getProblemContent(problemId, activeLocale, ProblemContentType.DESCRIPTION),
}),
getProblemSolution: tool({
description: "Get the current problem solution or editorial.",
parameters: z.object({}),
execute: async () =>
getProblemContent(problemId, activeLocale, ProblemContentType.SOLUTION),
}),
getTestcases: tool({
description: "Get the current problem's configured test cases.",
parameters: z.object({}),
execute: async () => {
if (!problemId) return { error: "Missing problem id." };
const testcases = await prisma.testcase.findMany({
where: { problemId },
orderBy: { createdAt: "asc" },
select: {
id: true,
expectedOutput: true,
inputs: {
orderBy: { index: "asc" },
select: {
name: true,
value: true,
},
},
},
});
return { testcases };
},
}),
getSubmissionHistory: tool({
description: "Get the current user's submission history for this problem.",
parameters: z.object({
limit: z.number().int().min(1).max(20).optional(),
}),
execute: async ({ limit = 10 }) => {
if (!userId) return { error: "User is not authenticated." };
if (!problemId) return { error: "Missing problem id." };
const submissions = await prisma.submission.findMany({
where: { userId, problemId },
take: limit,
orderBy: { createdAt: "desc" },
select: {
id: true,
language: true,
status: true,
message: true,
timeUsage: true,
memoryUsage: true,
createdAt: true,
},
});
return { submissions };
},
}),
getSubmissionDetail: tool({
description:
"Get code, compile output, and judge runs for one of the current user's submissions.",
parameters: z.object({
submissionId: z
.string()
.optional()
.describe("Defaults to the submission currently open in the URL."),
}),
execute: async ({ submissionId: requestedSubmissionId }) => {
if (!userId) return { error: "User is not authenticated." };
const targetSubmissionId = requestedSubmissionId ?? submissionId;
if (!targetSubmissionId) return { error: "Missing submission id." };
const submission = await prisma.submission.findFirst({
where: { id: targetSubmissionId, userId },
select: {
id: true,
language: true,
content: true,
status: true,
message: true,
timeUsage: true,
memoryUsage: true,
createdAt: true,
judge: {
select: {
status: true,
compileOutput: true,
judgeRuns: {
orderBy: { createdAt: "desc" },
select: {
status: true,
timeUsage: true,
memoryUsage: true,
stdin: true,
stdout: true,
stderr: true,
testcase: {
select: {
expectedOutput: true,
inputs: {
orderBy: { index: "asc" },
select: {
name: true,
value: true,
},
},
},
},
},
},
},
},
},
});
return submission ?? { error: "Submission not found." };
},
}),
},
});
return result.toDataStreamResponse();
}
async function getProblemContent(
problemId: string | undefined,
locale: Locale,
type: ProblemContentType
) {
if (!problemId) return { error: "Missing problem id." };
const localizations = await prisma.problemLocalization.findMany({
where: { problemId, type },
select: {
locale: true,
content: true,
},
});
const content =
localizations.find((item) => item.locale === locale)?.content ??
localizations[0]?.content;
return content ? { locale, content } : { error: "Content not found." };
}

View File

@ -23,10 +23,14 @@ const getLocalizedDescription = (
interface BotContentProps {
problemId: string;
submissionId?: string;
}
export const BotContent = async ({ problemId }: BotContentProps) => {
const locale = await getLocale();
export const BotContent = async ({
problemId,
submissionId,
}: BotContentProps) => {
const locale = (await getLocale()) as Locale;
const descriptions = await prisma.problemLocalization.findMany({
where: {
@ -35,9 +39,16 @@ export const BotContent = async ({ problemId }: BotContentProps) => {
},
});
const description = getLocalizedDescription(descriptions, locale as Locale);
const description = getLocalizedDescription(descriptions, locale);
return <BotForm description={description} />;
return (
<BotForm
description={description}
locale={locale}
problemId={problemId}
submissionId={submissionId}
/>
);
};
export const BotContentSkeleton = () => {

View File

@ -3,6 +3,7 @@
import { toast } from "sonner";
import { useCallback } from "react";
import { useChat } from "@ai-sdk/react";
import { MarkerSeverity } from "monaco-editor";
import {
ChatBubble,
ChatBubbleMessage,
@ -17,17 +18,25 @@ import { TooltipButton } from "@/components/tooltip-button";
import { useProblemEditorStore } from "@/stores/problem-editor";
import { MdxComponents } from "@/components/content/mdx-components";
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
import type { Locale } from "@/generated/client";
interface BotFormProps {
description: string;
locale: Locale;
problemId: string;
submissionId?: string;
}
export const BotForm = ({ description }: BotFormProps) => {
export const BotForm = ({
description,
locale,
problemId,
submissionId,
}: BotFormProps) => {
const t = useTranslations("Bot");
const { problem, language, value } = useProblemEditorStore();
const { problem, language, value, markers } = useProblemEditorStore();
const { messages, input, handleInputChange, setMessages, handleSubmit } =
useChat({
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
initialMessages: [
{
id: problem?.problemId || "",
@ -35,6 +44,39 @@ export const BotForm = ({ description }: BotFormProps) => {
content: `Problem description:\n${description}`,
},
],
body: {
locale,
problemId,
submissionId,
},
maxSteps: 3,
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === "getCurrentCode") {
return {
language,
code: value,
};
}
if (toolCall.toolName === "getEditorDiagnostics") {
const diagnostics = markers.map((marker) => ({
severity: getMarkerSeverityLabel(marker.severity),
message: marker.message,
source: marker.source,
code: marker.code,
startLineNumber: marker.startLineNumber,
startColumn: marker.startColumn,
endLineNumber: marker.endLineNumber,
endColumn: marker.endColumn,
}));
return {
errors: diagnostics.filter((item) => item.severity === "error"),
warnings: diagnostics.filter((item) => item.severity === "warning"),
diagnostics,
};
}
},
});
const handleFormSubmit = useCallback(
@ -46,16 +88,9 @@ export const BotForm = ({ description }: BotFormProps) => {
return;
}
const currentCodeMessage = {
id: problem?.problemId || "",
role: "system" as const,
content: `Current code:\n\`\`\`${language}\n${value}\n\`\`\``,
};
setMessages((prev) => [...prev, currentCodeMessage]);
handleSubmit();
},
[handleSubmit, input, language, problem?.problemId, setMessages, value]
[handleSubmit, input]
);
return (
@ -72,20 +107,30 @@ export const BotForm = ({ description }: BotFormProps) => {
(message) =>
message.role === "user" || message.role === "assistant"
)
.map((message) => (
.map((message) => {
const isEmptyAssistantMessage =
message.role === "assistant" &&
!message.content.trim() &&
status !== "ready";
return (
<ChatBubble
key={message.id}
layout="ai"
className="border-b pb-4"
>
<ChatBubbleMessage layout="ai">
<ChatBubbleMessage
layout="ai"
isLoading={isEmptyAssistantMessage}
>
<MdxPreview
source={message.content}
components={{ ...MdxComponents, pre: PreDetail }}
/>
</ChatBubbleMessage>
</ChatBubble>
))}
);
})}
</ChatMessageList>
</ScrollArea>
</div>
@ -131,3 +176,18 @@ export const BotForm = ({ description }: BotFormProps) => {
</div>
);
};
const getMarkerSeverityLabel = (severity: MarkerSeverity) => {
switch (severity) {
case MarkerSeverity.Error:
return "error";
case MarkerSeverity.Warning:
return "warning";
case MarkerSeverity.Info:
return "info";
case MarkerSeverity.Hint:
return "hint";
default:
return "unknown";
}
};

View File

@ -7,13 +7,14 @@ import { PanelLayout } from "@/features/problems/layouts/panel-layout";
interface BotPanelProps {
problemId: string;
submissionId?: string;
}
export const BotPanel = ({ problemId }: BotPanelProps) => {
export const BotPanel = ({ problemId, submissionId }: BotPanelProps) => {
return (
<PanelLayout isScroll={false}>
<Suspense fallback={<BotContentSkeleton />}>
<BotContent problemId={problemId} />
<BotContent problemId={problemId} submissionId={submissionId} />
</Suspense>
</PanelLayout>
);

View File

@ -22,7 +22,7 @@ export const ProblemView = ({ problemId, submissionId }: ProblemViewProps) => {
detail: <DetailPanel submissionId={submissionId} />,
code: <CodePanel problemId={problemId} />,
testcase: <TestcasePanel problemId={problemId} />,
bot: <BotPanel problemId={problemId} />,
bot: <BotPanel problemId={problemId} submissionId={submissionId} />,
};
return (