Compare commits

..

21 Commits

Author SHA1 Message Date
Xin Wang
00ef163767 Update project files 2026-03-30 23:44:48 +08:00
philschmid
e34e569de4 updates to prompt 2025-06-18 15:59:01 +02:00
Philipp Schmid
f586b75b46 Merge pull request #45 from smell-of-curry/patch-1
Update README.md
2025-06-18 15:40:07 +02:00
Philipp Schmid
35d7f027df Merge branch 'main' into patch-1 2025-06-18 15:40:00 +02:00
Philipp Schmid
d9c44fe764 Merge pull request #43 from nisaharan/main
Add cli example
2025-06-18 15:39:25 +02:00
Philipp Schmid
23f6aa51a9 Merge pull request #35 from dkqjrm/chore/typo
fix(docs): Correct typos and grammatical inconsistencies in docstrings/prompts
2025-06-18 15:38:29 +02:00
Philipp Schmid
3214a7e1d7 Merge pull request #17 from kahirokunn/fix/ime-input-form-submission
Fix IME input issues by implementing Ctrl/Cmd+Enter submission
2025-06-18 15:35:56 +02:00
Philipp Schmid
d3fd999cb6 Merge pull request #7 from CharlesCNorton/patch-1
Update README.md
2025-06-18 15:30:36 +02:00
Philipp Schmid
972392d3f1 Merge pull request #5 from LeaderOnePro/main
Update the Vite configuration and correct the path resolution to support file URLs.
2025-06-18 15:27:18 +02:00
Philipp Schmid
7f9597656d Merge pull request #76 from tigermlt/patch-1
Remove duplicate imports in state.py
2025-06-18 15:06:45 +02:00
tigermlt
1c4d5a7c93 Remove duplicate imports in state.py 2025-06-09 21:28:08 -07:00
Smell of curry
26c0b47b6c Update README.md 2025-06-05 12:26:17 -04:00
Nisaharan Genhatharan
211c23f826 Merge pull request #1 from nisaharan/Research_agent/cli_research
Add CLI example for agent
2025-06-05 09:56:25 -04:00
Nisaharan Genhatharan
3bf5d97bc7 Add CLI example 2025-06-05 09:54:39 -04:00
Hyun-Sik Won
3da4c4e412 fix(docs): Correct typos and grammatical inconsistencies 2025-06-05 16:35:35 +09:00
kahirokunn
b0dd02b92e fix: improve IME compatibility by changing form submission to Ctrl/Cmd+Enter
- Remove automatic form submission on Enter key to prevent conflicts with IME
- Add Ctrl+Enter (Windows/Linux) and Cmd+Enter (Mac) as submission shortcuts
- Preserve native textarea behavior for line breaks with Shift+Enter
- Fix issue where Japanese/Chinese/Korean input confirmation triggered form submission
2025-06-04 18:04:35 +09:00
LeaderOnePro
70c348c241 Merge branch 'main' of github.com:LeaderOnePro/deepresearch-fullstack-langgraph-quickstart 2025-06-04 13:24:35 +08:00
LeaderOnePro
c429cb2e0c Update the Vite configuration to correct the path resolution to use __dirname 2025-06-04 13:24:29 +08:00
LeaderOnePro
8dde8c6cc2 Merge branch 'google-gemini:main' into main 2025-06-04 13:22:28 +08:00
CharlesCNorton
e5386031c5 Update README.md
* Remove redundant phrasing “development during development”
* Add missing auxiliary verb in sentence about updating `apiUrl`
* Insert missing preposition in same `apiUrl` sentence (“file to your host”)
2025-06-03 11:14:13 -04:00
LeaderOnePro
aa4fb9dd51 更新 Vite 配置,修正路径解析以支持文件 URL 2025-06-03 12:21:31 +08:00
13 changed files with 201 additions and 273 deletions

View File

@@ -1,6 +1,6 @@
# Gemini Fullstack LangGraph Quickstart
# OpenAI-Compatible Fullstack LangGraph Quickstart
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.
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.
<img src="./app.png" title="Gemini Fullstack LangGraph" alt="Gemini Fullstack LangGraph" width="90%">
@@ -8,11 +8,11 @@ This project demonstrates a fullstack application using a React frontend and a L
- 💬 Fullstack application with a React frontend and LangGraph backend.
- 🧠 Powered by a LangGraph agent for advanced research and conversational AI.
- 🔍 Dynamic search query generation using Google Gemini models.
- 🌐 Integrated web research via Google Search API.
- 🔍 Dynamic search query generation using an OpenAI-compatible chat model.
- 🌐 Integrated web research via Tavily Search.
- 🤔 Reflective reasoning to identify knowledge gaps and refine searches.
- 📄 Generates answers with citations from gathered sources.
- 🔄 Hot-reloading for both frontend and backend development during development.
- 🔄 Hot-reloading for both frontend and backend during development.
## Project Structure
@@ -29,10 +29,15 @@ Follow these steps to get the application running locally for development and te
- Node.js and npm (or yarn/pnpm)
- Python 3.11+
- **`GEMINI_API_KEY`**: The backend agent requires a Google Gemini API key.
- **`OPENAI_API_KEY`**: The backend agent requires an OpenAI-compatible 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.
2. Create a file named `.env` by copying the `backend/.env.example` file.
3. Open the `.env` file and add your Gemini API key: `GEMINI_API_KEY="YOUR_ACTUAL_API_KEY"`
3. Open the `.env` file and add:
`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:**
@@ -67,11 +72,23 @@ 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%">
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 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 a Gemini model for this reflection process.
1. **Generate Initial Queries:** Based on your input, it generates a set of initial search queries using the configured chat model.
2. **Web Research:** For each query, it uses Tavily Search to gather relevant web pages and snippets.
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.
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 a Gemini 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 the configured chat model.
## CLI Example
For quick one-off questions you can execute the agent from the command line. The
script `backend/examples/cli_research.py` runs the LangGraph agent and prints the
final answer:
```bash
cd backend
python examples/cli_research.py "What are the latest trends in renewable energy?"
```
## Deployment
@@ -79,18 +96,18 @@ In production, the backend server serves the optimized static frontend build. La
_Note: For the docker-compose.yml example you need a LangSmith API key, you can get one from [LangSmith](https://smith.langchain.com/settings)._
_Note: If you are not running the docker-compose.yml example or exposing the backend server to the public internet, you update the `apiUrl` in the `frontend/src/App.tsx` file your host. Currently the `apiUrl` is set to `http://localhost:8123` for docker-compose or `http://localhost:2024` for development._
_Note: If you are not running the docker-compose.yml example or exposing the backend server to the public internet, you should update the `apiUrl` in the `frontend/src/App.tsx` file to your host. Currently the `apiUrl` is set to `http://localhost:8123` for docker-compose or `http://localhost:2024` for development._
**1. Build the Docker Image:**
Run the following command from the **project root directory**:
```bash
docker build -t gemini-fullstack-langgraph -f Dockerfile .
docker build -t openai-fullstack-langgraph -f Dockerfile .
```
**2. Run the Production Server:**
```bash
GEMINI_API_KEY=<your_gemini_api_key> LANGSMITH_API_KEY=<your_langsmith_api_key> docker-compose up
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
```
Open your browser and navigate to `http://localhost:8123/app/` to see the application. The API will be available at `http://localhost:8123`.
@@ -101,7 +118,8 @@ Open your browser and navigate to `http://localhost:8123/app/` to see the applic
- [Tailwind CSS](https://tailwindcss.com/) - For styling.
- [Shadcn UI](https://ui.shadcn.com/) - For components.
- [LangGraph](https://github.com/langchain-ai/langgraph) - For building the backend research agent.
- [Google Gemini](https://ai.google.dev/models/gemini) - LLM for query generation, reflection, and answer synthesis.
- OpenAI-compatible chat model - LLM for query generation, reflection, and answer synthesis.
- [Tavily](https://tavily.com/) - Web search for research retrieval.
## License

View File

@@ -0,0 +1,43 @@
import argparse
from langchain_core.messages import HumanMessage
from agent.graph import graph
def main() -> None:
"""Run the research agent from the command line."""
parser = argparse.ArgumentParser(description="Run the LangGraph research agent")
parser.add_argument("question", help="Research question")
parser.add_argument(
"--initial-queries",
type=int,
default=3,
help="Number of initial search queries",
)
parser.add_argument(
"--max-loops",
type=int,
default=2,
help="Maximum number of research loops",
)
parser.add_argument(
"--reasoning-model",
default="GLM-4.5-Air",
help="Model for the final answer",
)
args = parser.parse_args()
state = {
"messages": [HumanMessage(content=args.question)],
"initial_search_query_count": args.initial_queries,
"max_research_loops": args.max_loops,
"reasoning_model": args.reasoning_model,
}
result = graph.invoke(state)
messages = result.get("messages", [])
if messages:
print(messages[-1].content)
if __name__ == "__main__":
main()

View File

@@ -11,13 +11,13 @@ requires-python = ">=3.11,<4.0"
dependencies = [
"langgraph>=0.2.6",
"langchain>=0.3.19",
"langchain-google-genai",
"langchain-openai",
"python-dotenv>=1.0.1",
"langgraph-sdk>=0.1.57",
"langgraph-cli",
"langgraph-api",
"fastapi",
"google-genai",
"tavily-python",
]

View File

@@ -9,21 +9,21 @@ class Configuration(BaseModel):
"""The configuration for the agent."""
query_generator_model: str = Field(
default="gemini-2.0-flash",
default="GLM-4.5-Air",
metadata={
"description": "The name of the language model to use for the agent's query generation."
},
)
reflection_model: str = Field(
default="gemini-2.5-flash",
default="GLM-4.5-Air",
metadata={
"description": "The name of the language model to use for the agent's reflection."
},
)
answer_model: str = Field(
default="gemini-2.5-pro",
default="GLM-4.5-Air",
metadata={
"description": "The name of the language model to use for the agent's answer."
},

View File

@@ -7,7 +7,8 @@ from langgraph.types import Send
from langgraph.graph import StateGraph
from langgraph.graph import START, END
from langchain_core.runnables import RunnableConfig
from google.genai import Client
from langchain_openai import ChatOpenAI
from tavily import TavilyClient
from agent.state import (
OverallState,
@@ -23,60 +24,61 @@ from agent.prompts import (
reflection_instructions,
answer_instructions,
)
from langchain_google_genai import ChatGoogleGenerativeAI
from agent.utils import (
get_citations,
deduplicate_and_format_sources,
get_research_topic,
insert_citation_markers,
resolve_urls,
)
load_dotenv()
if os.getenv("GEMINI_API_KEY") is None:
raise ValueError("GEMINI_API_KEY is not set")
if os.getenv("OPENAI_API_KEY") is None:
raise ValueError("OPENAI_API_KEY is not set")
# Used for Google Search API
genai_client = Client(api_key=os.getenv("GEMINI_API_KEY"))
if os.getenv("TAVILY_API_KEY") is None:
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:
"""Create an OpenAI-compatible chat client."""
return ChatOpenAI(
model=model,
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 generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
"""LangGraph node that generates a search queries based on the User's question.
"""LangGraph node that generates search queries based on the User's question.
Uses Gemini 2.0 Flash to create an optimized search query for web research based on
the User's question.
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 query
Dictionary with state update, including search_query key containing the generated queries
"""
configurable = Configuration.from_runnable_config(config)
# check for custom initial search query count
# Check for custom initial search query count.
if state.get("initial_search_query_count") is None:
state["initial_search_query_count"] = configurable.number_of_initial_queries
# init Gemini 2.0 Flash
llm = ChatGoogleGenerativeAI(
model=configurable.query_generator_model,
temperature=1.0,
max_retries=2,
api_key=os.getenv("GEMINI_API_KEY"),
)
llm = create_llm(configurable.query_generator_model, temperature=1.0)
structured_llm = llm.with_structured_output(SearchQueryList)
# Format the prompt
current_date = get_current_date()
formatted_prompt = query_writer_instructions.format(
current_date=current_date,
research_topic=get_research_topic(state["messages"]),
number_queries=state["initial_search_query_count"],
)
# Generate the search queries
result = structured_llm.invoke(formatted_prompt)
return {"search_query": result.query}
@@ -93,46 +95,34 @@ def continue_to_web_research(state: QueryGenerationState):
def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
"""LangGraph node that performs web research using the native Google Search API tool.
Executes a web search using the native Google Search API tool in combination with Gemini 2.0 Flash.
Args:
state: Current graph state containing the search query and research loop count
config: Configuration for the runnable, including search API settings
Returns:
Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results
"""
# Configure
configurable = Configuration.from_runnable_config(config)
"""LangGraph node that performs web research using Tavily search."""
Configuration.from_runnable_config(config)
formatted_prompt = web_searcher_instructions.format(
current_date=get_current_date(),
research_topic=state["search_query"],
)
search_response = tavily_client.search(
query=state["search_query"],
topic="general",
search_depth="advanced",
max_results=5,
include_answer=False,
include_raw_content=False,
)
sources_gathered, formatted_results = deduplicate_and_format_sources(
search_response.get("results", []), int(state["id"])
)
# Uses the google genai client as the langchain client doesn't return grounding metadata
response = genai_client.models.generate_content(
model=configurable.query_generator_model,
contents=formatted_prompt,
config={
"tools": [{"google_search": {}}],
"temperature": 0,
},
web_research_result = (
f"{formatted_prompt}\n\n"
f"Search query: {state['search_query']}\n\n"
f"Findings:\n{formatted_results}"
)
# resolve the urls to short urls for saving tokens and time
resolved_urls = resolve_urls(
response.candidates[0].grounding_metadata.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)
sources_gathered = [item for citation in citations for item in citation["segments"]]
return {
"sources_gathered": sources_gathered,
"search_query": [state["search_query"]],
"web_research_result": [modified_text],
"web_research_result": [web_research_result],
}
@@ -162,13 +152,7 @@ def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
research_topic=get_research_topic(state["messages"]),
summaries="\n\n---\n\n".join(state["web_research_result"]),
)
# init Reasoning Model
llm = ChatGoogleGenerativeAI(
model=reasoning_model,
temperature=1.0,
max_retries=2,
api_key=os.getenv("GEMINI_API_KEY"),
)
llm = create_llm(reasoning_model, temperature=1.0)
result = llm.with_structured_output(Reflection).invoke(formatted_prompt)
return {
@@ -241,13 +225,7 @@ def finalize_answer(state: OverallState, config: RunnableConfig):
summaries="\n---\n\n".join(state["web_research_result"]),
)
# init Reasoning Model, default to Gemini 2.5 Flash
llm = ChatGoogleGenerativeAI(
model=reasoning_model,
temperature=0,
max_retries=2,
api_key=os.getenv("GEMINI_API_KEY"),
)
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

View File

@@ -34,11 +34,11 @@ Topic: What revenue grew more last year apple stock or the number of people buyi
Context: {research_topic}"""
web_searcher_instructions = """Conduct targeted Google Searches to gather the most recent, credible information on "{research_topic}" and synthesize it into a verifiable text artifact.
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.
Instructions:
- Query should ensure that the most current information is gathered. The current date is {current_date}.
- Conduct multiple, diverse searches to gather comprehensive information.
- Review the returned web search results and extract the strongest, most relevant findings.
- Consolidate key findings while meticulously tracking the source(s) for each specific piece of information.
- The output should be a well-written summary or report based on your search findings.
- Only include the information found in the search results, don't make up any information.
@@ -87,7 +87,7 @@ Instructions:
- You have access to all the information gathered from the previous steps.
- 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.
- you MUST include all the citations from the summaries in the answer correctly.
- Include the sources you used from the Summaries in the answer correctly, using markdown links. THIS IS A MUST.
User Context:
- {research_topic}

View File

@@ -8,8 +8,6 @@ from typing_extensions import Annotated
import operator
from dataclasses import dataclass, field
from typing_extensions import Annotated
class OverallState(TypedDict):

View File

@@ -1,166 +1,59 @@
from typing import Any, Dict, List
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage
from typing import Any
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
def get_research_topic(messages: List[AnyMessage]) -> str:
"""
Get the research topic from the messages.
"""
# check if request has a history and combine the messages into a single string
def get_research_topic(messages: list[AnyMessage]) -> str:
"""Get the research topic from the messages."""
if len(messages) == 1:
research_topic = messages[-1].content
else:
research_topic = ""
for message in messages:
if isinstance(message, HumanMessage):
research_topic += f"User: {message.content}\n"
elif isinstance(message, AIMessage):
research_topic += f"Assistant: {message.content}\n"
return str(messages[-1].content)
research_topic = ""
for message in messages:
if isinstance(message, HumanMessage):
research_topic += f"User: {message.content}\n"
elif isinstance(message, AIMessage):
research_topic += f"Assistant: {message.content}\n"
return research_topic
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.
"""
prefix = f"https://vertexaisearch.cloud.google.com/id/"
urls = [site.web.uri for site in urls_to_resolve]
def deduplicate_and_format_sources(
results: list[dict[str, Any]], search_id: int
) -> tuple[list[dict[str, str]], str]:
"""Convert Tavily results into source metadata and a markdown research summary."""
sources: list[dict[str, str]] = []
formatted_sections: list[str] = []
seen_urls: set[str] = set()
# Create a dictionary that maps each unique URL to its first occurrence index
resolved_map = {}
for idx, url in enumerate(urls):
if url not in resolved_map:
resolved_map[url] = f"{prefix}{id}-{idx}"
for idx, result in enumerate(results):
url = result.get("url")
if not url or url in seen_urls:
continue
return resolved_map
seen_urls.add(url)
title = result.get("title") or f"Source {idx + 1}"
short_url = f"https://search.local/{search_id}-{idx}"
content = (result.get("content") or "").strip()
def insert_citation_markers(text, citations_list):
"""
Inserts citation markers into a text string based on start and end indices.
Args:
text (str): The original text string.
citations_list (list): A list of dictionaries, where each dictionary
contains 'start_index', 'end_index', and
'segment_string' (the marker to insert).
Indices are assumed to be for the original text.
Returns:
str: The text with citation markers inserted.
"""
# Sort citations by end_index in descending order.
# If end_index is the same, secondary sort by start_index descending.
# This ensures that insertions at the end of the string don't affect
# the indices of earlier parts of the string that still need to be processed.
sorted_citations = sorted(
citations_list, key=lambda c: (c["end_index"], c["start_index"]), reverse=True
)
modified_text = text
for citation_info in sorted_citations:
# These indices refer to positions in the *original* text,
# but since we iterate from the end, they remain valid for insertion
# relative to the parts of the string already processed.
end_idx = citation_info["end_index"]
marker_to_insert = ""
for segment in citation_info["segments"]:
marker_to_insert += f" [{segment['label']}]({segment['short_url']})"
# Insert the citation marker at the original end_idx position
modified_text = (
modified_text[:end_idx] + marker_to_insert + modified_text[end_idx:]
sources.append(
{
"label": title,
"short_url": short_url,
"value": url,
}
)
return modified_text
def get_citations(response, resolved_urls_map):
"""
Extracts and formats citation information from a Gemini model's response.
This function processes the grounding metadata provided in the response to
construct a list of citation objects. Each citation object includes the
start and end indices of the text segment it refers to, and a string
containing formatted markdown links to the supporting web chunks.
Args:
response: The response object from the Gemini model, expected to have
a structure including `candidates[0].grounding_metadata`.
It also relies on a `resolved_map` being available in its
scope to map chunk URIs to resolved URLs.
Returns:
list: A list of dictionaries, where each dictionary represents a citation
and has the following keys:
- "start_index" (int): The starting character index of the cited
segment in the original text. Defaults to 0
if not specified.
- "end_index" (int): The character index immediately after the
end of the cited segment (exclusive).
- "segments" (list[str]): A list of individual markdown-formatted
links for each grounding chunk.
- "segment_string" (str): A concatenated string of all markdown-
formatted links for the citation.
Returns an empty list if no valid candidates or grounding supports
are found, or if essential data is missing.
"""
citations = []
# Ensure response and necessary nested structures are present
if not response or not response.candidates:
return citations
candidate = response.candidates[0]
if (
not hasattr(candidate, "grounding_metadata")
or not candidate.grounding_metadata
or not hasattr(candidate.grounding_metadata, "grounding_supports")
):
return citations
for support in candidate.grounding_metadata.grounding_supports:
citation = {}
# Ensure segment information is present
if not hasattr(support, "segment") or support.segment is None:
continue # Skip this support if segment info is missing
start_index = (
support.segment.start_index
if support.segment.start_index is not None
else 0
formatted_sections.append(
"\n".join(
[
f"- {title} [{title}]({short_url})",
f" URL: {url}",
f" Snippet: {content or 'No snippet returned.'}",
]
)
)
# Ensure end_index is present to form a valid segment
if support.segment.end_index is None:
continue # Skip if end_index is missing, as it's crucial
if not formatted_sections:
formatted_sections.append("- No Tavily results were returned for this query.")
# Add 1 to end_index to make it an exclusive end for slicing/range purposes
# (assuming the API provides an inclusive end_index)
citation["start_index"] = start_index
citation["end_index"] = support.segment.end_index
citation["segments"] = []
if (
hasattr(support, "grounding_chunk_indices")
and support.grounding_chunk_indices
):
for ind in support.grounding_chunk_indices:
try:
chunk = candidate.grounding_metadata.grounding_chunks[ind]
resolved_url = resolved_urls_map.get(chunk.web.uri, None)
citation["segments"].append(
{
"label": chunk.web.title.split(".")[:-1][0],
"short_url": resolved_url,
"value": chunk.web.uri,
}
)
except (IndexError, AttributeError, NameError):
# Handle cases where chunk, web, uri, or resolved_map might be problematic
# For simplicity, we'll just skip adding this particular segment link
# In a production system, you might want to log this.
pass
citations.append(citation)
return citations
return sources, "\n".join(formatted_sections)

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ 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");
const [model, setModel] = useState("GLM-4.5-Air");
const handleInternalSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault();
@@ -35,10 +35,9 @@ export const InputForm: React.FC<InputFormProps> = ({
setInternalInputValue("");
};
const handleInternalKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === "Enter" && !e.shiftKey) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Submit with Ctrl+Enter (Windows/Linux) or Cmd+Enter (Mac)
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleInternalSubmit();
}
@@ -59,9 +58,9 @@ export const InputForm: React.FC<InputFormProps> = ({
<Textarea
value={internalInputValue}
onChange={(e) => setInternalInputValue(e.target.value)}
onKeyDown={handleInternalKeyDown}
onKeyDown={handleKeyDown}
placeholder="Who won the Euro 2024 and scored the most goals?"
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
md:text-base min-h-[56px] max-h-[200px]`}
rows={1}
/>
@@ -137,27 +136,11 @@ export const InputForm: React.FC<InputFormProps> = ({
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem
value="gemini-2.0-flash"
value="GLM-4.5-Air"
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>
</SelectItem>
<SelectItem
value="gemini-2.5-flash-preview-04-17"
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"
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
<Zap className="h-4 w-4 mr-2 text-cyan-400" /> GLM-4.5-Air
</div>
</SelectItem>
</SelectContent>

View File

@@ -33,7 +33,7 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
/>
</div>
<p className="text-xs text-neutral-500">
Powered by Google Gemini and LangChain LangGraph.
Powered by GLM-4.5-Air, Tavily, and LangChain LangGraph.
</p>
</div>
);

View File

@@ -9,7 +9,7 @@ export default defineConfig({
base: "/app/",
resolve: {
alias: {
"@": path.resolve(new URL(".", import.meta.url).pathname, "./src"),
"@": path.resolve(__dirname, "./src"),
},
},
server: {