Compare commits

..

1 Commits

Author SHA1 Message Date
Xin Wang
0e2dafe440 Update agent flow and frontend chat experience 2026-03-31 21:58:24 +08:00
14 changed files with 348 additions and 251 deletions

View File

@@ -1,6 +1,6 @@
# OpenAI-Compatible Fullstack LangGraph Quickstart # Gemini Fullstack LangGraph Quickstart
This project demonstrates a fullstack application using a React frontend and a LangGraph-powered backend agent. The agent performs comprehensive research on a user's query by dynamically generating search terms, querying the web with Tavily, reflecting on the results to identify knowledge gaps, and iteratively refining its search until it can provide a well-supported answer with citations. This application serves as an example of building research-augmented conversational AI using LangGraph and an OpenAI-compatible chat model. This project demonstrates a fullstack application using a React frontend and a LangGraph-powered backend agent. The agent is designed to perform comprehensive research on a user's query by dynamically generating search terms, querying the web using Google Search, reflecting on the results to identify knowledge gaps, and iteratively refining its search until it can provide a well-supported answer with citations. This application serves as an example of building research-augmented conversational AI using LangGraph and Google's Gemini models.
<img src="./app.png" title="Gemini Fullstack LangGraph" alt="Gemini Fullstack LangGraph" width="90%"> <img src="./app.png" title="Gemini Fullstack LangGraph" alt="Gemini Fullstack LangGraph" width="90%">
@@ -8,8 +8,8 @@ This project demonstrates a fullstack application using a React frontend and a L
- 💬 Fullstack application with a React frontend and LangGraph backend. - 💬 Fullstack application with a React frontend and LangGraph backend.
- 🧠 Powered by a LangGraph agent for advanced research and conversational AI. - 🧠 Powered by a LangGraph agent for advanced research and conversational AI.
- 🔍 Dynamic search query generation using an OpenAI-compatible chat model. - 🔍 Dynamic search query generation using Google Gemini models.
- 🌐 Integrated web research via Tavily Search. - 🌐 Integrated web research via Google Search API.
- 🤔 Reflective reasoning to identify knowledge gaps and refine searches. - 🤔 Reflective reasoning to identify knowledge gaps and refine searches.
- 📄 Generates answers with citations from gathered sources. - 📄 Generates answers with citations from gathered sources.
- 🔄 Hot-reloading for both frontend and backend during development. - 🔄 Hot-reloading for both frontend and backend during development.
@@ -29,15 +29,10 @@ Follow these steps to get the application running locally for development and te
- Node.js and npm (or yarn/pnpm) - Node.js and npm (or yarn/pnpm)
- Python 3.11+ - Python 3.11+
- **`OPENAI_API_KEY`**: The backend agent requires an OpenAI-compatible API key. - **`GEMINI_API_KEY`**: The backend agent requires a Google Gemini API key.
- **`OPENAI_BASE_URL`**: Set this to `https://open.bigmodel.cn/api/paas/v4` for BigModel.
- **`TAVILY_API_KEY`**: Required for web search.
1. Navigate to the `backend/` directory. 1. Navigate to the `backend/` directory.
2. Create a file named `.env` by copying the `backend/.env.example` file. 2. Create a file named `.env` by copying the `backend/.env.example` file.
3. Open the `.env` file and add: 3. Open the `.env` file and add your Gemini API key: `GEMINI_API_KEY="YOUR_ACTUAL_API_KEY"`
`OPENAI_API_KEY="YOUR_ACTUAL_API_KEY"`
`OPENAI_BASE_URL="https://open.bigmodel.cn/api/paas/v4"`
`TAVILY_API_KEY="YOUR_ACTUAL_TAVILY_API_KEY"`
**2. Install Dependencies:** **2. Install Dependencies:**
@@ -72,11 +67,11 @@ The core of the backend is a LangGraph agent defined in `backend/src/agent/graph
<img src="./agent.png" title="Agent Flow" alt="Agent Flow" width="50%"> <img src="./agent.png" title="Agent Flow" alt="Agent Flow" width="50%">
1. **Generate Initial Queries:** Based on your input, it generates a set of initial search queries using the configured chat model. 1. **Generate Initial Queries:** Based on your input, it generates a set of initial search queries using a Gemini model.
2. **Web Research:** For each query, it uses Tavily Search to gather relevant web pages and snippets. 2. **Web Research:** For each query, it uses the Gemini model with the Google Search API to find relevant web pages.
3. **Reflection & Knowledge Gap Analysis:** The agent analyzes the search results to determine if the information is sufficient or if there are knowledge gaps. It uses the configured chat model for this reflection process. 3. **Reflection & Knowledge Gap Analysis:** The agent analyzes the search results to determine if the information is sufficient or if there are knowledge gaps. It uses a Gemini model for this reflection process.
4. **Iterative Refinement:** If gaps are found or the information is insufficient, it generates follow-up queries and repeats the web research and reflection steps (up to a configured maximum number of loops). 4. **Iterative Refinement:** If gaps are found or the information is insufficient, it generates follow-up queries and repeats the web research and reflection steps (up to a configured maximum number of loops).
5. **Finalize Answer:** Once the research is deemed sufficient, the agent synthesizes the gathered information into a coherent answer, including citations from the web sources, using the configured chat model. 5. **Finalize Answer:** Once the research is deemed sufficient, the agent synthesizes the gathered information into a coherent answer, including citations from the web sources, using a Gemini model.
## CLI Example ## CLI Example
@@ -102,12 +97,12 @@ _Note: If you are not running the docker-compose.yml example or exposing the bac
Run the following command from the **project root directory**: Run the following command from the **project root directory**:
```bash ```bash
docker build -t openai-fullstack-langgraph -f Dockerfile . docker build -t gemini-fullstack-langgraph -f Dockerfile .
``` ```
**2. Run the Production Server:** **2. Run the Production Server:**
```bash ```bash
OPENAI_API_KEY=<your_api_key> OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4 TAVILY_API_KEY=<your_tavily_api_key> LANGSMITH_API_KEY=<your_langsmith_api_key> docker-compose up GEMINI_API_KEY=<your_gemini_api_key> LANGSMITH_API_KEY=<your_langsmith_api_key> docker-compose up
``` ```
Open your browser and navigate to `http://localhost:8123/app/` to see the application. The API will be available at `http://localhost:8123`. Open your browser and navigate to `http://localhost:8123/app/` to see the application. The API will be available at `http://localhost:8123`.
@@ -118,8 +113,7 @@ Open your browser and navigate to `http://localhost:8123/app/` to see the applic
- [Tailwind CSS](https://tailwindcss.com/) - For styling. - [Tailwind CSS](https://tailwindcss.com/) - For styling.
- [Shadcn UI](https://ui.shadcn.com/) - For components. - [Shadcn UI](https://ui.shadcn.com/) - For components.
- [LangGraph](https://github.com/langchain-ai/langgraph) - For building the backend research agent. - [LangGraph](https://github.com/langchain-ai/langgraph) - For building the backend research agent.
- OpenAI-compatible chat model - LLM for query generation, reflection, and answer synthesis. - [Google Gemini](https://ai.google.dev/models/gemini) - LLM for query generation, reflection, and answer synthesis.
- [Tavily](https://tavily.com/) - Web search for research retrieval.
## License ## License

View File

@@ -1 +1,3 @@
# GEMINI_API_KEY= # OPENAI_API_KEY=
# OPENAI_BASE_URL=
# TAVILY_API_KEY=

View File

@@ -21,7 +21,7 @@ def main() -> None:
) )
parser.add_argument( parser.add_argument(
"--reasoning-model", "--reasoning-model",
default="GLM-4.5-Air", default="openai/gpt-5.4",
help="Model for the final answer", help="Model for the final answer",
) )
args = parser.parse_args() args = parser.parse_args()

View File

@@ -9,26 +9,33 @@ class Configuration(BaseModel):
"""The configuration for the agent.""" """The configuration for the agent."""
query_generator_model: str = Field( query_generator_model: str = Field(
default="GLM-4.5-Air", default="openai/gpt-5.4",
metadata={ metadata={
"description": "The name of the language model to use for the agent's query generation." "description": "The name of the language model to use for the agent's query generation."
}, },
) )
reflection_model: str = Field( reflection_model: str = Field(
default="GLM-4.5-Air", default="openai/gpt-5.4",
metadata={ metadata={
"description": "The name of the language model to use for the agent's reflection." "description": "The name of the language model to use for the agent's reflection."
}, },
) )
answer_model: str = Field( answer_model: str = Field(
default="GLM-4.5-Air", default="openai/gpt-5.4",
metadata={ metadata={
"description": "The name of the language model to use for the agent's answer." "description": "The name of the language model to use for the agent's answer."
}, },
) )
reasoning_model: str = Field(
default="openai/gpt-5.4",
metadata={
"description": "Fallback model used when the client does not provide a supported reasoning model."
},
)
number_of_initial_queries: int = Field( number_of_initial_queries: int = Field(
default=3, default=3,
metadata={"description": "The number of initial search queries to generate."}, metadata={"description": "The number of initial search queries to generate."},

View File

@@ -1,93 +1,134 @@
import os import os
from typing import Any
from agent.tools_and_schemas import SearchQueryList, Reflection from agent.configuration import Configuration
from dotenv import load_dotenv from agent.prompts import (
from langchain_core.messages import AIMessage answer_instructions,
from langgraph.types import Send get_current_date,
from langgraph.graph import StateGraph query_writer_instructions,
from langgraph.graph import START, END reflection_instructions,
from langchain_core.runnables import RunnableConfig )
from langchain_openai import ChatOpenAI
from tavily import TavilyClient
from agent.state import ( from agent.state import (
OverallState, OverallState,
QueryGenerationState, QueryGenerationState,
ReflectionState, ReflectionState,
WebSearchState, WebSearchState,
) )
from agent.configuration import Configuration from agent.tools_and_schemas import Reflection, SearchQueryList
from agent.prompts import (
get_current_date,
query_writer_instructions,
web_searcher_instructions,
reflection_instructions,
answer_instructions,
)
from agent.utils import ( from agent.utils import (
deduplicate_and_format_sources, format_sources_for_prompt,
get_research_topic, get_research_topic,
normalize_model_name,
normalize_tavily_sources,
shorten_search_query,
) )
from dotenv import load_dotenv
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
from tavily import TavilyClient
load_dotenv() load_dotenv()
if os.getenv("OPENAI_API_KEY") is None: TAVILY_TOPIC = "general"
raise ValueError("OPENAI_API_KEY is not set") TAVILY_SEARCH_DEPTH = "advanced"
TAVILY_MAX_RESULTS = 5
if os.getenv("TAVILY_API_KEY") is None: TAVILY_CHUNKS_PER_SOURCE = 3
raise ValueError("TAVILY_API_KEY is not set")
tavily_client = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
def create_llm(model: str, temperature: float) -> ChatOpenAI: def require_env(name: str) -> str:
"""Create an OpenAI-compatible chat client.""" """Return an environment variable or raise a clear error."""
value = os.getenv(name)
if not value:
raise ValueError(f"{name} is not set")
return value
def get_chat_model(model_name: str, temperature: float = 0) -> ChatOpenAI:
"""Create the OpenAI chat model lazily for easier testing."""
base_url = os.getenv("OPENAI_BASE_URL")
return ChatOpenAI( return ChatOpenAI(
model=model, model=normalize_model_name(model_name),
api_key=require_env("OPENAI_API_KEY"),
base_url=base_url or None,
temperature=temperature, temperature=temperature,
max_retries=2,
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4"),
) )
# Nodes def get_tavily_client() -> TavilyClient:
"""Create the Tavily client lazily for easier testing."""
return TavilyClient(api_key=require_env("TAVILY_API_KEY"))
def create_structured_response(
prompt: str,
model_name: str,
schema_model: type[Any],
temperature: float = 1,
) -> Any:
"""Call the LangChain OpenAI chat model and parse structured output."""
llm = get_chat_model(model_name, temperature=temperature)
return llm.with_structured_output(
schema_model,
method="function_calling",
).invoke(prompt)
def extract_text_content(content: Any) -> str:
"""Normalize LangChain message content into a string."""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
text_chunks = []
for chunk in content:
if isinstance(chunk, dict) and chunk.get("type") == "text":
text_chunks.append(str(chunk.get("text", "")))
return "\n".join(text_chunks).strip()
return str(content).strip()
def create_text_response(prompt: str, model_name: str, temperature: float = 0) -> str:
"""Call the LangChain OpenAI chat model and return markdown output."""
response = get_chat_model(model_name, temperature=temperature).invoke(prompt)
text_output = extract_text_content(response.content)
if not text_output:
raise ValueError("OpenAI response did not include text output")
return text_output
def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState: def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
"""LangGraph node that generates search queries based on the User's question. """Generate initial search queries from the user's request."""
Uses the configured chat model to create optimized search queries for web research.
Args:
state: Current graph state containing the User's question
config: Configuration for the runnable, including LLM provider settings
Returns:
Dictionary with state update, including search_query key containing the generated queries
"""
configurable = Configuration.from_runnable_config(config) configurable = Configuration.from_runnable_config(config)
reasoning_model = normalize_model_name(
state.get("reasoning_model"),
configurable.reasoning_model or configurable.query_generator_model,
)
# Check for custom initial search query count.
if state.get("initial_search_query_count") is None: if state.get("initial_search_query_count") is None:
state["initial_search_query_count"] = configurable.number_of_initial_queries state["initial_search_query_count"] = configurable.number_of_initial_queries
llm = create_llm(configurable.query_generator_model, temperature=1.0)
structured_llm = llm.with_structured_output(SearchQueryList)
current_date = get_current_date()
formatted_prompt = query_writer_instructions.format( formatted_prompt = query_writer_instructions.format(
current_date=current_date, current_date=get_current_date(),
research_topic=get_research_topic(state["messages"]), research_topic=get_research_topic(state["messages"]),
number_queries=state["initial_search_query_count"], number_queries=state["initial_search_query_count"],
) )
result = structured_llm.invoke(formatted_prompt) result = create_structured_response(
formatted_prompt,
reasoning_model,
SearchQueryList,
temperature=1,
)
return {"search_query": result.query} return {"search_query": result.query}
def continue_to_web_research(state: QueryGenerationState): def continue_to_web_research(state: QueryGenerationState) -> list[Send]:
"""LangGraph node that sends the search queries to the web research node. """Fan out search queries into parallel web research nodes."""
This is used to spawn n number of web research nodes, one for each search query.
"""
return [ return [
Send("web_research", {"search_query": search_query, "id": int(idx)}) Send("web_research", {"search_query": search_query, "id": int(idx)})
for idx, search_query in enumerate(state["search_query"]) for idx, search_query in enumerate(state["search_query"])
@@ -95,65 +136,63 @@ def continue_to_web_research(state: QueryGenerationState):
def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState: def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
"""LangGraph node that performs web research using Tavily search.""" """Execute Tavily search and return raw evidence for one query."""
Configuration.from_runnable_config(config) search_query = shorten_search_query(state["search_query"])
formatted_prompt = web_searcher_instructions.format( tavily_response = get_tavily_client().search(
current_date=get_current_date(), query=search_query,
research_topic=state["search_query"], topic=TAVILY_TOPIC,
) search_depth=TAVILY_SEARCH_DEPTH,
search_response = tavily_client.search( chunks_per_source=TAVILY_CHUNKS_PER_SOURCE,
query=state["search_query"], max_results=TAVILY_MAX_RESULTS,
topic="general",
search_depth="advanced",
max_results=5,
include_answer=False, include_answer=False,
include_raw_content=False, include_raw_content=False,
) )
sources_gathered, formatted_results = deduplicate_and_format_sources( sources = normalize_tavily_sources(tavily_response.get("results", []))
search_response.get("results", []), int(state["id"])
)
web_research_result = ( if not sources:
f"{formatted_prompt}\n\n" return {
f"Search query: {state['search_query']}\n\n" "sources_gathered": [],
f"Findings:\n{formatted_results}" "search_query": [search_query],
"web_research_result": [
f'No Tavily results were returned for "{search_query}".'
],
}
evidence = "\n\n".join(
[
f"Search Query: {search_query}",
"Source Evidence:",
format_sources_for_prompt(sources),
]
) )
return { return {
"sources_gathered": sources_gathered, "sources_gathered": sources,
"search_query": [state["search_query"]], "search_query": [search_query],
"web_research_result": [web_research_result], "web_research_result": [evidence],
} }
def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState: def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
"""LangGraph node that identifies knowledge gaps and generates potential follow-up queries. """Decide whether additional research is needed."""
Analyzes the current summary to identify areas for further research and generates
potential follow-up queries. Uses structured output to extract
the follow-up query in JSON format.
Args:
state: Current graph state containing the running summary and research topic
config: Configuration for the runnable, including LLM provider settings
Returns:
Dictionary with state update, including search_query key containing the generated follow-up query
"""
configurable = Configuration.from_runnable_config(config) configurable = Configuration.from_runnable_config(config)
# Increment the research loop count and get the reasoning model
state["research_loop_count"] = state.get("research_loop_count", 0) + 1 state["research_loop_count"] = state.get("research_loop_count", 0) + 1
reasoning_model = state.get("reasoning_model", configurable.reflection_model) reasoning_model = normalize_model_name(
state.get("reasoning_model"),
configurable.reasoning_model,
)
# Format the prompt
current_date = get_current_date()
formatted_prompt = reflection_instructions.format( formatted_prompt = reflection_instructions.format(
current_date=current_date, current_date=get_current_date(),
research_topic=get_research_topic(state["messages"]), research_topic=get_research_topic(state["messages"]),
summaries="\n\n---\n\n".join(state["web_research_result"]), summaries="\n\n---\n\n".join(state["web_research_result"]),
) )
llm = create_llm(reasoning_model, temperature=1.0) result = create_structured_response(
result = llm.with_structured_output(Reflection).invoke(formatted_prompt) formatted_prompt,
reasoning_model,
Reflection,
temperature=1,
)
return { return {
"is_sufficient": result.is_sufficient, "is_sufficient": result.is_sufficient,
@@ -167,19 +206,8 @@ def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
def evaluate_research( def evaluate_research(
state: ReflectionState, state: ReflectionState,
config: RunnableConfig, config: RunnableConfig,
) -> OverallState: ) -> OverallState | list[Send]:
"""LangGraph routing function that determines the next step in the research flow. """Route either back into research or into final answer synthesis."""
Controls the research loop by deciding whether to continue gathering information
or to finalize the summary based on the configured maximum number of research loops.
Args:
state: Current graph state containing the research loop count
config: Configuration for the runnable, including max_research_loops setting
Returns:
String literal indicating the next node to visit ("web_research" or "finalize_summary")
"""
configurable = Configuration.from_runnable_config(config) configurable = Configuration.from_runnable_config(config)
max_research_loops = ( max_research_loops = (
state.get("max_research_loops") state.get("max_research_loops")
@@ -188,7 +216,7 @@ def evaluate_research(
) )
if state["is_sufficient"] or state["research_loop_count"] >= max_research_loops: if state["is_sufficient"] or state["research_loop_count"] >= max_research_loops:
return "finalize_answer" return "finalize_answer"
else:
return [ return [
Send( Send(
"web_research", "web_research",
@@ -201,71 +229,40 @@ def evaluate_research(
] ]
def finalize_answer(state: OverallState, config: RunnableConfig): def finalize_answer(state: OverallState, config: RunnableConfig) -> OverallState:
"""LangGraph node that finalizes the research summary. """Generate the final cited answer from the accumulated research summaries."""
Prepares the final output by deduplicating and formatting sources, then
combining them with the running summary to create a well-structured
research report with proper citations.
Args:
state: Current graph state containing the running summary and sources gathered
Returns:
Dictionary with state update, including running_summary key containing the formatted final summary with sources
"""
configurable = Configuration.from_runnable_config(config) configurable = Configuration.from_runnable_config(config)
reasoning_model = state.get("reasoning_model") or configurable.answer_model reasoning_model = normalize_model_name(
state.get("reasoning_model"),
configurable.reasoning_model or configurable.answer_model,
)
# Format the prompt
current_date = get_current_date()
formatted_prompt = answer_instructions.format( formatted_prompt = answer_instructions.format(
current_date=current_date, current_date=get_current_date(),
research_topic=get_research_topic(state["messages"]), research_topic=get_research_topic(state["messages"]),
summaries="\n---\n\n".join(state["web_research_result"]), summaries="\n---\n\n".join(state["web_research_result"]),
) )
answer = create_text_response(formatted_prompt, reasoning_model, temperature=0)
llm = create_llm(reasoning_model, temperature=0)
result = llm.invoke(formatted_prompt)
# Replace the short urls with the original urls and add all used urls to the sources_gathered
unique_sources = []
for source in state["sources_gathered"]:
if source["short_url"] in result.content:
result.content = result.content.replace(
source["short_url"], source["value"]
)
unique_sources.append(source)
return { return {
"messages": [AIMessage(content=result.content)], "messages": [AIMessage(content=answer)],
"sources_gathered": unique_sources, "sources_gathered": state.get("sources_gathered", []),
} }
# Create our Agent Graph
builder = StateGraph(OverallState, config_schema=Configuration) builder = StateGraph(OverallState, config_schema=Configuration)
# Define the nodes we will cycle between
builder.add_node("generate_query", generate_query) builder.add_node("generate_query", generate_query)
builder.add_node("web_research", web_research) builder.add_node("web_research", web_research)
builder.add_node("reflection", reflection) builder.add_node("reflection", reflection)
builder.add_node("finalize_answer", finalize_answer) builder.add_node("finalize_answer", finalize_answer)
# Set the entrypoint as `generate_query`
# This means that this node is the first one called
builder.add_edge(START, "generate_query") builder.add_edge(START, "generate_query")
# Add conditional edge to continue with search queries in a parallel branch
builder.add_conditional_edges( builder.add_conditional_edges(
"generate_query", continue_to_web_research, ["web_research"] "generate_query", continue_to_web_research, ["web_research"]
) )
# Reflect on the web research
builder.add_edge("web_research", "reflection") builder.add_edge("web_research", "reflection")
# Evaluate the research
builder.add_conditional_edges( builder.add_conditional_edges(
"reflection", evaluate_research, ["web_research", "finalize_answer"] "reflection", evaluate_research, ["web_research", "finalize_answer"]
) )
# Finalize the answer
builder.add_edge("finalize_answer", END) builder.add_edge("finalize_answer", END)
graph = builder.compile(name="pro-search-agent") graph = builder.compile(name="pro-search-agent")

View File

@@ -15,6 +15,7 @@ Instructions:
- Queries should be diverse, if the topic is broad, generate more than 1 query. - Queries should be diverse, if the topic is broad, generate more than 1 query.
- Don't generate multiple similar queries, 1 is enough. - 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}. - Query should ensure that the most current information is gathered. The current date is {current_date}.
- Keep every individual query under 400 characters. Prefer concise search terms over full-sentence restatements.
Format: Format:
- Format your response as a JSON object with ALL two of these exact keys: - Format your response as a JSON object with ALL two of these exact keys:
@@ -34,26 +35,31 @@ Topic: What revenue grew more last year apple stock or the number of people buyi
Context: {research_topic}""" Context: {research_topic}"""
web_searcher_instructions = """Conduct targeted web research to gather the most recent, credible information on "{research_topic}" and synthesize it into a verifiable text artifact. web_searcher_instructions = """Review the provided Tavily search results for "{research_topic}" and synthesize them into a verifiable research note.
Instructions: Instructions:
- Query should ensure that the most current information is gathered. The current date is {current_date}. - Query should ensure that the most current information is gathered. The current date is {current_date}.
- Review the returned web search results and extract the strongest, most relevant findings. - Use only the provided Tavily search results. Do not invent or infer facts that are not supported by those results.
- Consolidate key findings while meticulously tracking the source(s) for each specific piece of information. - Consolidate the key findings into a concise research note for this query.
- The output should be a well-written summary or report based on your search findings. - Every factual paragraph or bullet must include at least one markdown citation using the exact title and URL from the provided sources, for example: [Reuters](https://www.reuters.com/example).
- Only include the information found in the search results, don't make up any information. - Preserve full URLs in citations. Do not use placeholders, IDs, or shortened URLs.
- If the results are insufficient, explicitly say what is missing and cite the closest available sources.
Research Topic: Research Topic:
{research_topic} {research_topic}
Search Results:
{search_results}
""" """
reflection_instructions = """You are an expert research assistant analyzing summaries about "{research_topic}". reflection_instructions = """You are an expert research assistant analyzing collected research evidence about "{research_topic}".
Instructions: Instructions:
- Identify knowledge gaps or areas that need deeper exploration and generate a follow-up query. (1 or multiple). - Identify knowledge gaps or areas that need deeper exploration and generate a follow-up query. (1 or multiple).
- If provided summaries are sufficient to answer the user's question, don't generate a follow-up query. - If the provided evidence is 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. - 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. - Focus on technical details, implementation specifics, or emerging trends that weren't fully covered.
- Keep every follow-up query under 400 characters. Prefer compact search phrases over long natural-language questions.
Requirements: Requirements:
- Ensure the follow-up query is self-contained and includes necessary context for web search. - Ensure the follow-up query is self-contained and includes necessary context for web search.
@@ -73,24 +79,26 @@ Example:
}} }}
``` ```
Reflect carefully on the Summaries to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format: Reflect carefully on the Research Evidence to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format:
Summaries: Research Evidence:
{summaries} {summaries}
""" """
answer_instructions = """Generate a high-quality answer to the user's question based on the provided summaries. answer_instructions = """Generate a high-quality answer to the user's question based on the provided research evidence.
Instructions: Instructions:
- The current date is {current_date}. - The current date is {current_date}.
- You are the final step of a multi-step research process, don't mention that you are the final step. - You are the final step of a multi-step research process, don't mention that you are the final step.
- You have access to all the information gathered from the previous steps. - You have access to all the information gathered from the previous steps.
- You have access to the user's question. - You have access to the user's question.
- Generate a high-quality answer to the user's question based on the provided summaries and the user's question. - Generate a high-quality answer to the user's question based on the provided research evidence and the user's question.
- Include the sources you used from the Summaries in the answer correctly, using markdown links. THIS IS A MUST. - Use only the evidence present in the Research Evidence.
- Preserve or reuse source citations from the Research Evidence as full markdown links. Every factual paragraph should include at least one citation.
- Do not invent sources, placeholders, or shortened URLs.
User Context: User Context:
- {research_topic} - {research_topic}
Summaries: Research Evidence:
{summaries}""" {summaries}"""

View File

@@ -29,13 +29,8 @@ class ReflectionState(TypedDict):
number_of_ran_queries: int number_of_ran_queries: int
class Query(TypedDict):
query: str
rationale: str
class QueryGenerationState(TypedDict): class QueryGenerationState(TypedDict):
search_query: list[Query] search_query: list[str]
class WebSearchState(TypedDict): class WebSearchState(TypedDict):

View File

@@ -1,9 +1,16 @@
from typing import Any from __future__ import annotations
import re
from typing import Any, List
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
DEFAULT_REASONING_MODEL = "openai/gpt-5.4"
SUPPORTED_MODEL_PREFIXES = ("gpt-", "deepseek-", "glm-")
MAX_TAVILY_QUERY_LENGTH = 380
def get_research_topic(messages: list[AnyMessage]) -> str:
def get_research_topic(messages: List[AnyMessage]) -> str:
"""Get the research topic from the messages.""" """Get the research topic from the messages."""
if len(messages) == 1: if len(messages) == 1:
return str(messages[-1].content) return str(messages[-1].content)
@@ -17,43 +24,83 @@ def get_research_topic(messages: list[AnyMessage]) -> str:
return research_topic return research_topic
def deduplicate_and_format_sources( def normalize_model_name(
results: list[dict[str, Any]], search_id: int model_name: str | None,
) -> tuple[list[dict[str, str]], str]: fallback: str = DEFAULT_REASONING_MODEL,
"""Convert Tavily results into source metadata and a markdown research summary.""" ) -> str:
sources: list[dict[str, str]] = [] """Normalize stale or unsupported model names to a supported default."""
formatted_sections: list[str] = [] candidate = (model_name or "").strip()
if candidate.startswith(SUPPORTED_MODEL_PREFIXES):
return candidate
provider, _, model = candidate.partition("/")
if provider and model.startswith(SUPPORTED_MODEL_PREFIXES):
return candidate
return fallback
def _normalize_source_title(source: dict[str, Any], index: int) -> str:
title = (source.get("title") or "").strip()
return title or f"Source {index}"
def normalize_tavily_sources(results: list[dict[str, Any]]) -> list[dict[str, str]]:
"""Normalize Tavily results into a stable source shape."""
normalized_sources: list[dict[str, str]] = []
seen_urls: set[str] = set() seen_urls: set[str] = set()
for idx, result in enumerate(results): for index, result in enumerate(results, start=1):
url = result.get("url") url = (result.get("url") or "").strip()
if not url or url in seen_urls: if not url or url in seen_urls:
continue continue
seen_urls.add(url) normalized_sources.append(
title = result.get("title") or f"Source {idx + 1}"
short_url = f"https://search.local/{search_id}-{idx}"
content = (result.get("content") or "").strip()
sources.append(
{ {
"label": title, "label": f"S{len(normalized_sources) + 1}",
"short_url": short_url, "title": _normalize_source_title(result, index),
"value": url, "value": url,
"content": (result.get("content") or "").strip(),
} }
) )
seen_urls.add(url)
formatted_sections.append( return normalized_sources
def format_sources_for_prompt(sources: list[dict[str, str]]) -> str:
"""Format normalized search results for prompt injection."""
if not sources:
return "No search results were returned."
formatted_sources = []
for source in sources:
snippet = source.get("content") or "No snippet available."
formatted_sources.append(
"\n".join( "\n".join(
[ [
f"- {title} [{title}]({short_url})", f"{source['label']}: {source['title']}",
f" URL: {url}", f"URL: {source['value']}",
f" Snippet: {content or 'No snippet returned.'}", f"Snippet: {snippet}",
] ]
) )
) )
if not formatted_sections: return "\n\n".join(formatted_sources)
formatted_sections.append("- No Tavily results were returned for this query.")
return sources, "\n".join(formatted_sections)
def shorten_search_query(
query: str,
max_length: int = MAX_TAVILY_QUERY_LENGTH,
) -> str:
"""Normalize and trim search queries to fit Tavily's length limit."""
normalized_query = re.sub(r"\s+", " ", query).strip()
if len(normalized_query) <= max_length:
return normalized_query
truncated_query = normalized_query[:max_length]
last_space = truncated_query.rfind(" ")
if last_space > max_length // 2:
truncated_query = truncated_query[:last_space]
return truncated_query.rstrip(" ,;:-")

View File

@@ -28,7 +28,7 @@ services:
retries: 5 retries: 5
interval: 5s interval: 5s
langgraph-api: langgraph-api:
image: openai-fullstack-langgraph image: gemini-fullstack-langgraph
container_name: langgraph-api container_name: langgraph-api
ports: ports:
- "8123:8000" - "8123:8000"
@@ -39,7 +39,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://open.bigmodel.cn/api/paas/v4} OPENAI_BASE_URL: ${OPENAI_BASE_URL}
TAVILY_API_KEY: ${TAVILY_API_KEY} TAVILY_API_KEY: ${TAVILY_API_KEY}
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY} LANGSMITH_API_KEY: ${LANGSMITH_API_KEY}
REDIS_URI: redis://langgraph-redis:6379 REDIS_URI: redis://langgraph-redis:6379

View File

@@ -2259,7 +2259,6 @@
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2269,7 +2268,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -2280,7 +2278,6 @@
"integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@@ -2339,7 +2336,6 @@
"integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.31.1", "@typescript-eslint/scope-manager": "8.31.1",
"@typescript-eslint/types": "8.31.1", "@typescript-eslint/types": "8.31.1",
@@ -2535,7 +2531,6 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3003,7 +2998,6 @@
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4885,7 +4879,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -4895,7 +4888,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -5353,7 +5345,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5448,7 +5439,6 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5673,7 +5663,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -5762,7 +5751,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5814,7 +5802,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -6,10 +6,27 @@ import { WelcomeScreen } from "@/components/WelcomeScreen";
import { ChatMessagesView } from "@/components/ChatMessagesView"; import { ChatMessagesView } from "@/components/ChatMessagesView";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const getSourceDisplayName = (source: any): string | null => {
if (source?.title) {
return source.title;
}
if (source?.value) {
try {
return new URL(source.value).hostname.replace(/^www\./, "");
} catch {
return null;
}
}
return source?.label || null;
};
export default function App() { export default function App() {
const [processedEventsTimeline, setProcessedEventsTimeline] = useState< const [processedEventsTimeline, setProcessedEventsTimeline] = useState<
ProcessedEvent[] ProcessedEvent[]
>([]); >([]);
const [selectedModel, setSelectedModel] = useState("openai/gpt-5.4");
const [historicalActivities, setHistoricalActivities] = useState< const [historicalActivities, setHistoricalActivities] = useState<
Record<string, ProcessedEvent[]> Record<string, ProcessedEvent[]>
>({}); >({});
@@ -38,7 +55,7 @@ export default function App() {
const sources = event.web_research.sources_gathered || []; const sources = event.web_research.sources_gathered || [];
const numSources = sources.length; const numSources = sources.length;
const uniqueLabels = [ const uniqueLabels = [
...new Set(sources.map((s: any) => s.label).filter(Boolean)), ...new Set(sources.map(getSourceDisplayName).filter(Boolean)),
]; ];
const exampleLabels = uniqueLabels.slice(0, 3).join(", "); const exampleLabels = uniqueLabels.slice(0, 3).join(", ");
processedEvent = { processedEvent = {
@@ -157,6 +174,8 @@ export default function App() {
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isLoading={thread.isLoading} isLoading={thread.isLoading}
onCancel={handleCancel} onCancel={handleCancel}
model={selectedModel}
onModelChange={setSelectedModel}
/> />
) : error ? ( ) : error ? (
<div className="flex flex-col items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full">
@@ -181,6 +200,8 @@ export default function App() {
onCancel={handleCancel} onCancel={handleCancel}
liveActivityEvents={processedEventsTimeline} liveActivityEvents={processedEventsTimeline}
historicalActivities={historicalActivities} historicalActivities={historicalActivities}
model={selectedModel}
onModelChange={setSelectedModel}
/> />
)} )}
</main> </main>

View File

@@ -230,6 +230,8 @@ interface ChatMessagesViewProps {
onCancel: () => void; onCancel: () => void;
liveActivityEvents: ProcessedEvent[]; liveActivityEvents: ProcessedEvent[];
historicalActivities: Record<string, ProcessedEvent[]>; historicalActivities: Record<string, ProcessedEvent[]>;
model: string;
onModelChange: (model: string) => void;
} }
export function ChatMessagesView({ export function ChatMessagesView({
@@ -240,6 +242,8 @@ export function ChatMessagesView({
onCancel, onCancel,
liveActivityEvents, liveActivityEvents,
historicalActivities, historicalActivities,
model,
onModelChange,
}: ChatMessagesViewProps) { }: ChatMessagesViewProps) {
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
@@ -316,6 +320,8 @@ export function ChatMessagesView({
isLoading={isLoading} isLoading={isLoading}
onCancel={onCancel} onCancel={onCancel}
hasHistory={messages.length > 0} hasHistory={messages.length > 0}
model={model}
onModelChange={onModelChange}
/> />
</div> </div>
); );

View File

@@ -16,6 +16,8 @@ interface InputFormProps {
onCancel: () => void; onCancel: () => void;
isLoading: boolean; isLoading: boolean;
hasHistory: boolean; hasHistory: boolean;
model: string;
onModelChange: (model: string) => void;
} }
export const InputForm: React.FC<InputFormProps> = ({ export const InputForm: React.FC<InputFormProps> = ({
@@ -23,10 +25,11 @@ export const InputForm: React.FC<InputFormProps> = ({
onCancel, onCancel,
isLoading, isLoading,
hasHistory, hasHistory,
model,
onModelChange,
}) => { }) => {
const [internalInputValue, setInternalInputValue] = useState(""); const [internalInputValue, setInternalInputValue] = useState("");
const [effort, setEffort] = useState("medium"); const [effort, setEffort] = useState("medium");
const [model, setModel] = useState("GLM-4.5-Air");
const handleInternalSubmit = (e?: React.FormEvent) => { const handleInternalSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
@@ -130,17 +133,41 @@ export const InputForm: React.FC<InputFormProps> = ({
<Cpu className="h-4 w-4 mr-2" /> <Cpu className="h-4 w-4 mr-2" />
Model Model
</div> </div>
<Select value={model} onValueChange={setModel}> <Select value={model} onValueChange={onModelChange}>
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer"> <SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
<SelectValue placeholder="Model" /> <SelectValue placeholder="Model" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer"> <SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem <SelectItem
value="GLM-4.5-Air" value="openai/gpt-5.4"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer" className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
> >
<div className="flex items-center"> <div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-cyan-400" /> GLM-4.5-Air <Zap className="h-4 w-4 mr-2 text-emerald-400" /> openai/gpt-5.4
</div>
</SelectItem>
<SelectItem
value="deepseek-v3"
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-cyan-400" /> deepseek-v3
</div>
</SelectItem>
<SelectItem
value="glm-4.5-air"
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-sky-400" /> glm-4.5-air
</div>
</SelectItem>
<SelectItem
value="glm-4.7"
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-violet-400" /> glm-4.7
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>

View File

@@ -8,12 +8,16 @@ interface WelcomeScreenProps {
) => void; ) => void;
onCancel: () => void; onCancel: () => void;
isLoading: boolean; isLoading: boolean;
model: string;
onModelChange: (model: string) => void;
} }
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
handleSubmit, handleSubmit,
onCancel, onCancel,
isLoading, isLoading,
model,
onModelChange,
}) => ( }) => (
<div className="h-full flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4"> <div className="h-full flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4">
<div> <div>
@@ -30,10 +34,12 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
isLoading={isLoading} isLoading={isLoading}
onCancel={onCancel} onCancel={onCancel}
hasHistory={false} hasHistory={false}
model={model}
onModelChange={onModelChange}
/> />
</div> </div>
<p className="text-xs text-neutral-500"> <p className="text-xs text-neutral-500">
Powered by GLM-4.5-Air, Tavily, and LangChain LangGraph. Powered by Google Gemini and LangChain LangGraph.
</p> </p>
</div> </div>
); );