Compare commits

..

21 Commits

Author SHA1 Message Date
Xin Wang
0e2dafe440 Update agent flow and frontend chat experience 2026-03-31 21:58:24 +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
15 changed files with 407 additions and 382 deletions

View File

@@ -12,7 +12,7 @@ This project demonstrates a fullstack application using a React frontend and a L
- 🌐 Integrated web research via Google Search API. - 🌐 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 development during development. - 🔄 Hot-reloading for both frontend and backend during development.
## Project Structure ## Project Structure
@@ -73,13 +73,25 @@ The core of the backend is a LangGraph agent defined in `backend/src/agent/graph
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 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 a Gemini 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 ## Deployment
In production, the backend server serves the optimized static frontend build. LangGraph requires a Redis instance and a Postgres database. Redis is used as a pub-sub broker to enable streaming real time output from background runs. Postgres is used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics. For more details on how to deploy the backend server, take a look at the [LangGraph Documentation](https://langchain-ai.github.io/langgraph/concepts/deployment_options/). Below is an example of how to build a Docker image that includes the optimized frontend build and the backend server and run it via `docker-compose`. In production, the backend server serves the optimized static frontend build. LangGraph requires a Redis instance and a Postgres database. Redis is used as a pub-sub broker to enable streaming real time output from background runs. Postgres is used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics. For more details on how to deploy the backend server, take a look at the [LangGraph Documentation](https://langchain-ai.github.io/langgraph/concepts/deployment_options/). Below is an example of how to build a Docker image that includes the optimized frontend build and the backend server and run it via `docker-compose`.
_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: 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:** **1. Build the Docker Image:**

View File

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

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="openai/gpt-5.4",
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 = [ dependencies = [
"langgraph>=0.2.6", "langgraph>=0.2.6",
"langchain>=0.3.19", "langchain>=0.3.19",
"langchain-google-genai", "langchain-openai",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"langgraph-sdk>=0.1.57", "langgraph-sdk>=0.1.57",
"langgraph-cli", "langgraph-cli",
"langgraph-api", "langgraph-api",
"fastapi", "fastapi",
"google-genai", "tavily-python",
] ]

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="gemini-2.0-flash", 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="gemini-2.5-flash", 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="gemini-2.5-pro", 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,91 +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 google.genai import Client
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 langchain_google_genai import ChatGoogleGenerativeAI
from agent.utils import ( from agent.utils import (
get_citations, format_sources_for_prompt,
get_research_topic, get_research_topic,
insert_citation_markers, normalize_model_name,
resolve_urls, 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("GEMINI_API_KEY") is None: TAVILY_TOPIC = "general"
raise ValueError("GEMINI_API_KEY is not set") TAVILY_SEARCH_DEPTH = "advanced"
TAVILY_MAX_RESULTS = 5
# Used for Google Search API TAVILY_CHUNKS_PER_SOURCE = 3
genai_client = Client(api_key=os.getenv("GEMINI_API_KEY"))
def require_env(name: str) -> str:
"""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(
model=normalize_model_name(model_name),
api_key=require_env("OPENAI_API_KEY"),
base_url=base_url or None,
temperature=temperature,
)
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
# Nodes
def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState: def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
"""LangGraph node that generates a search queries based on the User's question. """Generate initial search queries from the user's request."""
Uses Gemini 2.0 Flash to create an optimized search query for web research based on
the User's question.
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
"""
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
# 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"),
)
structured_llm = llm.with_structured_output(SearchQueryList)
# Format the prompt
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"],
) )
# Generate the search queries result = create_structured_response(
result = structured_llm.invoke(formatted_prompt) 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"])
@@ -93,83 +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 the native Google Search API tool. """Execute Tavily search and return raw evidence for one query."""
search_query = shorten_search_query(state["search_query"])
Executes a web search using the native Google Search API tool in combination with Gemini 2.0 Flash. tavily_response = get_tavily_client().search(
query=search_query,
Args: topic=TAVILY_TOPIC,
state: Current graph state containing the search query and research loop count search_depth=TAVILY_SEARCH_DEPTH,
config: Configuration for the runnable, including search API settings chunks_per_source=TAVILY_CHUNKS_PER_SOURCE,
max_results=TAVILY_MAX_RESULTS,
Returns: include_answer=False,
Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results include_raw_content=False,
"""
# Configure
configurable = Configuration.from_runnable_config(config)
formatted_prompt = web_searcher_instructions.format(
current_date=get_current_date(),
research_topic=state["search_query"],
) )
sources = normalize_tavily_sources(tavily_response.get("results", []))
# Uses the google genai client as the langchain client doesn't return grounding metadata if not sources:
response = genai_client.models.generate_content( return {
model=configurable.query_generator_model, "sources_gathered": [],
contents=formatted_prompt, "search_query": [search_query],
config={ "web_research_result": [
"tools": [{"google_search": {}}], f'No Tavily results were returned for "{search_query}".'
"temperature": 0, ],
}, }
evidence = "\n\n".join(
[
f"Search Query: {search_query}",
"Source Evidence:",
format_sources_for_prompt(sources),
]
) )
# 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 { return {
"sources_gathered": sources_gathered, "sources_gathered": sources,
"search_query": [state["search_query"]], "search_query": [search_query],
"web_research_result": [modified_text], "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"]),
) )
# init Reasoning Model result = create_structured_response(
llm = ChatGoogleGenerativeAI( formatted_prompt,
model=reasoning_model, reasoning_model,
temperature=1.0, Reflection,
max_retries=2, temperature=1,
api_key=os.getenv("GEMINI_API_KEY"),
) )
result = llm.with_structured_output(Reflection).invoke(formatted_prompt)
return { return {
"is_sufficient": result.is_sufficient, "is_sufficient": result.is_sufficient,
@@ -183,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")
@@ -204,90 +216,53 @@ 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",
{ {
"search_query": follow_up_query, "search_query": follow_up_query,
"id": state["number_of_ran_queries"] + int(idx), "id": state["number_of_ran_queries"] + int(idx),
}, },
) )
for idx, follow_up_query in enumerate(state["follow_up_queries"]) for idx, follow_up_query in enumerate(state["follow_up_queries"])
] ]
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)
# 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"),
)
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 Google Searches 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}.
- Conduct multiple, diverse searches to gather comprehensive information. - 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.
- you MUST include all the citations from the summaries in the answer correctly. - 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

@@ -8,8 +8,6 @@ from typing_extensions import Annotated
import operator import operator
from dataclasses import dataclass, field
from typing_extensions import Annotated
class OverallState(TypedDict): class OverallState(TypedDict):
@@ -31,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,166 +1,106 @@
from typing import Any, Dict, List from __future__ import annotations
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage
import re
from typing import Any, List
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.
"""
# check if request has a history and combine the messages into a single string
if len(messages) == 1: if len(messages) == 1:
research_topic = messages[-1].content return str(messages[-1].content)
else:
research_topic = "" research_topic = ""
for message in messages: for message in messages:
if isinstance(message, HumanMessage): if isinstance(message, HumanMessage):
research_topic += f"User: {message.content}\n" research_topic += f"User: {message.content}\n"
elif isinstance(message, AIMessage): elif isinstance(message, AIMessage):
research_topic += f"Assistant: {message.content}\n" research_topic += f"Assistant: {message.content}\n"
return research_topic return research_topic
def resolve_urls(urls_to_resolve: List[Any], id: int) -> Dict[str, str]: def normalize_model_name(
""" model_name: str | None,
Create a map of the vertex ai search urls (very long) to a short url with a unique id for each url. fallback: str = DEFAULT_REASONING_MODEL,
Ensures each original URL gets a consistent shortened form while maintaining uniqueness. ) -> str:
""" """Normalize stale or unsupported model names to a supported default."""
prefix = f"https://vertexaisearch.cloud.google.com/id/" candidate = (model_name or "").strip()
urls = [site.web.uri for site in urls_to_resolve] if candidate.startswith(SUPPORTED_MODEL_PREFIXES):
return candidate
# Create a dictionary that maps each unique URL to its first occurrence index provider, _, model = candidate.partition("/")
resolved_map = {} if provider and model.startswith(SUPPORTED_MODEL_PREFIXES):
for idx, url in enumerate(urls): return candidate
if url not in resolved_map:
resolved_map[url] = f"{prefix}{id}-{idx}"
return resolved_map return fallback
def insert_citation_markers(text, citations_list): def _normalize_source_title(source: dict[str, Any], index: int) -> str:
""" title = (source.get("title") or "").strip()
Inserts citation markers into a text string based on start and end indices. return title or f"Source {index}"
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: def normalize_tavily_sources(results: list[dict[str, Any]]) -> list[dict[str, str]]:
str: The text with citation markers inserted. """Normalize Tavily results into a stable source shape."""
""" normalized_sources: list[dict[str, str]] = []
# Sort citations by end_index in descending order. seen_urls: set[str] = set()
# 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 index, result in enumerate(results, start=1):
for citation_info in sorted_citations: url = (result.get("url") or "").strip()
# These indices refer to positions in the *original* text, if not url or url in seen_urls:
# but since we iterate from the end, they remain valid for insertion continue
# relative to the parts of the string already processed.
end_idx = citation_info["end_index"] normalized_sources.append(
marker_to_insert = "" {
for segment in citation_info["segments"]: "label": f"S{len(normalized_sources) + 1}",
marker_to_insert += f" [{segment['label']}]({segment['short_url']})" "title": _normalize_source_title(result, index),
# Insert the citation marker at the original end_idx position "value": url,
modified_text = ( "content": (result.get("content") or "").strip(),
modified_text[:end_idx] + marker_to_insert + modified_text[end_idx:] }
)
seen_urls.add(url)
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(
[
f"{source['label']}: {source['title']}",
f"URL: {source['value']}",
f"Snippet: {snippet}",
]
)
) )
return modified_text return "\n\n".join(formatted_sources)
def get_citations(response, resolved_urls_map): def shorten_search_query(
""" query: str,
Extracts and formats citation information from a Gemini model's response. 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
This function processes the grounding metadata provided in the response to truncated_query = normalized_query[:max_length]
construct a list of citation objects. Each citation object includes the last_space = truncated_query.rfind(" ")
start and end indices of the text segment it refers to, and a string if last_space > max_length // 2:
containing formatted markdown links to the supporting web chunks. truncated_query = truncated_query[:last_space]
Args: return truncated_query.rstrip(" ,;:-")
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
)
# 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
# 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

View File

@@ -38,7 +38,9 @@ services:
langgraph-postgres: langgraph-postgres:
condition: service_healthy condition: service_healthy
environment: environment:
GEMINI_API_KEY: ${GEMINI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_BASE_URL: ${OPENAI_BASE_URL}
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
POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable

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("gemini-2.5-flash-preview-04-17");
const handleInternalSubmit = (e?: React.FormEvent) => { const handleInternalSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
@@ -35,10 +38,9 @@ export const InputForm: React.FC<InputFormProps> = ({
setInternalInputValue(""); setInternalInputValue("");
}; };
const handleInternalKeyDown = ( const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e: React.KeyboardEvent<HTMLTextAreaElement> // Submit with Ctrl+Enter (Windows/Linux) or Cmd+Enter (Mac)
) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleInternalSubmit(); handleInternalSubmit();
} }
@@ -59,7 +61,7 @@ export const InputForm: React.FC<InputFormProps> = ({
<Textarea <Textarea
value={internalInputValue} value={internalInputValue}
onChange={(e) => setInternalInputValue(e.target.value)} onChange={(e) => setInternalInputValue(e.target.value)}
onKeyDown={handleInternalKeyDown} onKeyDown={handleKeyDown}
placeholder="Who won the Euro 2024 and scored the most goals?" 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]`} md:text-base min-h-[56px] max-h-[200px]`}
@@ -131,33 +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="gemini-2.0-flash" 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-yellow-400" /> 2.0 Flash <Zap className="h-4 w-4 mr-2 text-emerald-400" /> openai/gpt-5.4
</div> </div>
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="gemini-2.5-flash-preview-04-17" value="deepseek-v3"
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-orange-400" /> 2.5 Flash <Cpu className="h-4 w-4 mr-2 text-cyan-400" /> deepseek-v3
</div> </div>
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="gemini-2.5-pro-preview-05-06" value="glm-4.5-air"
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">
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 2.5 Pro <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,6 +34,8 @@ 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">

View File

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