mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-10 13:13:22 +08:00
feat(i18n): add course language config with 11 languages and UI linkage
- Add lib/i18n/course-languages.ts with curated language list
(zh-CN, zh-TW, en-US, ja, ko, fr, de, es, pt, ru, ar) including
native labels and English prompt names
- Course language defaults to UI locale on first visit; once user
explicitly picks a language, that choice persists across sessions
- Replace toggle button with dropdown selector showing native labels
- Widen language types from 'zh-CN'|'en-US' to string throughout
- Fix hardcoded language ternaries in LLM prompt injection:
- prompt-builder.ts: use getCourseLanguagePromptName()
- classroom-generation.ts: remove normalizeLanguage() that forced
all non-English to zh-CN
- PBL system prompt, agent templates, generate-pbl: append language
instruction for non-zh/en languages
- quiz-grade API: add language suffix for grading feedback
Closes #327
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a7cf676c47
commit
f7bd6bdb21
@@ -10,6 +10,7 @@ import { callLLM } from '@/lib/ai/llm';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages';
|
||||
const log = createLogger('Quiz Grade');
|
||||
|
||||
interface GradeRequest {
|
||||
@@ -38,6 +39,9 @@ export async function POST(req: NextRequest) {
|
||||
const { model: languageModel } = resolveModelFromHeaders(req);
|
||||
|
||||
const isZh = language === 'zh-CN';
|
||||
const langName = getCourseLanguagePromptName(language || 'en-US');
|
||||
const langSuffix =
|
||||
!isZh && language !== 'en-US' ? `\nIMPORTANT: Write your comment in ${langName}.` : '';
|
||||
|
||||
const systemPrompt = isZh
|
||||
? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。
|
||||
@@ -45,7 +49,7 @@ export async function POST(req: NextRequest) {
|
||||
{"score": <0到${points}的整数>, "comment": "<一两句评语>"}`
|
||||
: `You are a professional educational assessor. Grade the student's answer and provide brief feedback.
|
||||
You must reply in the following JSON format only (no other content):
|
||||
{"score": <integer from 0 to ${points}>, "comment": "<one or two sentences of feedback>"}`;
|
||||
{"score": <integer from 0 to ${points}>, "comment": "<one or two sentences of feedback>"}${langSuffix}`;
|
||||
|
||||
const userPrompt = isZh
|
||||
? `题目:${question}
|
||||
|
||||
+6
-4
@@ -46,6 +46,7 @@ import { toast } from 'sonner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
|
||||
import { SpeechButton } from '@/components/audio/speech-button';
|
||||
import { findClosestCourseLanguage } from '@/lib/i18n/course-languages';
|
||||
|
||||
const log = createLogger('Home');
|
||||
|
||||
@@ -56,7 +57,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
|
||||
interface FormState {
|
||||
pdfFile: File | null;
|
||||
requirement: string;
|
||||
language: 'zh-CN' | 'en-US';
|
||||
language: string;
|
||||
webSearch: boolean;
|
||||
}
|
||||
|
||||
@@ -99,11 +100,12 @@ function HomePage() {
|
||||
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
const updates: Partial<FormState> = {};
|
||||
if (savedWebSearch === 'true') updates.webSearch = true;
|
||||
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
|
||||
if (savedLanguage) {
|
||||
// User previously made an explicit choice — respect it
|
||||
updates.language = savedLanguage;
|
||||
} else {
|
||||
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||||
updates.language = detected;
|
||||
// First visit: derive course language from UI locale
|
||||
updates.language = findClosestCourseLanguage(locale);
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { WebSearchProviderId } from '@/lib/web-search/types';
|
||||
import type { ProviderId } from '@/lib/ai/providers';
|
||||
import type { SettingsSection } from '@/lib/types/settings';
|
||||
import { MediaPopover } from '@/components/generation/media-popover';
|
||||
import { courseLanguages, getCourseLanguageLabel } from '@/lib/i18n/course-languages';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────
|
||||
const MAX_PDF_SIZE_MB = 50;
|
||||
@@ -28,8 +29,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
export interface GenerationToolbarProps {
|
||||
language: 'zh-CN' | 'en-US';
|
||||
onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
|
||||
language: string;
|
||||
onLanguageChange: (lang: string) => void;
|
||||
webSearch: boolean;
|
||||
onWebSearchChange: (v: boolean) => void;
|
||||
onSettingsOpen: (section?: SettingsSection) => void;
|
||||
@@ -357,19 +358,36 @@ export function GenerationToolbar({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* ── Language pill ── */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
|
||||
className={pillMuted}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* ── Language selector ── */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={pillMuted}>
|
||||
<Globe className="size-3.5" />
|
||||
<span>{getCourseLanguageLabel(language)}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{courseLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => onLanguageChange(lang.code)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted/60',
|
||||
language === lang.code && 'font-medium text-foreground',
|
||||
language !== lang.code && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{language === lang.code && <Check className="size-3.5" />}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── Separator ── */}
|
||||
<div className="w-px h-4 bg-border/60 mx-1" />
|
||||
|
||||
@@ -14,7 +14,7 @@ Please generate scene outlines based on the following course requirements.
|
||||
|
||||
**Required language**: {{language}}
|
||||
|
||||
(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English)
|
||||
(All content must be generated in the specified language)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -735,7 +735,7 @@ function normalizeQuizAnswer(question: Record<string, unknown>): string[] | unde
|
||||
async function generateInteractiveContent(
|
||||
outline: SceneOutline,
|
||||
aiCall: AICallFn,
|
||||
language: 'zh-CN' | 'en-US' = 'zh-CN',
|
||||
language: string = 'zh-CN',
|
||||
): Promise<GeneratedInteractiveContent | null> {
|
||||
const config = outline.interactiveConfig!;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Curated list of languages available for course generation.
|
||||
*
|
||||
* To add a new language, append an entry here — no other file changes needed.
|
||||
* - code: BCP-47 tag stored in settings / passed to APIs
|
||||
* - label: Native name shown in the UI (autoglossonym, no i18n needed)
|
||||
* - promptName: English name injected into LLM prompts
|
||||
*/
|
||||
export const courseLanguages = [
|
||||
{ code: 'zh-CN', label: '简体中文', promptName: 'Chinese (Simplified)' },
|
||||
{ code: 'zh-TW', label: '繁體中文', promptName: 'Chinese (Traditional)' },
|
||||
{ code: 'en-US', label: 'English', promptName: 'English' },
|
||||
{ code: 'ja', label: '日本語', promptName: 'Japanese' },
|
||||
{ code: 'ko', label: '한국어', promptName: 'Korean' },
|
||||
{ code: 'fr', label: 'Français', promptName: 'French' },
|
||||
{ code: 'de', label: 'Deutsch', promptName: 'German' },
|
||||
{ code: 'es', label: 'Español', promptName: 'Spanish' },
|
||||
{ code: 'pt', label: 'Português', promptName: 'Portuguese' },
|
||||
{ code: 'ru', label: 'Русский', promptName: 'Russian' },
|
||||
{ code: 'ar', label: 'العربية', promptName: 'Arabic' },
|
||||
] as const;
|
||||
|
||||
export type CourseLanguageCode = (typeof courseLanguages)[number]['code'];
|
||||
|
||||
const defaultCode: CourseLanguageCode = 'zh-CN';
|
||||
|
||||
/** Get the native display label for a course language code. */
|
||||
export function getCourseLanguageLabel(code: string): string {
|
||||
return courseLanguages.find((l) => l.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
/** Get the English prompt name for a course language code. */
|
||||
export function getCourseLanguagePromptName(code: string): string {
|
||||
return courseLanguages.find((l) => l.code === code)?.promptName ?? code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest supported course language for a given locale string.
|
||||
* Matching order: exact code → base language prefix → default (zh-CN).
|
||||
*
|
||||
* Examples:
|
||||
* 'en-US' → 'en-US' (exact)
|
||||
* 'en-GB' → 'en-US' (prefix 'en' matches 'en-US')
|
||||
* 'zh-TW' → 'zh-TW' (exact)
|
||||
* 'zh' → 'zh-CN' (prefix 'zh' matches 'zh-CN', first in list)
|
||||
* 'sv' → 'zh-CN' (no match, fallback)
|
||||
*/
|
||||
export function findClosestCourseLanguage(locale: string): CourseLanguageCode {
|
||||
// Exact match
|
||||
const exact = courseLanguages.find((l) => l.code === locale);
|
||||
if (exact) return exact.code;
|
||||
|
||||
// Base language prefix match (e.g. 'en' from 'en-GB')
|
||||
const base = locale.split('-')[0];
|
||||
const prefixMatch = courseLanguages.find((l) => l.code.split('-')[0] === base);
|
||||
if (prefixMatch) return prefixMatch.code;
|
||||
|
||||
return defaultCode;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { StatelessChatRequest } from '@/lib/types/chat';
|
||||
import type { AgentConfig } from '@/lib/orchestration/registry/types';
|
||||
import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt';
|
||||
import { getActionDescriptions, getEffectiveActions } from './tool-schemas';
|
||||
import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages';
|
||||
|
||||
// ==================== Role Guidelines ====================
|
||||
|
||||
@@ -166,7 +167,7 @@ Personalize your teaching based on their background when relevant. Address them
|
||||
// Build language constraint from stage language
|
||||
const courseLanguage = storeState.stage?.language;
|
||||
const languageConstraint = courseLanguage
|
||||
? `\n# Language (CRITICAL)\nYou MUST speak in ${courseLanguage === 'zh-CN' ? 'Chinese (Simplified)' : courseLanguage === 'en-US' ? 'English' : courseLanguage}. ALL text content in your response MUST be in this language.\n`
|
||||
? `\n# Language (CRITICAL)\nYou MUST speak in ${getCourseLanguagePromptName(courseLanguage)}. ALL text content in your response MUST be in this language.\n`
|
||||
: '';
|
||||
|
||||
return `# Role
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ProjectMCP } from './mcp/project-mcp';
|
||||
import { AgentMCP } from './mcp/agent-mcp';
|
||||
import { IssueboardMCP } from './mcp/issueboard-mcp';
|
||||
import { buildPBLSystemPrompt } from './pbl-system-prompt';
|
||||
import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages';
|
||||
import type { PBLMode } from './types';
|
||||
|
||||
export interface GeneratePBLConfig {
|
||||
@@ -290,7 +291,7 @@ export async function generatePBLContent(
|
||||
prompt:
|
||||
language === 'zh-CN'
|
||||
? `请设计一个PBL项目。现在从 project_info 模式开始,先设置项目标题和描述。`
|
||||
: `Design a PBL project. Start in project_info mode by setting the project title and description.`,
|
||||
: `Design a PBL project. Start in project_info mode by setting the project title and description.${language !== 'en-US' ? ` Respond in ${getCourseLanguagePromptName(language)}.` : ''}`,
|
||||
tools: pblTools,
|
||||
stopWhen: stepCountIs(30),
|
||||
onStepFinish: ({ toolCalls, text }) => {
|
||||
@@ -395,7 +396,7 @@ Based on the issue information above, generate 1-3 specific, actionable question
|
||||
- Help break down the problem
|
||||
- Encourage critical thinking
|
||||
|
||||
Format your response as a numbered list.`;
|
||||
Format your response as a numbered list.${language !== 'en-US' ? `\n\nIMPORTANT: Generate all questions in ${getCourseLanguagePromptName(language)}.` : ''}`;
|
||||
|
||||
const questionResult = await callLLM(
|
||||
{
|
||||
|
||||
@@ -4,18 +4,26 @@
|
||||
* Migrated from PBL-Nano with multi-language support.
|
||||
*/
|
||||
|
||||
import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages';
|
||||
|
||||
function appendLanguageInstruction(prompt: string, language: string): string {
|
||||
if (language === 'zh-CN' || language === 'en-US') return prompt;
|
||||
const langName = getCourseLanguagePromptName(language);
|
||||
return `${prompt}\n\n## Language (CRITICAL)\nYou MUST respond in ${langName}.`;
|
||||
}
|
||||
|
||||
export function getQuestionAgentPrompt(language: string = 'en-US'): string {
|
||||
if (language === 'zh-CN') {
|
||||
return QUESTION_AGENT_TEMPLATE_PROMPT_ZH;
|
||||
}
|
||||
return QUESTION_AGENT_TEMPLATE_PROMPT;
|
||||
return appendLanguageInstruction(QUESTION_AGENT_TEMPLATE_PROMPT, language);
|
||||
}
|
||||
|
||||
export function getJudgeAgentPrompt(language: string = 'en-US'): string {
|
||||
if (language === 'zh-CN') {
|
||||
return JUDGE_AGENT_TEMPLATE_PROMPT_ZH;
|
||||
}
|
||||
return JUDGE_AGENT_TEMPLATE_PROMPT;
|
||||
return appendLanguageInstruction(JUDGE_AGENT_TEMPLATE_PROMPT, language);
|
||||
}
|
||||
|
||||
export const QUESTION_AGENT_TEMPLATE_PROMPT = `You are a Question Agent in a Project-Based Learning platform. Your role is to help students understand and complete their assigned issue.
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Enhanced with multi-language support and configurable parameters.
|
||||
*/
|
||||
|
||||
import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages';
|
||||
|
||||
export interface PBLSystemPromptConfig {
|
||||
projectTopic: string;
|
||||
projectDescription: string;
|
||||
@@ -20,6 +22,12 @@ export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string {
|
||||
return buildPBLSystemPromptZH(config);
|
||||
}
|
||||
|
||||
const langName = getCourseLanguagePromptName(language);
|
||||
const langInstruction =
|
||||
language !== 'en-US'
|
||||
? `\n\n## Language (CRITICAL)\nYou MUST produce ALL output in ${langName}. Every title, description, role name, issue text, and any other generated content MUST be in ${langName}.\n`
|
||||
: '';
|
||||
|
||||
return `You are a Teaching Assistant (TA) on a Project-Based Learning platform. You are fully responsible for designing group projects for students based on the course information provided by the teacher.
|
||||
|
||||
## Your Responsibility
|
||||
@@ -81,7 +89,7 @@ When you create issues:
|
||||
|
||||
**IMPORTANT**: Once you have configured the project info, defined all necessary agents (roles), and created the issueboard with tasks, you MUST set your mode to **idle** to indicate completion.
|
||||
|
||||
Your initial mode is **project_info**.`;
|
||||
Your initial mode is **project_info**.${langInstruction}`;
|
||||
}
|
||||
|
||||
function buildPBLSystemPromptZH(config: PBLSystemPromptConfig): string {
|
||||
|
||||
@@ -96,8 +96,8 @@ function createInMemoryStore(stage: Stage): StageStore {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' {
|
||||
return language === 'en-US' ? 'en-US' : 'zh-CN';
|
||||
function normalizeLanguage(language?: string): string {
|
||||
return language || 'zh-CN';
|
||||
}
|
||||
|
||||
function stripCodeFences(text: string): string {
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface UploadedDocument {
|
||||
*/
|
||||
export interface UserRequirements {
|
||||
requirement: string; // Single free-form text for all user input
|
||||
language: 'zh-CN' | 'en-US'; // Course language - critical for generation
|
||||
language: string; // Course language - critical for generation
|
||||
userNickname?: string; // Student nickname for personalization
|
||||
userBio?: string; // Student background for personalization
|
||||
webSearch?: boolean; // Enable web search for richer context
|
||||
@@ -100,7 +100,7 @@ export interface SceneOutline {
|
||||
teachingObjective?: string;
|
||||
estimatedDuration?: number; // seconds
|
||||
order: number;
|
||||
language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements)
|
||||
language?: string; // Generation language (inherited from requirements)
|
||||
// Suggested image IDs (from PDF-extracted images)
|
||||
suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"]
|
||||
// AI-generated media requests (when PDF images are insufficient)
|
||||
@@ -124,7 +124,7 @@ export interface SceneOutline {
|
||||
projectDescription: string;
|
||||
targetSkills: string[];
|
||||
issueCount?: number;
|
||||
language: 'zh-CN' | 'en-US';
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user