From 6b063222676fa31ccd055af6d1326aa710528880 Mon Sep 17 00:00:00 2001 From: JaeSeoKim Date: Wed, 8 Jul 2026 14:16:38 +0900 Subject: [PATCH] feat(ai): response-language setting for Ask AI and agent review jobs Adds a "Response Language" option (preset dropdown + custom input, Settings AI tab) so Ask AI answers and guide/tour/review agent jobs are written in the user's chosen language. - Ask AI: optional `responseLanguage` on AIContext; buildSystemPrompt / buildForkPreamble append the instruction. A per-query instruction is appended only when settings drift from the session's baked-in language, so a change applies from the very next question without duplication. - Agent jobs: launchJob injects the setting into the job body; guide/tour buildCommand and the review-job user message (Bun + Pi servers) prepend the instruction. Guide repair jobs are deliberately excluded (they must never rewrite content). - Stored as English language name in the cookie `plannotator-ai-response-language`; default Auto (no instruction). Co-Authored-By: Claude Fable 5 --- apps/pi-extension/server/agent-jobs.ts | 3 +- apps/pi-extension/server/serverReview.ts | 8 ++- packages/ai/ai.test.ts | 21 ++++++++ packages/ai/context.ts | 27 ++++++++-- packages/core/ai-context.ts | 8 +-- packages/server/agent-jobs.ts | 3 +- packages/server/agent-review-message.ts | 14 ++++++ packages/server/guide/guide-review.test.ts | 26 +++++++++- packages/server/guide/guide-review.ts | 8 ++- packages/server/review.ts | 8 ++- packages/server/tour/tour-review.ts | 8 ++- packages/ui/components/AISettingsTab.tsx | 58 ++++++++++++++++++++++ packages/ui/hooks/useAIChat.ts | 17 ++++++- packages/ui/hooks/useAgentJobs.ts | 9 +++- packages/ui/utils/aiProvider.ts | 51 +++++++++++++++++++ 15 files changed, 248 insertions(+), 21 deletions(-) diff --git a/apps/pi-extension/server/agent-jobs.ts b/apps/pi-extension/server/agent-jobs.ts index a1649e50c..7b7f7a10b 100644 --- a/apps/pi-extension/server/agent-jobs.ts +++ b/apps/pi-extension/server/agent-jobs.ts @@ -568,7 +568,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { const KNOWN_JOB_FIELDS = new Set([ "provider", "command", "label", "engine", "model", "reasoningEffort", "effort", "thinking", "fastMode", - "reviewProfileId", "repairOf", + "responseLanguage", "reviewProfileId", "repairOf", ]); if (body && typeof body === "object") { const unknown = Object.keys(body).filter((k) => !KNOWN_JOB_FIELDS.has(k)); @@ -631,6 +631,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) { if (typeof body.effort === "string") config.effort = body.effort; if (typeof body.thinking === "string") config.thinking = body.thinking; if (body.fastMode === true) config.fastMode = true; + if (typeof body.responseLanguage === "string") config.responseLanguage = body.responseLanguage; if (typeof body.reviewProfileId === "string") config.reviewProfileId = body.reviewProfileId; if (typeof body.repairOf === "string") config.repairOf = body.repairOf; const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined); diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 322b03839..56a7b88b8 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -103,7 +103,7 @@ import { parseCodexOutput, transformReviewFindings, } from "../generated/codex-review.js"; -import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.js"; +import { agentJobLanguageInstruction, buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.js"; import { composeClaudeReviewPrompt, buildClaudeCommand, @@ -992,13 +992,17 @@ export async function startReviewServer(options: { // prompt; strip the default framing prose from the user message so only the // git/PR context remains. The default review keeps today's message verbatim. const isCustomReview = reviewProfile.source === "user"; - const userMessage = workspacePrompt + const baseUserMessage = workspacePrompt ? buildAgentReviewUserMessageForTarget({ kind: "workspace", patch: currentPatch, workspace: workspacePrompt, }, isCustomReview) : buildAgentReviewUserMessage(currentPatch, currentDiffType as DiffType, userMessageOptions, prMeta, isCustomReview); + const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage); + const userMessage = languageInstruction + ? `${languageInstruction}\n\n${baseUserMessage}` + : baseUserMessage; const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review"; if (provider === "codex") { diff --git a/packages/ai/ai.test.ts b/packages/ai/ai.test.ts index 4e98184c6..f0d8582dd 100644 --- a/packages/ai/ai.test.ts +++ b/packages/ai/ai.test.ts @@ -446,6 +446,27 @@ describe("Context builders", () => { expect(prompt).toMatch(/messages/i); expect(prompt).toMatch(/inspect/i); }); + + test("responseLanguage appends a language instruction in every mode", () => { + const modes: AIContext[] = [ + { mode: "plan-review", plan: { plan: "# Plan" }, responseLanguage: "Korean" }, + { mode: "code-review", review: { patch: "+x" }, responseLanguage: "Korean" }, + { mode: "annotate", annotate: { content: "# Doc", filePath: "/x.md" }, responseLanguage: "Korean" }, + ]; + for (const ctx of modes) { + expect(buildSystemPrompt(ctx)).toContain("Always respond in Korean"); + expect(buildForkPreamble(ctx)).toContain("Always respond in Korean"); + } + }); + + test("no language instruction when responseLanguage is absent or blank", () => { + const plain: AIContext = { mode: "plan-review", plan: { plan: "# Plan" } }; + const blank: AIContext = { mode: "plan-review", plan: { plan: "# Plan" }, responseLanguage: " " }; + for (const ctx of [plain, blank]) { + expect(buildSystemPrompt(ctx)).not.toContain("Always respond in"); + expect(buildForkPreamble(ctx)).not.toContain("Always respond in"); + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/ai/context.ts b/packages/ai/context.ts index d7c84140d..bcaf0cad1 100644 --- a/packages/ai/context.ts +++ b/packages/ai/context.ts @@ -22,14 +22,20 @@ import type { AIContext } from "./types.ts"; * - That it's operating inside Plannotator (not a general coding session) */ export function buildSystemPrompt(ctx: AIContext): string { + let base: string; switch (ctx.mode) { case "plan-review": - return buildPlanReviewPrompt(ctx); + base = buildPlanReviewPrompt(ctx); + break; case "code-review": - return buildCodeReviewPrompt(); + base = buildCodeReviewPrompt(); + break; case "annotate": - return buildAnnotatePrompt(ctx); + base = buildAnnotatePrompt(ctx); + break; } + const lang = languageInstruction(ctx); + return lang ? `${base}\n\n${lang}` : base; } /** @@ -44,9 +50,12 @@ export function buildForkPreamble(ctx: AIContext): string { "The user is now reviewing your work in Plannotator and has a question.", "Answer the user's message directly and concisely based on the conversation " + "history and the context below. Do not re-review or summarize the work unless they ask.", - "", ]; + const lang = languageInstruction(ctx); + if (lang) lines.push(lang); + lines.push(""); + switch (ctx.mode) { case "plan-review": { lines.push("## Current Plan Under Review"); @@ -151,6 +160,16 @@ const ANSWER_DIRECTLY = "The material below is context for what the user is looking at — do NOT review, summarize, or critique it unless the user's message asks you to. " + "Only investigate further (read files, run git) if the user's question actually requires it."; +/** Instruction appended when the user picked a response language in settings. */ +function languageInstruction(ctx: AIContext): string | null { + const lang = ctx.responseLanguage?.trim(); + if (!lang) return null; + return ( + `IMPORTANT: Always respond in ${lang}, regardless of the language of the user's message ` + + "or the material under review. Keep code, file paths, and technical identifiers as-is." + ); +} + function truncate(text: string, max: number): string { if (text.length <= max) return text; return `${text.slice(0, max)}\n\n... [truncated for context window]`; diff --git a/packages/core/ai-context.ts b/packages/core/ai-context.ts index a3130f72f..21a8221fa 100644 --- a/packages/core/ai-context.ts +++ b/packages/core/ai-context.ts @@ -76,8 +76,10 @@ export interface AnnotateContext { /** * Union of mode-specific contexts, discriminated by `mode`. + * `responseLanguage` is the language the AI should respond in + * (English name, e.g. "Korean"); omit for auto. */ export type AIContext = - | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } - | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } - | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; + | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession; responseLanguage?: string } + | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession; responseLanguage?: string } + | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession; responseLanguage?: string }; diff --git a/packages/server/agent-jobs.ts b/packages/server/agent-jobs.ts index 8f65ef04a..75895aae5 100644 --- a/packages/server/agent-jobs.ts +++ b/packages/server/agent-jobs.ts @@ -604,7 +604,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob const KNOWN_JOB_FIELDS = new Set([ "provider", "command", "label", "engine", "model", "reasoningEffort", "effort", "thinking", "fastMode", - "reviewProfileId", "repairOf", + "responseLanguage", "reviewProfileId", "repairOf", ]); if (body && typeof body === "object") { const unknown = Object.keys(body).filter((k) => !KNOWN_JOB_FIELDS.has(k)); @@ -674,6 +674,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob if (typeof body.effort === "string") config.effort = body.effort; if (typeof body.thinking === "string") config.thinking = body.thinking; if (body.fastMode === true) config.fastMode = true; + if (typeof body.responseLanguage === "string") config.responseLanguage = body.responseLanguage; if (typeof body.reviewProfileId === "string") config.reviewProfileId = body.reviewProfileId; if (typeof body.repairOf === "string") config.repairOf = body.repairOf; const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined); diff --git a/packages/server/agent-review-message.ts b/packages/server/agent-review-message.ts index 5b1be6b05..7f07bb340 100644 --- a/packages/server/agent-review-message.ts +++ b/packages/server/agent-review-message.ts @@ -36,6 +36,20 @@ export type AgentReviewTarget = workspace: WorkspaceReviewPromptContext; }; +/** + * Instruction appended to agent-job prompts (review / guide / tour) when the + * user picked a response language in settings. `responseLanguage` comes from + * the client job-launch body, so it's validated here. + */ +export function agentJobLanguageInstruction(responseLanguage: unknown): string | null { + const lang = typeof responseLanguage === "string" ? responseLanguage.trim() : ""; + if (!lang) return null; + return ( + `IMPORTANT: Write all natural-language output (titles, overviews, summaries, findings, comments) in ${lang}. ` + + "Keep file paths, code identifiers, and JSON/schema keys as-is." + ); +} + export interface LocalDiffInstruction { target: string; inspect: string; diff --git a/packages/server/guide/guide-review.test.ts b/packages/server/guide/guide-review.test.ts index f25a9df3f..bbdb4f373 100644 --- a/packages/server/guide/guide-review.test.ts +++ b/packages/server/guide/guide-review.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { repairGuideJsonText, validateGuideOutput, parseGuideStreamOutput } from "./guide-review"; +import { repairGuideJsonText, validateGuideOutput, parseGuideStreamOutput, createGuideSession } from "./guide-review"; // Pins the behaviors the PR-993 review rounds fixed. This module previously // had NO direct coverage — the repair ladder and validation are pure logic @@ -150,3 +150,27 @@ describe("parseGuideStreamOutput", () => { expect(parseGuideStreamOutput("")).toBeNull(); }); }); + +describe("guide buildCommand response language", () => { + const baseOpts = { + cwd: "/tmp", + patch: "diff --git a/src/a.ts b/src/a.ts\n+x", + diffType: "uncommitted" as const, + changedFiles: [{ path: "src/a.ts", additions: 1, deletions: 0 }], + }; + + it("prepends the language instruction to the user message when responseLanguage is set", async () => { + const session = createGuideSession(); + const built = await session.buildCommand({ + ...baseOpts, + config: { engine: "claude", responseLanguage: "Korean" }, + }); + expect(built.prompt).toContain("in Korean"); + }); + + it("leaves the prompt unchanged when responseLanguage is absent", async () => { + const session = createGuideSession(); + const built = await session.buildCommand({ ...baseOpts, config: { engine: "claude" } }); + expect(built.prompt).not.toContain("in Korean"); + }); +}); diff --git a/packages/server/guide/guide-review.ts b/packages/server/guide/guide-review.ts index eacb9a13b..f082de363 100644 --- a/packages/server/guide/guide-review.ts +++ b/packages/server/guide/guide-review.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile, readFile, unlink } from "node:fs/promises"; import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; import type { DiffType } from "../vcs"; import type { PRMetadata } from "../pr"; -import { buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message"; +import { agentJobLanguageInstruction, buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message"; import { MARKER_ENGINES, makeMarkerNonce, @@ -1142,7 +1142,11 @@ export function createGuideSession(): GuideSession { return { command, stdinPrompt, prompt: repairPrompt, cwd, label: "Guide Repair", captureStdout: true, engine: "claude", model, effort: "low" }; } - const userMessage = buildGuideUserMessage(patch, diffType, options, prMetadata, changedFiles); + const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage); + const baseUserMessage = buildGuideUserMessage(patch, diffType, options, prMetadata, changedFiles); + const userMessage = languageInstruction + ? `${languageInstruction}\n\n${baseUserMessage}` + : baseUserMessage; // Marker engines (Cursor, OpenCode, Pi) — none has a schema flag, so the // guide contract's marker-delimited JSON block (composeGuideMarkerPrompt) diff --git a/packages/server/review.ts b/packages/server/review.ts index f84d6bdc6..e1e379dc9 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -67,7 +67,7 @@ import { parseCodexOutput, transformReviewFindings, } from "./codex-review"; -import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "./agent-review-message"; +import { agentJobLanguageInstruction, buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "./agent-review-message"; import { composeClaudeReviewPrompt, buildClaudeCommand, @@ -958,13 +958,17 @@ export async function startReviewServer( // prompt; strip the default framing prose from the user message so only the // git/PR context remains. The default review keeps today's message verbatim. const isCustomReview = reviewProfile.source === "user"; - const userMessage = workspacePrompt + const baseUserMessage = workspacePrompt ? buildAgentReviewUserMessageForTarget({ kind: "workspace", patch: launchPatch, workspace: workspacePrompt, }, isCustomReview) : buildAgentReviewUserMessage(launchPatch, launchDiffType as DiffType, userMessageOptions, launchMetadata, isCustomReview); + const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage); + const userMessage = languageInstruction + ? `${languageInstruction}\n\n${baseUserMessage}` + : baseUserMessage; const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review"; if (provider === "codex") { diff --git a/packages/server/tour/tour-review.ts b/packages/server/tour/tour-review.ts index ad41035b0..d368ca0d5 100644 --- a/packages/server/tour/tour-review.ts +++ b/packages/server/tour/tour-review.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile, readFile, unlink } from "node:fs/promises"; import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; import type { DiffType } from "../vcs"; import type { PRMetadata } from "../pr"; -import { buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message"; +import { agentJobLanguageInstruction, buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message"; import type { CodeTourOutput, TourDiffAnchor, @@ -566,7 +566,11 @@ export function createTourSession(): TourSession { const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined; const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined; const fastMode = config?.fastMode === true; - const userMessage = buildTourUserMessage(patch, diffType, options, prMetadata); + const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage); + const baseUserMessage = buildTourUserMessage(patch, diffType, options, prMetadata); + const userMessage = languageInstruction + ? `${languageInstruction}\n\n${baseUserMessage}` + : baseUserMessage; const prompt = TOUR_REVIEW_PROMPT + "\n\n---\n\n" + userMessage; if (engine === "codex") { diff --git a/packages/ui/components/AISettingsTab.tsx b/packages/ui/components/AISettingsTab.tsx index 5010471a5..6f3b0c5dc 100644 --- a/packages/ui/components/AISettingsTab.tsx +++ b/packages/ui/components/AISettingsTab.tsx @@ -1,11 +1,14 @@ import type React from 'react'; import { getProviderMeta } from './ProviderIcons'; import { + AI_RESPONSE_LANGUAGES, getAIProviderSettings, + getAIResponseLanguage, resolveAIModelForProvider, resolveAIProviderSelection, saveAIProviderSelection, savePreferredModel, + setAIResponseLanguage, type AIProviderOption, } from '../utils/aiProvider'; import { useState } from 'react'; @@ -47,6 +50,59 @@ export const AISettingsTab: React.FC = ({ setPreferredModels(prev => ({ ...prev, [providerId]: modelId })); }; + const [responseLanguage, setResponseLanguage] = useState(() => getAIResponseLanguage() ?? ''); + const isPresetLanguage = (value: string) => + value === '' || AI_RESPONSE_LANGUAGES.some(l => l.value === value); + const [customLanguage, setCustomLanguage] = useState(() => !isPresetLanguage(getAIResponseLanguage() ?? '')); + + const handleLanguageSelect = (value: string) => { + if (value === '__custom__') { + setCustomLanguage(true); + return; + } + setCustomLanguage(false); + setResponseLanguage(value); + setAIResponseLanguage(value || null); + }; + + const handleCustomLanguageChange = (value: string) => { + setResponseLanguage(value); + setAIResponseLanguage(value.trim() || null); + }; + + const languageSection = ( + <> +
+
Response Language
+
+ Language the AI should respond in. Applies from your next question. +
+
+
+ + {customLanguage && ( + handleCustomLanguageChange(e.target.value)} + placeholder='Language name in English, e.g. "Brazilian Portuguese"' + className="w-full text-xs bg-muted rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + )} +
+ + ); + if (providers.length === 0) { return ( <> @@ -140,6 +196,8 @@ export const AISettingsTab: React.FC = ({ Providers are detected from installed CLI tools. No API keys are managed by Plannotator — you must be authenticated with each CLI independently.{' '} Learn more + + {languageSection} ); }; diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index ec85fc28f..44de663b9 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { AIContext } from '@plannotator/core'; import type { AIQuestion, AIResponse } from '../types'; import { generateId } from '../utils/generateId'; +import { getAIResponseLanguage } from '../utils/aiProvider'; export interface AIChatEntry { question: AIQuestion; @@ -214,6 +215,10 @@ export function useAIChat({ const createRequestRef = useRef(0); const sessionIdRef = useRef(null); sessionIdRef.current = thread.sessionId; + // Language baked into the current session's system prompt at creation time. + // Lets ask() append a per-query instruction ONLY when settings have drifted + // from it, instead of duplicating the instruction on every message. + const sessionLanguageRef = useRef(null); const updateMessages = useCallback((updater: (messages: AIChatEntry[]) => AIChatEntry[]) => { setThread(prev => ({ ...prev, messages: updater(prev.messages) })); @@ -235,8 +240,11 @@ export function useAIChat({ const requestId = ++createRequestRef.current; setIsCreatingSession(true); try { + // Read at call time so a settings change applies to the next session. + const responseLanguage = getAIResponseLanguage(); + sessionLanguageRef.current = responseLanguage; const res = await aiTransport.session({ - context, + context: responseLanguage ? { ...context, responseLanguage } : context, ...(providerId && { providerId }), ...(model && { model }), ...(reasoningEffort && { reasoningEffort }), @@ -326,10 +334,15 @@ export function useAIChat({ throw createAbortError('AI question was superseded'); } + // The session's system prompt carries the language chosen at creation + // time; append a per-query instruction only when settings have drifted + // from it since, so a change still applies to the very next question. + const lang = getAIResponseLanguage(); + const langDrifted = !!lang && lang !== sessionLanguageRef.current; const fullPrompt = buildPrompt(params); const res = await aiTransport.query({ sessionId: sid, - prompt: fullPrompt, + prompt: langDrifted ? `${fullPrompt}\n\n(Respond in ${lang}.)` : fullPrompt, ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), }, controller.signal); diff --git a/packages/ui/hooks/useAgentJobs.ts b/packages/ui/hooks/useAgentJobs.ts index 7873f59d2..435b0ee3d 100644 --- a/packages/ui/hooks/useAgentJobs.ts +++ b/packages/ui/hooks/useAgentJobs.ts @@ -12,6 +12,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from '../types'; +import { getAIResponseLanguage } from '../utils/aiProvider'; const POLL_INTERVAL_MS = 500; const STREAM_URL = '/api/agents/jobs/stream'; @@ -31,6 +32,9 @@ export type AgentLaunchParams = { /** Pi's unified reasoning level (`--thinking off|minimal|low|medium|high|xhigh`). */ thinking?: string; fastMode?: boolean; + /** Language the agent should write its output in (English name, e.g. "Korean"). + * Injected automatically from settings by launchJob. */ + responseLanguage?: string; reviewProfileId?: string; /** Launches a guide-repair job against a failed guide job's captured output * (see GuideEmptyState's failure-recovery panel). The server resolves a @@ -267,10 +271,13 @@ export function useAgentJobs( const launchJob = useCallback( async (params: AgentLaunchParams): Promise => { + // The response-language setting rides on every launch so guide/tour/review + // agents write their output in the user's chosen language. + const responseLanguage = getAIResponseLanguage(); const res = await fetch(JOBS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), + body: JSON.stringify(responseLanguage ? { responseLanguage, ...params } : params), }); if (!res.ok) { throw new Error(await readResponseError(res)); diff --git a/packages/ui/utils/aiProvider.ts b/packages/ui/utils/aiProvider.ts index dbfacc16f..4e0ed98f9 100644 --- a/packages/ui/utils/aiProvider.ts +++ b/packages/ui/utils/aiProvider.ts @@ -12,6 +12,57 @@ import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator const PROVIDER_KEY = 'plannotator-ai-provider'; const MODELS_KEY = 'plannotator-ai-models'; const PROVIDER_BY_ORIGIN_KEY = 'plannotator-ai-provider-by-origin'; +const RESPONSE_LANGUAGE_KEY = 'plannotator-ai-response-language'; + +/** Preset languages for the Ask AI response-language setting. Stored value is the English name. */ +export const AI_RESPONSE_LANGUAGES: { value: string; label: string }[] = [ + { value: 'English', label: 'English' }, + { value: 'Korean', label: '한국어 (Korean)' }, + { value: 'Japanese', label: '日本語 (Japanese)' }, + { value: 'Chinese (Simplified)', label: '简体中文 (Chinese, Simplified)' }, + { value: 'Chinese (Traditional)', label: '繁體中文 (Chinese, Traditional)' }, + { value: 'Spanish', label: 'Español (Spanish)' }, + { value: 'French', label: 'Français (French)' }, + { value: 'German', label: 'Deutsch (German)' }, + { value: 'Portuguese (Brazilian)', label: 'Português do Brasil (Portuguese, Brazilian)' }, + { value: 'Portuguese', label: 'Português (Portuguese)' }, + { value: 'Italian', label: 'Italiano (Italian)' }, + { value: 'Dutch', label: 'Nederlands (Dutch)' }, + { value: 'Russian', label: 'Русский (Russian)' }, + { value: 'Ukrainian', label: 'Українська (Ukrainian)' }, + { value: 'Polish', label: 'Polski (Polish)' }, + { value: 'Czech', label: 'Čeština (Czech)' }, + { value: 'Turkish', label: 'Türkçe (Turkish)' }, + { value: 'Arabic', label: 'العربية (Arabic)' }, + { value: 'Hebrew', label: 'עברית (Hebrew)' }, + { value: 'Hindi', label: 'हिन्दी (Hindi)' }, + { value: 'Thai', label: 'ไทย (Thai)' }, + { value: 'Vietnamese', label: 'Tiếng Việt (Vietnamese)' }, + { value: 'Indonesian', label: 'Bahasa Indonesia (Indonesian)' }, + { value: 'Malay', label: 'Bahasa Melayu (Malay)' }, + { value: 'Swedish', label: 'Svenska (Swedish)' }, + { value: 'Norwegian', label: 'Norsk (Norwegian)' }, + { value: 'Danish', label: 'Dansk (Danish)' }, + { value: 'Finnish', label: 'Suomi (Finnish)' }, + { value: 'Greek', label: 'Ελληνικά (Greek)' }, + { value: 'Hungarian', label: 'Magyar (Hungarian)' }, + { value: 'Romanian', label: 'Română (Romanian)' }, +]; + +/** Language the AI should respond in (English name, e.g. "Korean"), or null for auto. */ +export function getAIResponseLanguage(): string | null { + const value = storage.getItem(RESPONSE_LANGUAGE_KEY)?.trim(); + return value || null; +} + +export function setAIResponseLanguage(lang: string | null): void { + const value = lang?.trim(); + if (value) { + storage.setItem(RESPONSE_LANGUAGE_KEY, value); + } else { + storage.removeItem(RESPONSE_LANGUAGE_KEY); + } +} export interface AIProviderModel { id: string;