mirror of
https://github.com/hwchase17/langchain.git
synced 2026-04-12 15:33:17 +00:00
add demo
This commit is contained in:
3
examples/headless-langgraph-e2e/.env.example
Normal file
3
examples/headless-langgraph-e2e/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
OPENAI_API_KEY=sk-...
|
||||
# Optional override (default: openai:gpt-4o-mini)
|
||||
# OPENAI_MODEL=openai:gpt-4o-mini
|
||||
7
examples/headless-langgraph-e2e/.gitignore
vendored
Normal file
7
examples/headless-langgraph-e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.langgraph_api/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
87
examples/headless-langgraph-e2e/README.md
Normal file
87
examples/headless-langgraph-e2e/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Headless tool + LangGraph dev server (E2E)
|
||||
|
||||
This example runs a **local LangGraph API** (`langgraph dev`) with a graph built via
|
||||
[`create_agent`](https://docs.langchain.com/oss/python/langchain/agents) and a **headless
|
||||
tool** from the branch (`langchain.tools.tool` with `name`, `description`, and
|
||||
`args_schema` only). The tool calls LangGraph `interrupt()` so you can handle execution in
|
||||
a browser or other client and **resume** with the result.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- An `OPENAI_API_KEY` in `.env` (the demo model defaults to `openai:gpt-4o-mini`)
|
||||
|
||||
## 1. Configure environment
|
||||
|
||||
```bash
|
||||
cd examples/headless-langgraph-e2e
|
||||
cp .env.example .env
|
||||
# Edit .env and set OPENAI_API_KEY
|
||||
```
|
||||
|
||||
## 2. Install dependencies
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
## 3. Start the LangGraph dev server
|
||||
|
||||
Default API URL is `http://127.0.0.1:2024` (CORS allows `*` by default).
|
||||
|
||||
```bash
|
||||
uv run langgraph dev --config langgraph.json --no-browser
|
||||
```
|
||||
|
||||
The dev server listens on **port 2024** by default (see the banner for the exact API URL).
|
||||
|
||||
### Port 2024 already in use
|
||||
|
||||
Another process (often a previous `langgraph dev`) is bound to 2024. Either stop it or use a free port:
|
||||
|
||||
```bash
|
||||
# See what is using 2024 (macOS / Linux)
|
||||
lsof -i :2024
|
||||
```
|
||||
|
||||
Then quit that process, or start on another port:
|
||||
|
||||
```bash
|
||||
uv run langgraph dev --config langgraph.json --no-browser --port 2025
|
||||
```
|
||||
|
||||
Set the **LangGraph API base URL** in the frontend to match (for example `http://127.0.0.1:2025`).
|
||||
|
||||
## 4. Run the React frontend (Vite + `useStream`)
|
||||
|
||||
The UI mirrors the headless-tools pattern from `ui-playground` (`tool({ name, description, schema })` +
|
||||
`.implement(...)` passed to `useStream({ tools: [...] })`).
|
||||
|
||||
In another shell:
|
||||
|
||||
```bash
|
||||
cd examples/headless-langgraph-e2e/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8765`. The **LangGraph API base URL** field defaults to
|
||||
`http://127.0.0.1:2024`; change it if you used `--port` with a different value.
|
||||
|
||||
Send a message that asks to open a URL or for your location; the model should call the
|
||||
matching headless tool, the client runs the implementation in `src/tools.ts`, and streaming
|
||||
continues after the tool result is applied.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Checkpointer:** For a plain Python script you normally pass `checkpointer=InMemorySaver()`
|
||||
to `create_agent` so `interrupt()` / headless tools persist. The **LangGraph dev server**
|
||||
injects its own persistence and **rejects** a custom checkpointer on the graph, so this
|
||||
example omits it.
|
||||
- **Studio:** The server banner prints a link to open **LangSmith Studio** against your
|
||||
local API (useful for debugging the same graph).
|
||||
|
||||
## Graph id
|
||||
|
||||
The graph is registered as **`agent`** in `langgraph.json` (this is the assistant / graph
|
||||
id passed to the SDK).
|
||||
65
examples/headless-langgraph-e2e/agent.py
Normal file
65
examples/headless-langgraph-e2e/agent.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""LangGraph dev graph: `create_agent` with a headless (interrupting) tool.
|
||||
|
||||
Headless tools (`browser_navigate`, `geolocation_get`) have no local implementation;
|
||||
they call LangGraph `interrupt()` so the client can execute them and resume with a result.
|
||||
|
||||
Run the API: ``uv run langgraph dev`` from this directory (see README).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Model id for init_chat_model (requires OPENAI_API_KEY in .env for OpenAI models).
|
||||
_model = os.environ.get("OPENAI_MODEL", "openai:gpt-4o-mini")
|
||||
|
||||
|
||||
class BrowserNavigateArgs(BaseModel):
|
||||
"""Arguments for the headless browser navigation tool."""
|
||||
|
||||
url: str = Field(..., description="URL to open in the headless browser.")
|
||||
|
||||
|
||||
class GeolocationGetArgs(BaseModel):
|
||||
"""Arguments for the headless geolocation tool."""
|
||||
|
||||
high_accuracy: bool | None = Field(
|
||||
default=None,
|
||||
description="If true, request high-accuracy GPS from the browser when supported.",
|
||||
)
|
||||
|
||||
|
||||
browser_navigate = tool(
|
||||
name="browser_navigate",
|
||||
description=(
|
||||
"Navigate a headless browser to a URL. Execution is delegated to the client: "
|
||||
"the graph pauses until the client resumes with the page result."
|
||||
),
|
||||
args_schema=BrowserNavigateArgs,
|
||||
)
|
||||
|
||||
geolocation_get = tool(
|
||||
name="geolocation_get",
|
||||
description=(
|
||||
"Get the user's current GPS coordinates using the browser Geolocation API. "
|
||||
"The client shows their position on a map (OpenStreetMap). "
|
||||
"The browser may prompt for permission the first time."
|
||||
),
|
||||
args_schema=GeolocationGetArgs,
|
||||
)
|
||||
|
||||
graph = create_agent(
|
||||
_model,
|
||||
tools=[browser_navigate, geolocation_get],
|
||||
system_prompt=(
|
||||
"You are a helpful assistant. When the user asks to open, visit, or browse "
|
||||
"a URL, call browser_navigate with that URL. When they ask where they are, "
|
||||
"their location, or to show them on a map, call geolocation_get. "
|
||||
"After the client returns out-of-process results, reply briefly using that information."
|
||||
),
|
||||
# No custom checkpointer: LangGraph API (`langgraph dev`) supplies persistence.
|
||||
)
|
||||
12
examples/headless-langgraph-e2e/frontend/index.html
Normal file
12
examples/headless-langgraph-e2e/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Headless tool demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1464
examples/headless-langgraph-e2e/frontend/package-lock.json
generated
Normal file
1464
examples/headless-langgraph-e2e/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
examples/headless-langgraph-e2e/frontend/package.json
Normal file
24
examples/headless-langgraph-e2e/frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "headless-langgraph-e2e-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 8765",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 8765"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/react": "^0.3.2",
|
||||
"langchain": "^1.3.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6",
|
||||
"typescript": "^5.8",
|
||||
"vite": "^8"
|
||||
}
|
||||
}
|
||||
243
examples/headless-langgraph-e2e/frontend/src/App.tsx
Normal file
243
examples/headless-langgraph-e2e/frontend/src/App.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { AIMessage, HumanMessage } from "langchain";
|
||||
import {
|
||||
useStream,
|
||||
type DefaultToolCall,
|
||||
type ToolCallWithResult,
|
||||
} from "@langchain/react";
|
||||
|
||||
import { LocationMap } from "./LocationMap";
|
||||
import { browserNavigateClient, geolocationGetClient } from "./tools";
|
||||
|
||||
const ASSISTANT_ID = "agent";
|
||||
|
||||
const PRESETS = [
|
||||
"Please open https://example.com in the browser.",
|
||||
"Visit https://langchain.com and tell me the page title if you can.",
|
||||
"Where am I right now? Show me on a map.",
|
||||
];
|
||||
|
||||
function parseResultContent(result: ToolCallWithResult<DefaultToolCall>["result"]): unknown {
|
||||
if (!result) return undefined;
|
||||
const c = result.content;
|
||||
const raw = typeof c === "string" ? c : JSON.stringify(c);
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function HeadlessToolCallCard({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: ToolCallWithResult<DefaultToolCall>;
|
||||
}) {
|
||||
const { call, result, state } = toolCall;
|
||||
const pending = state === "pending";
|
||||
const parsed = parseResultContent(result);
|
||||
|
||||
const title =
|
||||
call.name === "geolocation_get"
|
||||
? "geolocation_get"
|
||||
: call.name === "browser_navigate"
|
||||
? "browser_navigate"
|
||||
: call.name;
|
||||
|
||||
const icon =
|
||||
call.name === "geolocation_get" ? "📍" : call.name === "browser_navigate" ? "🌐" : "🔧";
|
||||
|
||||
let map: ReactNode = null;
|
||||
if (
|
||||
call.name === "geolocation_get" &&
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
"success" in parsed &&
|
||||
(parsed as { success?: boolean }).success === true &&
|
||||
"latitude" in parsed &&
|
||||
"longitude" in parsed
|
||||
) {
|
||||
const p = parsed as { latitude: number; longitude: number; accuracy?: number };
|
||||
map = <LocationMap latitude={p.latitude} longitude={p.longitude} accuracy={p.accuracy} />;
|
||||
}
|
||||
|
||||
let resultText = "";
|
||||
if (result) {
|
||||
const c = result.content;
|
||||
resultText = typeof c === "string" ? c : JSON.stringify(c);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-card">
|
||||
<header>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span>{pending ? `Running ${call.name}…` : title}</span>
|
||||
</header>
|
||||
<pre>{JSON.stringify(call.args, null, 2)}</pre>
|
||||
{map}
|
||||
{resultText ? (
|
||||
<pre>
|
||||
{map ? "---\n" : ""}
|
||||
{resultText}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [apiUrl, setApiUrl] = useState("http://127.0.0.1:2024");
|
||||
|
||||
const tools = useMemo(() => [browserNavigateClient, geolocationGetClient], []);
|
||||
|
||||
const stream = useStream({
|
||||
apiUrl: apiUrl.replace(/\/$/, ""),
|
||||
assistantId: ASSISTANT_ID,
|
||||
tools,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string) => {
|
||||
void stream.submit({
|
||||
messages: [{ type: "human" as const, content: text }],
|
||||
});
|
||||
},
|
||||
[stream],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Headless tool (useStream)</h1>
|
||||
<p className="hint">
|
||||
Start the graph API from the parent directory:{" "}
|
||||
<code>uv run langgraph dev --config langgraph.json --no-browser</code>. Tool name{" "}
|
||||
<code>browser_navigate</code> and <code>geolocation_get</code> match <code>agent.py</code>
|
||||
; the client runs implementations passed to <code>useStream</code> (see{" "}
|
||||
<code>ui-playground/.../headless-tools</code>). Geolocation uses OpenStreetMap embeds.
|
||||
</p>
|
||||
|
||||
<div className="row">
|
||||
<div style={{ flex: 1, minWidth: "12rem" }}>
|
||||
<label htmlFor="apiUrl">LangGraph API base URL</label>
|
||||
<input
|
||||
id="apiUrl"
|
||||
type="url"
|
||||
value={apiUrl}
|
||||
onChange={(e) => {
|
||||
setApiUrl(e.target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{stream.messages.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => {
|
||||
stream.switchThread(null);
|
||||
}}
|
||||
>
|
||||
New thread
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label htmlFor="msg">Message</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
rows={3}
|
||||
defaultValue={PRESETS[0]}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit((e.target as HTMLTextAreaElement).value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={stream.isLoading}
|
||||
onClick={() => {
|
||||
const ta = document.getElementById("msg") as HTMLTextAreaElement | null;
|
||||
handleSubmit(ta?.value ?? "");
|
||||
}}
|
||||
>
|
||||
{stream.isLoading ? "Running…" : "Send"}
|
||||
</button>
|
||||
|
||||
{stream.messages.length === 0 ? (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<span className="hint">Try a preset:</span>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className="secondary"
|
||||
style={{ width: "auto" }}
|
||||
onClick={() => {
|
||||
handleSubmit(p);
|
||||
}}
|
||||
>
|
||||
{p.slice(0, 42)}
|
||||
{p.length > 42 ? "…" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: "1.25rem" }}>
|
||||
{stream.messages.map((msg, idx) => {
|
||||
if (HumanMessage.isInstance(msg)) {
|
||||
return (
|
||||
<div key={msg.id ?? idx} className="bubble-user">
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (AIMessage.isInstance(msg)) {
|
||||
const msgToolCalls = stream.toolCalls.filter((tc) =>
|
||||
msg.tool_calls?.some((t) => t.id === tc.call.id),
|
||||
);
|
||||
|
||||
if (msgToolCalls.length > 0) {
|
||||
return (
|
||||
<div key={msg.id ?? idx}>
|
||||
{msgToolCalls.map((tc) => (
|
||||
<HeadlessToolCallCard
|
||||
key={tc.id}
|
||||
toolCall={tc as ToolCallWithResult<DefaultToolCall>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!msg.text) return null;
|
||||
return (
|
||||
<div key={msg.id ?? idx} className="bubble-ai">
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{stream.isLoading &&
|
||||
!stream.messages.some((m) => AIMessage.isInstance(m) && m.text) &&
|
||||
stream.toolCalls.length === 0 && <div className="typing">Thinking…</div>}
|
||||
|
||||
{stream.error ? (
|
||||
<div className="error" role="alert">
|
||||
{stream.error instanceof Error ? stream.error.message : String(stream.error)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
examples/headless-langgraph-e2e/frontend/src/LocationMap.tsx
Normal file
40
examples/headless-langgraph-e2e/frontend/src/LocationMap.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* OpenStreetMap embed (same approach as ui-playground headless-tools/components.tsx).
|
||||
*/
|
||||
|
||||
type LocationMapProps = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
};
|
||||
|
||||
export function LocationMap({ latitude, longitude, accuracy }: LocationMapProps) {
|
||||
const delta = 0.005;
|
||||
const bbox = `${longitude - delta},${latitude - delta},${longitude + delta},${latitude + delta}`;
|
||||
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${latitude},${longitude}`;
|
||||
const externalHref = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
|
||||
|
||||
return (
|
||||
<div className="location-map">
|
||||
<div className="location-map-frame">
|
||||
<iframe
|
||||
src={src}
|
||||
title="Your location on OpenStreetMap"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="location-map-meta">
|
||||
<span className="location-map-coords">
|
||||
{latitude.toFixed(5)}, {longitude.toFixed(5)}
|
||||
{accuracy !== undefined ? (
|
||||
<span className="location-map-acc"> ±{Math.round(accuracy)} m</span>
|
||||
) : null}
|
||||
</span>
|
||||
<a href={externalHref} target="_blank" rel="noopener noreferrer" className="location-map-link">
|
||||
Open in OSM ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
examples/headless-langgraph-e2e/frontend/src/index.css
Normal file
181
examples/headless-langgraph-e2e/frontend/src/index.css
Normal file
@@ -0,0 +1,181 @@
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 1.45;
|
||||
color: #e8e8ef;
|
||||
background: #12141a;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: #8b92a6;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #a8adbc;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2c3140;
|
||||
background: #1a1d26;
|
||||
color: #e8e8ef;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #3b5bdb;
|
||||
border-color: #3b5bdb;
|
||||
font-weight: 500;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #2c3140;
|
||||
border-color: #3d4455;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.row input {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(220, 38, 38, 0.45);
|
||||
background: rgba(127, 29, 29, 0.25);
|
||||
color: #fecaca;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2c3140;
|
||||
background: #1a1d26;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tool-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-card pre {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #c8ccd8;
|
||||
}
|
||||
|
||||
.bubble-user {
|
||||
background: #2c3140;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bubble-ai {
|
||||
background: #1e2433;
|
||||
border: 1px solid #2c3140;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.typing {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.location-map {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.location-map-frame {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2c3140;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.location-map-frame iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.location-map-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #a8adbc;
|
||||
}
|
||||
|
||||
.location-map-coords {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.location-map-acc {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.location-map-link {
|
||||
color: #7c9cff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.location-map-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
16
examples/headless-langgraph-e2e/frontend/src/main.tsx
Normal file
16
examples/headless-langgraph-e2e/frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error("Missing #root");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
97
examples/headless-langgraph-e2e/frontend/src/tools.ts
Normal file
97
examples/headless-langgraph-e2e/frontend/src/tools.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Headless tool definitions + client implementations.
|
||||
*
|
||||
* Mirrors `agent.py`: schema-only headless tools; execution runs here via
|
||||
* `useStream({ tools: [...] })`.
|
||||
*
|
||||
* Pattern: https://github.com/langchain-ai/langchainjs (headless `tool` + `.implement()`)
|
||||
*/
|
||||
import { tool } from "langchain";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Must match `browser_navigate` in `agent.py`. */
|
||||
export const browserNavigate = tool({
|
||||
name: "browser_navigate",
|
||||
description:
|
||||
"Navigate a headless browser to a URL. The real browser runs out-of-process; " +
|
||||
"the graph pauses until the client resumes with a result.",
|
||||
schema: z.object({
|
||||
url: z.string().describe("URL to open in the headless browser."),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Client-side implementation: try `fetch` for same-origin or CORS-friendly URLs;
|
||||
* otherwise return a simulated result (many sites block browser cross-origin fetch).
|
||||
*/
|
||||
/** Must match `geolocation_get` in `agent.py`. */
|
||||
export const geolocationGet = tool({
|
||||
name: "geolocation_get",
|
||||
description:
|
||||
"Get the user's current GPS coordinates using the browser's Geolocation API. " +
|
||||
"The client can show the position on OpenStreetMap.",
|
||||
schema: z.object({
|
||||
high_accuracy: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Request high-accuracy GPS when supported."),
|
||||
}),
|
||||
});
|
||||
|
||||
export const geolocationGetClient = geolocationGet.implement(async ({ high_accuracy }) => {
|
||||
if (!navigator.geolocation) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
message: "Geolocation is not supported by this browser.",
|
||||
});
|
||||
}
|
||||
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: high_accuracy ?? true,
|
||||
timeout: 10_000,
|
||||
maximumAge: 5 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
const { latitude, longitude, accuracy } = position.coords;
|
||||
const timestamp = new Date(position.timestamp).toISOString();
|
||||
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
timestamp,
|
||||
message: `Location: ${latitude.toFixed(5)}, ${longitude.toFixed(5)} (±${Math.round(accuracy)} m)`,
|
||||
});
|
||||
});
|
||||
|
||||
export const browserNavigateClient = browserNavigate.implement(async ({ url }) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return JSON.stringify({ ok: false, error: "Invalid URL", url });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(parsed.href, { mode: "cors", credentials: "omit" });
|
||||
const text = await res.text();
|
||||
const titleMatch = text.match(/<title>([^<]*)<\/title>/i);
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
url: parsed.href,
|
||||
status: res.status,
|
||||
title: titleMatch?.[1]?.trim() ?? null,
|
||||
});
|
||||
} catch {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
url: parsed.href,
|
||||
simulated: true,
|
||||
note:
|
||||
"Could not fetch page (often cross-origin). Simulated successful navigation for E2E.",
|
||||
});
|
||||
}
|
||||
});
|
||||
20
examples/headless-langgraph-e2e/frontend/tsconfig.json
Normal file
20
examples/headless-langgraph-e2e/frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
examples/headless-langgraph-e2e/frontend/vite.config.ts
Normal file
9
examples/headless-langgraph-e2e/frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 8765,
|
||||
},
|
||||
});
|
||||
8
examples/headless-langgraph-e2e/langgraph.json
Normal file
8
examples/headless-langgraph-e2e/langgraph.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://langgra.ph/schema.json",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
24
examples/headless-langgraph-e2e/pyproject.toml
Normal file
24
examples/headless-langgraph-e2e/pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "headless-langgraph-e2e"
|
||||
version = "0.1.0"
|
||||
description = "Local LangGraph dev server + headless tool demo (create_agent)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain",
|
||||
"langchain-openai>=0.3.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
langchain = { path = "../../libs/langchain_v1", editable = true }
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["langgraph-cli[inmem]>=0.4.14"]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["agent"]
|
||||
2241
examples/headless-langgraph-e2e/uv.lock
generated
Normal file
2241
examples/headless-langgraph-e2e/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user