feat(language): add multilingual support across frontend and backend

This commit is contained in:
zihanjian
2025-12-01 20:51:55 +08:00
parent 788210f9d5
commit 69327f63c5
8 changed files with 152 additions and 33 deletions

View File

@@ -55,6 +55,7 @@ def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerati
Dictionary with state update, including search_query key containing the generated queries
"""
configurable = Configuration.from_runnable_config(config)
language = state.get("language") or "English"
# check for custom initial search query count
if state.get("initial_search_query_count") is None:
@@ -75,6 +76,7 @@ def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerati
current_date=current_date,
research_topic=get_research_topic(state["messages"]),
number_queries=state["initial_search_query_count"],
language=language,
)
# Generate the search queries
result = structured_llm.invoke(formatted_prompt)
@@ -106,9 +108,11 @@ def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
"""
# Configure
configurable = Configuration.from_runnable_config(config)
language = state.get("language") or "English"
formatted_prompt = web_searcher_instructions.format(
current_date=get_current_date(),
research_topic=state["search_query"],
language=language,
)
# Uses the google genai client as the langchain client doesn't return grounding metadata
@@ -121,12 +125,15 @@ def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
},
)
# resolve the urls to short urls for saving tokens and time
resolved_urls = resolve_urls(
response.candidates[0].grounding_metadata.grounding_chunks, state["id"]
)
candidate = response.candidates[0] if response and response.candidates else None
grounding_chunks = None
if candidate and getattr(candidate, "grounding_metadata", None):
grounding_chunks = getattr(candidate.grounding_metadata, "grounding_chunks", None)
resolved_urls = resolve_urls(grounding_chunks, state["id"])
# Gets the citations and adds them to the generated text
citations = get_citations(response, resolved_urls)
modified_text = insert_citation_markers(response.text, citations)
base_text = response.text or ""
modified_text = insert_citation_markers(base_text, citations)
sources_gathered = [item for citation in citations for item in citation["segments"]]
return {
@@ -151,6 +158,7 @@ def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
Dictionary with state update, including search_query key containing the generated follow-up query
"""
configurable = Configuration.from_runnable_config(config)
language = state.get("language") or "English"
# Increment the research loop count and get the reasoning model
state["research_loop_count"] = state.get("research_loop_count", 0) + 1
reasoning_model = state.get("reasoning_model", configurable.reflection_model)
@@ -161,6 +169,7 @@ def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
current_date=current_date,
research_topic=get_research_topic(state["messages"]),
summaries="\n\n---\n\n".join(state["web_research_result"]),
language=language,
)
# init Reasoning Model
llm = ChatGoogleGenerativeAI(
@@ -232,13 +241,21 @@ def finalize_answer(state: OverallState, config: RunnableConfig):
"""
configurable = Configuration.from_runnable_config(config)
reasoning_model = state.get("reasoning_model") or configurable.answer_model
language = state.get("language") or "English"
# Format the prompt
current_date = get_current_date()
# Escape braces in user content to avoid str.format KeyErrors when summaries contain JSON-like text
safe_topic = get_research_topic(state["messages"]).replace("{", "{{").replace(
"}", "}}"
)
summaries_text = "\n---\n\n".join(state["web_research_result"])
safe_summaries = summaries_text.replace("{", "{{").replace("}", "}}")
formatted_prompt = answer_instructions.format(
current_date=current_date,
research_topic=get_research_topic(state["messages"]),
summaries="\n---\n\n".join(state["web_research_result"]),
research_topic=safe_topic,
summaries=safe_summaries,
language=language,
)
# init Reasoning Model, default to Gemini 2.5 Flash

View File

@@ -16,6 +16,7 @@ Instructions:
- Queries should be diverse, if the topic is broad, generate more than 1 query.
- Don't generate multiple similar queries, 1 is enough.
- Query should ensure that the most current information is gathered. The current date is {current_date}.
- Always responed in {language}.
Format:
- Format your response as a JSON object with ALL two of these exact keys:
@@ -43,6 +44,7 @@ Instructions:
- Consolidate key findings while meticulously tracking the source(s) for each specific piece of information.
- The output should be concise research notes oriented toward comic creation, not a narrative report. Capture factual details that help draw scenes and characters.
- Only include the information found in the search results, don't make up any information.
- Always responed in {language}.
Research Topic:
{research_topic}
@@ -55,6 +57,7 @@ Instructions:
- If provided summaries are sufficient to answer the user's question, don't generate a follow-up query.
- If there is a knowledge gap, generate a follow-up query that would help expand your understanding.
- Focus on technical details, implementation specifics, or emerging trends that weren't fully covered.
- Always responed in {language}.
Requirements:
- Ensure the follow-up query is self-contained and includes necessary context for web search.
@@ -88,12 +91,11 @@ Strict Requirements:
- "id": integer, the 1-based page identifier (e.g., 1, 2, 3, ...)
- "detail": string, a thorough page description that fine-grains every panel: characters' actions, attire, environment, camera/framing, dialogue with tone, props, transitions.
- Do NOT invent facts. Ground all details in the provided summaries.
- Include sources inline inside the "detail" string using markdown links (e.g., [apnews](https://vertexaisearch.cloud.google.com/id/1-0)).
Example JSON (structure only):
[
{ "id": 1, "detail": "..." },
{ "id": 2, "detail": "..." }
{{ "id": 1, "detail": "..." }},
{{ "id": 2, "detail": "..." }}
]
Instructions:
@@ -102,6 +104,7 @@ Instructions:
- Use the user's request and all research summaries to build the storyboard.
- If the topic includes people, capture personality, visual appearance (hair, clothing, accessories), and speech style. If it includes objects, explain what they are and notable visual traits. If it includes locations or events, capture time period, atmosphere, and visual cues.
- Output must be a page-by-page JSON where each page is an object with "id" and a single "detail" string that thoroughly covers all panels and specifics.
- Always responed in {language}.
User Context:
- {research_topic}

View File

@@ -19,6 +19,7 @@ class OverallState(TypedDict):
max_research_loops: int
research_loop_count: int
reasoning_model: str
language: str
class ReflectionState(TypedDict):

View File

@@ -24,6 +24,8 @@ def resolve_urls(urls_to_resolve: List[Any], id: int) -> Dict[str, str]:
Create a map of the vertex ai search urls (very long) to a short url with a unique id for each url.
Ensures each original URL gets a consistent shortened form while maintaining uniqueness.
"""
if not urls_to_resolve:
return {}
prefix = f"https://vertexaisearch.cloud.google.com/id/"
urls = [site.web.uri for site in urls_to_resolve]

View File

@@ -21,6 +21,7 @@ export default function App() {
initial_search_query_count: number;
max_research_loops: number;
reasoning_model: string;
language: string;
}>({
apiUrl: import.meta.env.DEV
? "http://localhost:2024"
@@ -100,7 +101,12 @@ export default function App() {
}, [thread.messages, thread.isLoading, processedEventsTimeline]);
const handleSubmit = useCallback(
(submittedInputValue: string, effort: string, model: string) => {
(
submittedInputValue: string,
effort: string,
model: string,
language: string
) => {
if (!submittedInputValue.trim()) return;
setProcessedEventsTimeline([]);
hasFinalizeEventOccurredRef.current = false;
@@ -139,6 +145,7 @@ export default function App() {
initial_search_query_count: initial_search_query_count,
max_research_loops: max_research_loops,
reasoning_model: model,
language,
});
},
[thread]

View File

@@ -181,6 +181,30 @@ const AiMessageBubble: React.FC<AiMessageBubbleProps> = ({
handleCopy,
copiedMessageId,
}) => {
const parsedPages = (() => {
if (typeof message.content !== "string") return null;
try {
const data = JSON.parse(message.content);
if (
Array.isArray(data) &&
data.every(
(p) =>
p &&
typeof p === "object" &&
"id" in p &&
"detail" in p &&
typeof p.id === "number" &&
typeof p.detail === "string"
)
) {
return data as { id: number; detail: string }[];
}
} catch (_e) {
return null;
}
return null;
})();
// Determine which activity events to show and if it's for a live loading message
const activityForThisBubble =
isLastMessage && isOverallLoading ? liveActivity : historicalActivity;
@@ -196,11 +220,29 @@ const AiMessageBubble: React.FC<AiMessageBubbleProps> = ({
/>
</div>
)}
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
{parsedPages ? (
<div className="space-y-3">
{parsedPages.map((page) => (
<div
key={page.id}
className="rounded-xl border border-neutral-700 bg-neutral-800/80 p-3 shadow-sm"
>
<div className="text-xs uppercase tracking-wide text-neutral-400 mb-1">
Page {page.id}
</div>
<ReactMarkdown components={mdComponents}>
{page.detail}
</ReactMarkdown>
</div>
))}
</div>
) : (
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
)}
<Button
variant="default"
className={`cursor-pointer bg-neutral-700 border-neutral-600 text-neutral-300 self-end ${
@@ -226,7 +268,7 @@ interface ChatMessagesViewProps {
messages: Message[];
isLoading: boolean;
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
onSubmit: (inputValue: string, effort: string, model: string) => void;
onSubmit: (inputValue: string, effort: string, model: string, language: string) => void;
onCancel: () => void;
liveActivityEvents: ProcessedEvent[];
historicalActivities: Record<string, ProcessedEvent[]>;

View File

@@ -1,6 +1,14 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { SquarePen, Brain, Send, StopCircle, Zap, Cpu } from "lucide-react";
import {
SquarePen,
Brain,
Send,
StopCircle,
Zap,
Cpu,
Languages,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -12,7 +20,12 @@ import {
// Updated InputFormProps
interface InputFormProps {
onSubmit: (inputValue: string, effort: string, model: string) => void;
onSubmit: (
inputValue: string,
effort: string,
model: string,
language: string
) => void;
onCancel: () => void;
isLoading: boolean;
hasHistory: boolean;
@@ -26,12 +39,14 @@ export const InputForm: React.FC<InputFormProps> = ({
}) => {
const [internalInputValue, setInternalInputValue] = useState("");
const [effort, setEffort] = useState("medium");
const [model, setModel] = useState("gemini-2.5-flash-preview-04-17");
// Default to a current, broadly capable model
const [model, setModel] = useState("gemini-2.5-flash");
const [language, setLanguage] = useState("English");
const handleInternalSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!internalInputValue.trim()) return;
onSubmit(internalInputValue, effort, model);
onSubmit(internalInputValue, effort, model, language);
setInternalInputValue("");
};
@@ -134,30 +149,61 @@ export const InputForm: React.FC<InputFormProps> = ({
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem
value="gemini-2.5-flash-lite"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-yellow-400" /> 2.5 Lite
</div>
</SelectItem>
<SelectItem
value="gemini-2.5-flash"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-orange-400" /> 2.5 Flash
</div>
</SelectItem>
<SelectItem
value="gemini-3-pro-preview"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 3 Pro Preview
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 bg-neutral-700 border-neutral-600 text-neutral-300 focus:ring-neutral-500 rounded-xl rounded-t-sm pl-2 max-w-[100%] sm:max-w-[90%]">
<div className="flex flex-row items-center text-sm ml-2">
<Languages className="h-4 w-4 mr-2" />
Language
</div>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem
value="gemini-2.0-flash"
value="English"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-yellow-400" /> 2.0 Flash
</div>
English
</SelectItem>
<SelectItem
value="gemini-2.5-flash-preview-04-17"
value="简体中文"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-orange-400" /> 2.5 Flash
</div>
</SelectItem>
<SelectItem
value="gemini-2.5-pro-preview-05-06"
value="日本語"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 2.5 Pro
</div>
</SelectItem>
</SelectContent>
</Select>

View File

@@ -4,7 +4,8 @@ interface WelcomeScreenProps {
handleSubmit: (
submittedInputValue: string,
effort: string,
model: string
model: string,
language: string
) => void;
onCancel: () => void;
isLoading: boolean;