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:
yangshen
2026-03-30 17:06:20 +08:00
co-authored by Claude Opus 4.6
parent a7cf676c47
commit f7bd6bdb21
12 changed files with 134 additions and 33 deletions
+5 -1
View File
@@ -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
View File
@@ -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 }));
+33 -15
View File
@@ -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)
---
+1 -1
View File
@@ -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!;
+59
View File
@@ -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;
}
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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(
{
+10 -2
View File
@@ -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.
+9 -1
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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;
};
}