Compare commits

...

10 Commits

Author SHA1 Message Date
d4b4ef3690 add graph architecture to config page 2026-02-12 11:03:04 +08:00
4086d0eba4 add graph assets 2026-02-12 11:02:36 +08:00
e8765100f9 ignore only root level assets 2026-02-12 11:02:28 +08:00
2865de6843 bug fix 2026-02-12 10:36:57 +08:00
f662bdb60d import the sht 2026-02-12 10:32:49 +08:00
d3f7144680 update readme 2026-02-11 18:17:59 +08:00
37f8708ecf bug fixes 2026-02-11 18:03:26 +08:00
0caf45e360 frontend v1 2026-02-11 17:47:18 +08:00
0ad07402f2 update viewer 2026-02-11 17:46:41 +08:00
eba1ee00e9 ignore lots of sht 2026-02-11 17:46:20 +08:00
23 changed files with 3107 additions and 10 deletions

7
.gitignore vendored
View File

@@ -1,4 +1,4 @@
assets/ /assets/
.vscode/ .vscode/
logs/ logs/
*.egg-info/ *.egg-info/
@@ -7,4 +7,7 @@ logs/
*.zip *.zip
*.ipynb *.ipynb
django.log django.log
.env .env
frontend/node_modules/
frontend/dist/

View File

@@ -134,6 +134,38 @@ they are in `configs/route_sys_prompts`
- `tool_prompt.txt`: controls `tool_model_call` - `tool_prompt.txt`: controls `tool_model_call`
- `chatty_prompt.txt`: controls how the model say random things when tool use is in progress. Ignore this for now as model architecture is not yet configurable - `chatty_prompt.txt`: controls how the model say random things when tool use is in progress. Ignore this for now as model architecture is not yet configurable
## Frontend (Conversation Viewer UI)
The React-based frontend for browsing conversations lives in the `frontend` directory.
### Install dependencies
```bash
cd frontend
npm install
```
### Start the `front_apis` server
The frontend talks to the `front_apis` FastAPI service, which by default listens on `http://127.0.0.1:8001`.
From the project root:
```bash
uvicorn fastapi_server.front_apis:app --reload --host 0.0.0.0 --port 8001
```
You can change the URL by setting `VITE_FRONT_API_BASE_URL` in `frontend/.env` (defaults to `http://127.0.0.1:8001`).
### Start the development server
```bash
cd frontend
npm run dev
```
By default, Vite will start the app on `http://localhost:5173` (or the next available port).
## Stress Test results ## Stress Test results
### Dashscope server summary ### Dashscope server summary

24
frontend/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Agent Manager Frontend
React frontend for configuring and launching agents through `fastapi_server/front_apis.py`.
## Run
```bash
cd /home/smith/projects/work/langchain-agent/frontend
npm install
npm run dev
```
## API Base URL
By default, the app calls:
- `http://127.0.0.1:8001`
If your `front_apis.py` server runs elsewhere, set:
```bash
VITE_FRONT_API_BASE_URL=http://<host>:<port>
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1726
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "langchain-agent-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

638
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,638 @@
import { useEffect, useMemo, useState } from "react";
import {
createPipeline,
deleteGraphConfig,
getGraphConfig,
getGraphDefaultConfig,
listAvailableGraphs,
listGraphConfigs,
listPipelines,
stopPipeline,
upsertGraphConfig,
} from "./api/frontApis";
import type {
GraphConfigListItem,
GraphConfigReadResponse,
PipelineRunInfo,
} from "./types";
type EditableAgent = {
id: string;
isDraft: boolean;
graphId: string;
pipelineId: string;
promptSetId?: string;
toolKeys: string[];
prompts: Record<string, string>;
port: number;
llmName: string;
};
const DEFAULT_ENTRY_POINT = "fastapi_server/server_dashscope.py";
const DEFAULT_LLM_NAME = "qwen-plus";
const DEFAULT_PORT = 8100;
const GRAPH_ARCH_IMAGE_MODULES = import.meta.glob(
"../assets/images/graph_arch/*.{png,jpg,jpeg,webp,gif}",
{ eager: true, import: "default" }
) as Record<string, string>;
const FALLBACK_PROMPTS_BY_GRAPH: Record<string, Record<string, string>> = {
routing: {
route_prompt: "",
chat_prompt: "",
tool_prompt: "",
},
react: {
sys_prompt: "",
},
};
function makeAgentKey(pipelineId: string, promptSetId: string): string {
return `${pipelineId}::${promptSetId}`;
}
function parseToolCsv(value: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const token of value.split(",")) {
const trimmed = token.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
out.push(trimmed);
}
return out;
}
function getGraphArchImage(graphId: string): string | null {
const normalizedGraphId = graphId.trim().toLowerCase();
for (const [path, source] of Object.entries(GRAPH_ARCH_IMAGE_MODULES)) {
const fileName = path.split("/").pop() || "";
const baseName = fileName.split(".")[0]?.toLowerCase() || "";
if (baseName === normalizedGraphId) {
return source;
}
}
return null;
}
function toEditable(
config: GraphConfigReadResponse,
draft: boolean
): EditableAgent {
return {
id: draft
? `draft-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
: makeAgentKey(config.pipeline_id, config.prompt_set_id),
isDraft: draft,
graphId: config.graph_id || config.pipeline_id,
pipelineId: config.pipeline_id,
promptSetId: config.prompt_set_id,
toolKeys: config.tool_keys || [],
prompts: config.prompt_dict || {},
port: DEFAULT_PORT,
llmName: DEFAULT_LLM_NAME,
};
}
export default function App() {
const [graphs, setGraphs] = useState<string[]>([]);
const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]);
const [running, setRunning] = useState<PipelineRunInfo[]>([]);
const [draftAgents, setDraftAgents] = useState<EditableAgent[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editor, setEditor] = useState<EditableAgent | null>(null);
const [statusMessage, setStatusMessage] = useState<string>("");
const [busy, setBusy] = useState(false);
const configKeySet = useMemo(
() => new Set(configItems.map((x) => makeAgentKey(x.pipeline_id, x.prompt_set_id))),
[configItems]
);
const visibleConfigItems = useMemo(
() =>
configItems.filter((item) => {
// Hide the pre-seeded template entries (pipeline_id === graph_id, name "default")
if (
item.name.toLowerCase() === "default" &&
item.graph_id &&
item.pipeline_id === item.graph_id
) {
return false;
}
return true;
}),
[configItems]
);
const selectedRuns = useMemo(() => {
if (!editor?.pipelineId) {
return [];
}
return running.filter((run) => {
if (run.pipeline_id !== editor.pipelineId) {
return false;
}
if (!editor.promptSetId) {
return true;
}
return run.prompt_set_id === editor.promptSetId;
});
}, [editor, running]);
async function refreshConfigs(): Promise<void> {
const resp = await listGraphConfigs();
setConfigItems(resp.items);
}
async function refreshRunning(): Promise<void> {
const resp = await listPipelines();
setRunning(resp.items);
}
async function bootstrap(): Promise<void> {
setBusy(true);
setStatusMessage("Loading graphs and agent configs...");
try {
const [graphResp, configResp, runsResp] = await Promise.all([
listAvailableGraphs(),
listGraphConfigs(),
listPipelines(),
]);
setGraphs(graphResp.available_graphs || []);
setConfigItems(configResp.items || []);
setRunning(runsResp.items || []);
setStatusMessage("");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
useEffect(() => {
bootstrap();
const timer = setInterval(() => {
refreshRunning().catch(() => undefined);
}, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (selectedId && !selectedId.startsWith("draft-") && !configKeySet.has(selectedId)) {
setSelectedId(null);
setEditor(null);
}
}, [selectedId, configKeySet]);
async function selectExisting(item: GraphConfigListItem): Promise<void> {
const id = makeAgentKey(item.pipeline_id, item.prompt_set_id);
setSelectedId(id);
setBusy(true);
setStatusMessage("Loading agent details...");
try {
const detail = await getGraphConfig(item.pipeline_id, item.prompt_set_id);
const editable = toEditable(detail, false);
editable.id = id;
editable.port = editor?.pipelineId === editable.pipelineId ? editor.port : DEFAULT_PORT;
editable.llmName = editor?.pipelineId === editable.pipelineId ? editor.llmName : DEFAULT_LLM_NAME;
setEditor(editable);
setStatusMessage("");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function addDraftAgent(): Promise<void> {
const graphId = graphs[0] || "routing";
setBusy(true);
setStatusMessage("Preparing new agent draft...");
try {
const defaults = await loadPromptDefaults(graphId);
const editable = toEditable(defaults, true);
editable.graphId = graphId;
editable.pipelineId = "";
editable.promptSetId = undefined;
editable.id = `draft-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
setDraftAgents((prev) => [editable, ...prev]);
setEditor(editable);
setSelectedId(editable.id);
setStatusMessage("");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function changeGraph(graphId: string): Promise<void> {
if (!editor) {
return;
}
setBusy(true);
setStatusMessage("Loading default prompts for selected graph...");
try {
const defaults = await loadPromptDefaults(graphId);
setEditorAndSyncDraft((prev) => ({
...prev,
graphId,
prompts: { ...defaults.prompt_dict },
toolKeys: defaults.tool_keys || [],
}));
setStatusMessage("");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
function setEditorAndSyncDraft(
updater: (prev: EditableAgent) => EditableAgent
): void {
setEditor((prev) => {
if (!prev) {
return prev;
}
const next = updater(prev);
if (next.isDraft) {
setDraftAgents((drafts) => drafts.map((draft) => (draft.id === next.id ? next : draft)));
}
return next;
});
}
function updateEditor<K extends keyof EditableAgent>(key: K, value: EditableAgent[K]): void {
setEditorAndSyncDraft((prev) => ({ ...prev, [key]: value }));
}
function updatePrompt(key: string, value: string): void {
setEditorAndSyncDraft((prev) => ({
...prev,
prompts: {
...prev.prompts,
[key]: value,
},
}));
}
async function loadPromptDefaults(graphId: string): Promise<GraphConfigReadResponse> {
try {
return await getGraphDefaultConfig(graphId);
} catch {
const fallbackPrompts = FALLBACK_PROMPTS_BY_GRAPH[graphId] || { sys_prompt: "" };
setStatusMessage(
`No backend default config found for '${graphId}'. Using built-in fallback fields.`
);
return {
graph_id: graphId,
pipeline_id: graphId,
prompt_set_id: "default",
tool_keys: [],
prompt_dict: fallbackPrompts,
};
}
}
async function saveConfig(): Promise<void> {
if (!editor) {
return;
}
const promptEntries = Object.entries(editor.prompts);
if (!editor.pipelineId.trim()) {
setStatusMessage("pipeline_id is required.");
return;
}
if (!editor.graphId.trim()) {
setStatusMessage("graph_id is required.");
return;
}
if (promptEntries.length === 0) {
setStatusMessage("At least one prompt field is required.");
return;
}
if (promptEntries.some(([_, content]) => !content.trim())) {
setStatusMessage("All prompt fields must be filled.");
return;
}
setBusy(true);
setStatusMessage("Saving agent config...");
try {
const upsertResp = await upsertGraphConfig({
graph_id: editor.graphId,
pipeline_id: editor.pipelineId.trim(),
prompt_set_id: editor.promptSetId,
tool_keys: editor.toolKeys,
prompt_dict: editor.prompts,
});
await refreshConfigs();
const detail = await getGraphConfig(upsertResp.pipeline_id, upsertResp.prompt_set_id);
const saved = toEditable(detail, false);
saved.id = makeAgentKey(upsertResp.pipeline_id, upsertResp.prompt_set_id);
saved.port = editor.port;
saved.llmName = editor.llmName;
setEditor(saved);
setSelectedId(saved.id);
setDraftAgents((prev) => prev.filter((d) => d.id !== editor.id));
setStatusMessage("Agent config saved.");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function deleteSelected(): Promise<void> {
if (!editor) {
return;
}
if (editor.isDraft || !editor.promptSetId) {
setDraftAgents((prev) => prev.filter((d) => d.id !== editor.id));
setEditor(null);
setSelectedId(null);
setStatusMessage("Draft deleted.");
return;
}
setBusy(true);
setStatusMessage("Deleting agent config...");
try {
await deleteGraphConfig(editor.pipelineId, editor.promptSetId);
await refreshConfigs();
setEditor(null);
setSelectedId(null);
setStatusMessage("Agent deleted.");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function runSelected(): Promise<void> {
if (!editor) {
return;
}
if (!editor.promptSetId) {
setStatusMessage("Save the agent first before running.");
return;
}
if (!editor.pipelineId.trim()) {
setStatusMessage("pipeline_id is required before run.");
return;
}
if (!Number.isInteger(editor.port) || editor.port <= 0) {
setStatusMessage("port must be a positive integer.");
return;
}
setBusy(true);
setStatusMessage("Starting agent...");
try {
const resp = await createPipeline({
graph_id: editor.graphId,
pipeline_id: editor.pipelineId.trim(),
prompt_set_id: editor.promptSetId,
tool_keys: editor.toolKeys,
port: editor.port,
entry_point: DEFAULT_ENTRY_POINT,
llm_name: editor.llmName,
});
await refreshRunning();
setStatusMessage(`Agent started. URL: ${resp.url}`);
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function stopSelected(): Promise<void> {
if (!editor) {
return;
}
const target = selectedRuns[0];
if (!target) {
setStatusMessage("No running instance found for this agent.");
return;
}
setBusy(true);
setStatusMessage("Stopping agent...");
try {
await stopPipeline(target.run_id);
await refreshRunning();
setStatusMessage("Agent stopped.");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
const rows = [
...draftAgents.map((d) => ({
id: d.id,
label: d.pipelineId || "(new agent)",
graphId: d.graphId,
isDraft: true,
})),
...visibleConfigItems.map((item) => ({
id: makeAgentKey(item.pipeline_id, item.prompt_set_id),
label: item.pipeline_id,
graphId: item.graph_id || item.pipeline_id,
isDraft: false,
})),
];
const graphArchImage = editor ? getGraphArchImage(editor.graphId) : null;
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar-header">
<h2>Agents</h2>
<button onClick={addDraftAgent} disabled={busy}>
+ New
</button>
</div>
<div className="agent-list">
{rows.map((row) => (
<button
key={row.id}
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
onClick={() => {
if (row.isDraft) {
const selectedDraft = draftAgents.find((d) => d.id === row.id) || null;
setSelectedId(row.id);
setEditor(selectedDraft);
return;
}
const item = visibleConfigItems.find(
(x) => makeAgentKey(x.pipeline_id, x.prompt_set_id) === row.id
);
if (item) {
selectExisting(item);
}
}}
>
<span>{row.label}</span>
<small>{row.graphId}</small>
</button>
))}
{rows.length === 0 ? <p className="empty">No agents configured yet.</p> : null}
</div>
</aside>
<main className="content">
<header className="content-header">
<h1>Agent Configuration</h1>
<div className="header-actions">
<button onClick={saveConfig} disabled={busy || !editor}>
Save
</button>
<button onClick={runSelected} disabled={busy || !editor}>
Run
</button>
<button onClick={stopSelected} disabled={busy || !editor}>
Stop
</button>
<button onClick={deleteSelected} disabled={busy || !editor}>
Delete
</button>
</div>
</header>
{statusMessage ? <p className="status">{statusMessage}</p> : null}
{!editor ? (
<div className="empty-panel">
<p>Select an agent from the left or create a new one.</p>
</div>
) : (
<section className="form-grid">
<label>
Agent Type (graph_id)
<select
value={editor.graphId}
onChange={(e) => changeGraph(e.target.value)}
disabled={busy}
>
{graphs.map((graph) => (
<option key={graph} value={graph}>
{graph}
</option>
))}
</select>
</label>
{graphArchImage && (
<div className="graph-arch-section">
<h3>Graph Architecture</h3>
<div className="graph-arch-image-container">
<img
src={graphArchImage}
alt={`${editor.graphId} architecture diagram`}
className="graph-arch-image"
/>
</div>
</div>
)}
<label>
pipeline_id
<input
value={editor.pipelineId}
onChange={(e) => updateEditor("pipelineId", e.target.value)}
placeholder="example: routing-agent-1"
disabled={busy}
/>
</label>
<label>
prompt_set_id
<input value={editor.promptSetId || "(assigned on save)"} readOnly />
</label>
<label>
tool_keys (comma separated)
<input
value={editor.toolKeys.join(", ")}
onChange={(e) => updateEditor("toolKeys", parseToolCsv(e.target.value))}
placeholder="tool_a, tool_b"
disabled={busy}
/>
</label>
<label>
port
<input
type="number"
min={1}
value={editor.port}
onChange={(e) => updateEditor("port", Number(e.target.value))}
disabled={busy}
/>
</label>
<label>
llm_name
<input
value={editor.llmName}
onChange={(e) => updateEditor("llmName", e.target.value)}
disabled={busy}
/>
</label>
<div className="prompt-section">
<h3>Prompts</h3>
{Object.keys(editor.prompts).length === 0 ? (
<p className="empty">No prompt keys returned from backend.</p>
) : (
Object.entries(editor.prompts).map(([key, value]) => (
<label key={key}>
{key}
<textarea
value={value}
onChange={(e) => updatePrompt(key, e.target.value)}
rows={4}
disabled={busy}
/>
</label>
))
)}
</div>
<div className="run-info">
<h3>Running Instances</h3>
{selectedRuns.length === 0 ? (
<p className="empty">No active runs for this agent.</p>
) : (
selectedRuns.map((run) => (
<div key={run.run_id} className="run-card">
<div>
<strong>run_id:</strong> {run.run_id}
</div>
<div>
<strong>pid:</strong> {run.pid}
</div>
<div>
<strong>url:</strong>{" "}
<a href={run.url} target="_blank" rel="noreferrer">
{run.url}
</a>
</div>
</div>
))
)}
</div>
</section>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import type {
AvailableGraphsResponse,
GraphConfigListResponse,
GraphConfigReadResponse,
GraphConfigUpsertRequest,
GraphConfigUpsertResponse,
PipelineCreateRequest,
PipelineCreateResponse,
PipelineListResponse,
PipelineStopResponse,
} from "../types";
const API_BASE_URL =
import.meta.env.VITE_FRONT_API_BASE_URL?.trim() || "http://127.0.0.1:8001";
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
...init,
});
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
const payload = (await response.json()) as { detail?: string };
if (payload.detail) {
message = payload.detail;
}
} catch {
// Use fallback message if response is not JSON.
}
throw new Error(message);
}
return (await response.json()) as T;
}
export function listAvailableGraphs(): Promise<AvailableGraphsResponse> {
return fetchJson("/v1/pipelines/graphs");
}
export function listGraphConfigs(
params?: Partial<{ pipeline_id: string; graph_id: string }>
): Promise<GraphConfigListResponse> {
const query = new URLSearchParams();
if (params?.pipeline_id) {
query.set("pipeline_id", params.pipeline_id);
}
if (params?.graph_id) {
query.set("graph_id", params.graph_id);
}
const suffix = query.toString() ? `?${query.toString()}` : "";
return fetchJson(`/v1/graph-configs${suffix}`);
}
export function getGraphConfig(
pipelineId: string,
promptSetId: string
): Promise<GraphConfigReadResponse> {
return fetchJson(`/v1/graph-configs/${pipelineId}/${promptSetId}`);
}
export function getGraphDefaultConfig(
graphId: string
): Promise<GraphConfigReadResponse> {
return fetchJson(`/v1/graphs/${graphId}/default-config`);
}
export function upsertGraphConfig(
payload: GraphConfigUpsertRequest
): Promise<GraphConfigUpsertResponse> {
return fetchJson("/v1/graph-configs", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function deleteGraphConfig(
pipelineId: string,
promptSetId: string
): Promise<{ status: string; pipeline_id: string; prompt_set_id: string }> {
return fetchJson(`/v1/graph-configs/${pipelineId}/${promptSetId}`, {
method: "DELETE",
});
}
export function createPipeline(
payload: PipelineCreateRequest
): Promise<PipelineCreateResponse> {
return fetchJson("/v1/pipelines", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function listPipelines(): Promise<PipelineListResponse> {
return fetchJson("/v1/pipelines");
}
export function stopPipeline(runId: string): Promise<PipelineStopResponse> {
return fetchJson(`/v1/pipelines/${runId}`, {
method: "DELETE",
});
}

11
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

186
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,186 @@
:root {
color-scheme: light;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
line-height: 1.4;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f6f8fb;
color: #1c2430;
}
#root {
min-height: 100vh;
}
.app {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid #dbe2ea;
background: #ffffff;
padding: 16px;
}
.sidebar-header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.sidebar-header h2 {
font-size: 18px;
margin: 0;
}
button {
border: 1px solid #c9d4e2;
border-radius: 8px;
background: #fff;
cursor: pointer;
padding: 8px 10px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.agent-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-item {
align-items: flex-start;
display: flex;
flex-direction: column;
text-align: left;
width: 100%;
}
.agent-item.selected {
border-color: #4d7ef3;
background: #edf3ff;
}
.agent-item small {
color: #5f6f82;
}
.content {
padding: 20px;
}
.content-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.content-header h1 {
font-size: 24px;
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.status {
background: #eaf2ff;
border: 1px solid #bdd3ff;
border-radius: 8px;
color: #163f87;
margin-top: 12px;
padding: 10px;
}
.empty-panel {
margin-top: 30px;
}
.form-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(250px, 1fr));
margin-top: 16px;
}
.form-grid label {
display: flex;
flex-direction: column;
font-size: 14px;
gap: 6px;
}
.form-grid input,
.form-grid select,
.form-grid textarea {
border: 1px solid #c9d4e2;
border-radius: 8px;
font-size: 14px;
padding: 8px;
}
.prompt-section,
.run-info {
border: 1px solid #dbe2ea;
border-radius: 10px;
grid-column: 1 / -1;
padding: 12px;
}
.prompt-section h3,
.run-info h3 {
margin-top: 0;
}
.graph-arch-section {
border: 1px solid #dbe2ea;
border-radius: 10px;
grid-column: 1 / -1;
padding: 12px;
}
.graph-arch-section h3 {
margin-top: 0;
}
.graph-arch-image-container {
align-items: center;
display: flex;
justify-content: center;
overflow: auto;
}
.graph-arch-image {
border-radius: 8px;
border: 1px solid #dbe2ea;
max-width: 100%;
}
.run-card {
border: 1px solid #dbe2ea;
border-radius: 8px;
margin-top: 8px;
padding: 10px;
}
.empty {
color: #687788;
margin: 6px 0;
}

77
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,77 @@
export type GraphConfigListItem = {
graph_id?: string | null;
pipeline_id: string;
prompt_set_id: string;
name: string;
description: string;
is_active: boolean;
tool_keys: string[];
created_at?: string | null;
updated_at?: string | null;
};
export type GraphConfigListResponse = {
items: GraphConfigListItem[];
count: number;
};
export type GraphConfigReadResponse = {
graph_id?: string | null;
pipeline_id: string;
prompt_set_id: string;
tool_keys: string[];
prompt_dict: Record<string, string>;
};
export type GraphConfigUpsertRequest = {
graph_id: string;
pipeline_id: string;
prompt_set_id?: string;
tool_keys: string[];
prompt_dict: Record<string, string>;
};
export type GraphConfigUpsertResponse = {
graph_id: string;
pipeline_id: string;
prompt_set_id: string;
tool_keys: string[];
prompt_keys: string[];
};
export type AvailableGraphsResponse = {
available_graphs: string[];
};
export type PipelineCreateRequest = {
graph_id: string;
pipeline_id: string;
prompt_set_id: string;
tool_keys: string[];
port: number;
entry_point: string;
llm_name: string;
};
export type PipelineRunInfo = {
run_id: string;
pid: number;
graph_id: string;
pipeline_id: string;
prompt_set_id: string;
url: string;
port: number;
};
export type PipelineCreateResponse = PipelineRunInfo;
export type PipelineListResponse = {
items: PipelineRunInfo[];
count: number;
};
export type PipelineStopResponse = {
run_id: string;
status: string;
};

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/frontApis.ts"],"version":"5.9.3"}

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

8
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});

View File

@@ -1,9 +1,9 @@
import tyro import tyro
from lang_agent.graphs.react import ReactGraphConfig from lang_agent.graphs.react import ReactGraphConfig, ReactGraph
from lang_agent.graphs.routing import RoutingConfig from lang_agent.graphs.routing import RoutingConfig, RoutingGraph
from lang_agent.graphs.dual_path import DualConfig from lang_agent.graphs.dual_path import DualConfig, Dual
from lang_agent.graphs.vision_routing import VisionRoutingConfig from lang_agent.graphs.vision_routing import VisionRoutingConfig, VisionRoutingGraph
graph_dict = { graph_dict = {
"react": ReactGraphConfig(), "react": ReactGraphConfig(),

View File

@@ -39,6 +39,34 @@
font-weight: 600; font-weight: 600;
} }
.connection-status {
font-size: 11px;
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-indicator.connected {
background-color: #2ecc71;
box-shadow: 0 0 4px #2ecc71;
}
.status-indicator.disconnected {
background-color: #e74c3c;
}
.status-indicator.connecting {
background-color: #f39c12;
}
.conversation-list { .conversation-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -252,6 +280,10 @@
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>💬 Conversations</h1> <h1>💬 Conversations</h1>
<div class="connection-status">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Connecting...</span>
</div>
</div> </div>
<div class="conversation-list" id="conversationList"> <div class="conversation-list" id="conversationList">
<div class="loading">Loading conversations...</div> <div class="loading">Loading conversations...</div>
@@ -275,6 +307,147 @@
<script> <script>
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let currentConversationId = null; let currentConversationId = null;
let eventSource = null;
let conversationsMap = new Map(); // Track conversations for efficient updates
// Update connection status UI
function updateConnectionStatus(status, text) {
const indicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
indicator.className = 'status-indicator ' + status;
statusText.textContent = text;
}
// Connect to SSE endpoint
function connectSSE() {
if (eventSource) {
eventSource.close();
}
updateConnectionStatus('connecting', 'Connecting...');
eventSource = new EventSource(`${API_BASE}/api/events`);
eventSource.onopen = () => {
updateConnectionStatus('connected', 'Live updates active');
};
eventSource.onerror = () => {
updateConnectionStatus('disconnected', 'Connection lost - reconnecting...');
// EventSource will automatically try to reconnect
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleSSEEvent(data);
} catch (error) {
console.error('Error parsing SSE event:', error);
}
};
}
// Handle SSE events
function handleSSEEvent(data) {
if (data.type === 'error') {
console.error('SSE error:', data.message);
return;
}
if (data.type === 'conversation_new') {
// Add new conversation to the list
addConversationToList(data.conversation);
} else if (data.type === 'conversation_updated') {
// Update existing conversation
updateConversationInList(data.conversation);
// If this is the currently viewed conversation, refresh messages
if (currentConversationId === data.conversation.conversation_id) {
selectConversation(data.conversation.conversation_id, true); // true = silent refresh
}
} else if (data.type === 'conversation_deleted') {
// Remove conversation from list
removeConversationFromList(data.conversation_id);
}
}
// Add a new conversation to the list
function addConversationToList(conversation) {
const listEl = document.getElementById('conversationList');
const existingItem = listEl.querySelector(`[data-id="${conversation.conversation_id}"]`);
if (existingItem) {
// Already exists, just update it
updateConversationInList(conversation);
return;
}
conversationsMap.set(conversation.conversation_id, conversation);
// Create new item
const item = document.createElement('div');
item.className = 'conversation-item';
item.dataset.id = conversation.conversation_id;
item.innerHTML = `
<div class="conversation-id">${conversation.conversation_id}</div>
<div class="conversation-meta">
<span>${formatDate(conversation.last_updated)}</span>
<span class="message-count">${conversation.message_count} msgs</span>
</div>
`;
item.addEventListener('click', () => {
selectConversation(conversation.conversation_id);
});
// Insert at the top (most recent first)
if (listEl.firstChild) {
listEl.insertBefore(item, listEl.firstChild);
} else {
listEl.appendChild(item);
}
}
// Update an existing conversation in the list
function updateConversationInList(conversation) {
conversationsMap.set(conversation.conversation_id, conversation);
const listEl = document.getElementById('conversationList');
const item = listEl.querySelector(`[data-id="${conversation.conversation_id}"]`);
if (item) {
item.querySelector('.conversation-meta span:first-child').textContent = formatDate(conversation.last_updated);
item.querySelector('.message-count').textContent = `${conversation.message_count} msgs`;
// Move to top if it was updated
if (item !== listEl.firstChild) {
listEl.insertBefore(item, listEl.firstChild);
}
} else {
// Doesn't exist yet, add it
addConversationToList(conversation);
}
}
// Remove a conversation from the list
function removeConversationFromList(conversationId) {
conversationsMap.delete(conversationId);
const listEl = document.getElementById('conversationList');
const item = listEl.querySelector(`[data-id="${conversationId}"]`);
if (item) {
item.remove();
// If this was the current conversation, clear the view
if (currentConversationId === conversationId) {
currentConversationId = null;
document.getElementById('conversationIdDisplay').textContent = 'Select a conversation to view messages';
document.getElementById('messagesContainer').innerHTML = '<div class="empty-state"><div>Select a conversation from the left to view messages</div></div>';
}
}
}
// Load conversations on page load // Load conversations on page load
async function loadConversations() { async function loadConversations() {
@@ -285,6 +458,12 @@
const conversations = await response.json(); const conversations = await response.json();
// Store conversations in map
conversationsMap.clear();
conversations.forEach(conv => {
conversationsMap.set(conv.conversation_id, conv);
});
if (conversations.length === 0) { if (conversations.length === 0) {
listEl.innerHTML = '<div class="loading">No conversations found</div>'; listEl.innerHTML = '<div class="loading">No conversations found</div>';
return; return;
@@ -319,7 +498,7 @@
} }
// Select a conversation and load its messages // Select a conversation and load its messages
async function selectConversation(conversationId) { async function selectConversation(conversationId, silentRefresh = false) {
currentConversationId = conversationId; currentConversationId = conversationId;
// Update UI // Update UI
@@ -330,7 +509,11 @@
document.getElementById('conversationIdDisplay').textContent = conversationId; document.getElementById('conversationIdDisplay').textContent = conversationId;
const container = document.getElementById('messagesContainer'); const container = document.getElementById('messagesContainer');
container.innerHTML = '<div class="loading">Loading messages...</div>';
// Only show loading if not a silent refresh
if (!silentRefresh) {
container.innerHTML = '<div class="loading">Loading messages...</div>';
}
try { try {
const response = await fetch(`${API_BASE}/api/conversations/${conversationId}/messages`); const response = await fetch(`${API_BASE}/api/conversations/${conversationId}/messages`);
@@ -343,6 +526,10 @@
return; return;
} }
// Check if we need to preserve scroll position (for silent refresh)
const wasAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50;
const oldScrollHeight = container.scrollHeight;
container.innerHTML = messages.map(msg => { container.innerHTML = messages.map(msg => {
const isHuman = msg.message_type === 'human'; const isHuman = msg.message_type === 'human';
const isTool = msg.message_type === 'tool'; const isTool = msg.message_type === 'tool';
@@ -360,8 +547,15 @@
`; `;
}).join(''); }).join('');
// Scroll to bottom // Scroll to bottom if user was at bottom, or if it's a new selection
container.scrollTop = container.scrollHeight; if (wasAtBottom || !silentRefresh) {
container.scrollTop = container.scrollHeight;
} else {
// Preserve scroll position relative to bottom
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - oldScrollHeight;
container.scrollTop = container.scrollTop + scrollDiff;
}
} catch (error) { } catch (error) {
container.innerHTML = `<div class="error">Error loading messages: ${error.message}</div>`; container.innerHTML = `<div class="error">Error loading messages: ${error.message}</div>`;
console.error('Error loading messages:', error); console.error('Error loading messages:', error);
@@ -388,6 +582,16 @@
// Initialize on page load // Initialize on page load
loadConversations(); loadConversations();
// Connect to SSE for live updates
connectSSE();
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script> </script>
</body> </body>
</html> </html>