Compare commits
10 Commits
75e5214683
...
d4b4ef3690
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b4ef3690 | |||
| 4086d0eba4 | |||
| e8765100f9 | |||
| 2865de6843 | |||
| f662bdb60d | |||
| d3f7144680 | |||
| 37f8708ecf | |||
| 0caf45e360 | |||
| 0ad07402f2 | |||
| eba1ee00e9 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||||
32
README.md
32
README.md
@@ -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
24
frontend/README.md
Normal 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>
|
||||||
|
```
|
||||||
|
|
||||||
BIN
frontend/assets/images/graph_arch/react.png
Normal file
BIN
frontend/assets/images/graph_arch/react.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
frontend/assets/images/graph_arch/routing.png
Normal file
BIN
frontend/assets/images/graph_arch/routing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
1726
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal 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
638
frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
106
frontend/src/api/frontApis.ts
Normal file
106
frontend/src/api/frontApis.ts
Normal 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
11
frontend/src/main.tsx
Normal 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
186
frontend/src/styles.css
Normal 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
77
frontend/src/types.ts
Normal 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
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
1
frontend/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal 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
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user