Compare commits

...

24 Commits

Author SHA1 Message Date
leehuwuj 55f6a28f38 Merge remote-tracking branch 'origin/main' into lee/add-workflow-editor 2025-05-15 14:36:49 +07:00
Thuc Pham 00c4ac1e62 feat: support dev mode for backend ts server (#616)
* feat: support dev mode for backend ts server

* update message

* validate typescript file

* fix: format

* use temp file to avoid server restart

* fix format

* use npx tsc

* remove typescript deps
2025-05-15 13:28:37 +07:00
thucpn 89c9ce8bb5 increase delay time before trigger polling 2025-05-15 11:16:52 +07:00
leehuwuj cd72078b7e fix wrong check 2025-05-15 10:02:31 +07:00
leehuwuj df5017da6c remove unessesary debug log 2025-05-15 09:53:08 +07:00
leehuwuj 31c07d5b5c enhance doc 2025-05-15 09:34:20 +07:00
thucpn 7774021eaa fix minor UI bugs 2025-05-14 16:28:56 +07:00
leehuwuj 1d375d9e6a enhance doc 2025-05-14 14:25:31 +07:00
thucpn fc3b4b6216 enhance UI with shadow overlay 2025-05-14 14:24:56 +07:00
thucpn 44a0f80955 Merge branch 'main' into lee/add-workflow-editor 2025-05-14 14:04:53 +07:00
thucpn 36c9e09469 fix: missing language for code editor 2025-05-14 14:03:48 +07:00
thucpn c3d5d206cb bump chat-ui to fix syntax highlight issue 2025-05-14 11:32:04 +07:00
thucpn 63b670f4a1 fix: polling should work when server not yet started 2025-05-13 18:23:37 +07:00
leehuwuj 3f8868f0f3 Merge remote-tracking branch 'origin/main' into lee/add-workflow-editor 2025-05-13 18:03:33 +07:00
leehuwuj 4dd395ec1d Revert "support devmode for backend ts server"
This reverts commit bd943fd8c1.
2025-05-13 18:03:20 +07:00
thucpn bd943fd8c1 support devmode for backend ts server 2025-05-13 17:51:36 +07:00
leehuwuj 126430553a fix mypy 2025-05-13 17:45:10 +07:00
leehuwuj 138a26deb5 include changes from #614 2025-05-13 17:41:46 +07:00
leehuwuj 8ce137d779 change to file_path 2025-05-13 17:37:11 +07:00
leehuwuj 731fc09358 update doc 2025-05-13 16:33:10 +07:00
leehuwuj d3226e833b fix: update default workflow file path and improve error handling 2025-05-13 16:24:21 +07:00
leehuwuj 97f6c26dd2 feat: Add simple chat app example with FastAPI integration 2025-05-13 14:52:57 +07:00
leehuwuj 3d641bdacd Merge remote-tracking branch 'origin/main' into lee/add-workflow-editor 2025-05-12 16:20:07 +07:00
leehuwuj 9843960484 Add UI components and static assets for chat interface 2025-05-06 13:09:32 +07:00
21 changed files with 3404 additions and 2693 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@llamaindex/server": patch
---
feat: add dev mode UI
@@ -0,0 +1,21 @@
This example shows how to use the dev mode of the server.
First, we need to set `devMode` to `true` in the `uiConfig` of the server.
```ts
new LlamaIndexServer({
workflow: workflowFactory,
uiConfig: {
appTitle: "Calculator",
devMode: true,
},
port: 6000,
}).start();
```
Export OpenAI API key and start the server in dev mode.
```bash
export OPENAI_API_KEY=<your-openai-api-key>
npx tsx watch index.ts
```
+15
View File
@@ -0,0 +1,15 @@
import { LlamaIndexServer } from "@llamaindex/server";
import { workflowFactory } from "./src/app/workflow";
new LlamaIndexServer({
workflow: workflowFactory,
uiConfig: {
appTitle: "Calculator",
devMode: true,
starterQuestions: [
"What is the weather in Tokyo?",
"What is the weather in New York?",
],
},
port: 6005,
}).start();
@@ -0,0 +1,16 @@
import { agent } from "@llamaindex/workflow";
import { tool } from "llamaindex";
import { z } from "zod";
export const workflowFactory = async () => {
return agent({
tools: [
tool({
name: "weather",
description: "Get the weather in a specific city",
parameters: z.object({ city: z.string() }),
execute: ({ city }) => `The weather in ${city} is sunny`,
}),
],
});
};
@@ -13,6 +13,7 @@ import CustomChatMessages from "./chat-messages";
import { DynamicEventsErrors } from "./custom/events/dynamic-events-errors";
import { fetchComponentDefinitions } from "./custom/events/loader";
import { ComponentDef } from "./custom/events/types";
import { DevModePanel } from "./dev-mode-panel";
export default function ChatSection() {
const handler = useChat({
@@ -35,12 +36,13 @@ export default function ChatSection() {
<ChatHeader />
<ChatUI
handler={handler}
className="flex min-h-0 flex-1 flex-row justify-center gap-4 px-4 py-0"
className="relative flex min-h-0 flex-1 flex-row justify-center gap-4 px-4 py-0"
>
<ResizablePanelGroup direction="horizontal">
<ChatSectionPanel />
<ChatCanvasPanel />
</ResizablePanelGroup>
<DevModePanel />
</ChatUI>
</div>
<ChatInjection />
@@ -0,0 +1,270 @@
"use client";
import {
CodeEditor,
fileExtensionToEditorLang,
} from "@llamaindex/chat-ui/widgets";
import { AlertCircle, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../button";
import { getConfig } from "../lib/utils";
const API_PATH = "/api/dev/files/workflow";
const POLLING_TIMEOUT = 30_000; // 30 seconds
type WorkflowFile = {
last_modified: number;
file_path: string;
content: string;
};
export function DevModePanel() {
const devModeEnabled = getConfig("DEV_MODE");
if (!devModeEnabled) return null;
return <DevModePanelComp />;
}
function DevModePanelComp() {
const [devModeOpen, setDevModeOpen] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [fetchingError, setFetchingError] = useState<string | null>();
const [workflowFile, setWorkflowFile] = useState<WorkflowFile | null>(null);
const [updatedCode, setUpdatedCode] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [pollingError, setPollingError] = useState<string | null>(null);
async function fetchWorkflowCode() {
try {
setIsFetching(true);
const response = await fetch(API_PATH);
const data = await response.json();
if (!response.ok) {
throw new Error(data?.detail ?? "Unknown error");
}
setWorkflowFile(data);
setFetchingError(null);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
setFetchingError(errorMessage);
console.warn("Error fetching workflow code:", error);
} finally {
setIsFetching(false);
}
}
async function restartingWorkflow() {
if (!workflowFile) return;
const initialLastModified = workflowFile.last_modified;
setIsPolling(true);
setPollingError(null);
const pollStartTime = Date.now();
// interval refetching the updated workflow code
const poll = async () => {
if (Date.now() - pollStartTime > POLLING_TIMEOUT) {
setPollingError(
`Server not responding after ${POLLING_TIMEOUT / 1000} seconds.`,
);
return;
}
try {
const pollResponse = await fetch(API_PATH);
const pollData = (await pollResponse.json()) as WorkflowFile;
if (pollData.last_modified !== initialLastModified) {
setWorkflowFile(pollData);
setUpdatedCode(pollData.content);
setIsPolling(false);
setPollingError(null);
setDevModeOpen(false);
} else {
setTimeout(poll, 2000);
}
} catch (error) {
console.info("Polling error", error);
setTimeout(poll, 2000);
}
};
setTimeout(poll, 2000);
}
const handleResetCode = () => {
setUpdatedCode(workflowFile?.content ?? null);
setSaveError(null);
};
const handleSaveCode = async () => {
if (!workflowFile) return;
try {
setIsSaving(true);
const response = await fetch(API_PATH, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
content: updatedCode,
file_path: workflowFile.file_path,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.detail ?? "Unknown error");
}
setSaveError(null);
await restartingWorkflow();
} catch (error) {
console.warn("Error saving workflow code:", error);
setSaveError(
error instanceof Error
? error.message
: "Unknown error happened when saving workflow code",
);
} finally {
setIsSaving(false);
}
};
useEffect(() => {
if (devModeOpen) {
fetchWorkflowCode();
}
}, [devModeOpen]);
const codeEditorLanguage = fileExtensionToEditorLang(
workflowFile?.file_path.split(".").pop() ?? "",
);
return (
<>
<Button
onClick={() => setDevModeOpen(!devModeOpen)}
className="fixed right-2 top-1/2 origin-right -translate-y-1/2 rotate-90 transform rounded-l-md shadow-md transition-transform hover:-translate-x-1"
>
Dev Mode
</Button>
{isPolling && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/50 backdrop-blur-sm">
{!pollingError && (
<>
<Loader2 className="mb-4 h-16 w-16 animate-spin text-white" />
<p className="text-lg font-semibold text-white">
Applying changes and restarting server...
</p>
<p className="mt-2 text-sm text-slate-300">
Please wait for a while then you can start chatting with the
updated workflow.
</p>
</>
)}
{pollingError && (
<div className="bg-destructive/20 text-destructive-foreground mt-4 max-w-md rounded-md p-4 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<AlertCircle className="shrink-0" size={16} />
<h6 className="text-sm font-medium">Server Starting Error</h6>
</div>
<p className="text-sm">{pollingError}</p>
<p className="text-sm">
Please reload the page and check server logs.
</p>
</div>
)}
</div>
)}
<div
className={`border-border fixed right-0 top-0 z-10 h-full w-full border-l shadow-xl transition-all duration-300 ease-in-out ${
devModeOpen ? "translate-x-0 bg-black/50" : "translate-x-full"
}`}
onClick={() => setDevModeOpen(false)}
>
<div
className={`bg-background ml-auto flex h-full w-[800px] flex-col p-4`}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Workflow Editor</h2>
<p className="text-muted-foreground text-sm">
{isFetching ? (
"Loading..."
) : workflowFile ? (
<>
Edit the code of <b>{workflowFile.file_path}</b> and save to
apply changes to your workflow.
</>
) : (
""
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setDevModeOpen(false)}
>
Close
</Button>
</div>
<div className="flex-1 overflow-auto">
{fetchingError ? (
<div className="bg-destructive/10 text-destructive/70 mb-4 flex items-center gap-2 rounded-md p-4">
<AlertCircle className="shrink-0" size={16} />
<p className="text-sm font-medium">{fetchingError}</p>
</div>
) : (
<CodeEditor
code={updatedCode ?? workflowFile?.content ?? ""}
onChange={setUpdatedCode}
language={codeEditorLanguage}
/>
)}
</div>
<div className="mt-4 flex flex-col">
{saveError && (
<div className="bg-destructive/10 text-destructive/70 mb-4 rounded-md p-4">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="shrink-0" size={16} />
<h6 className="text-sm font-medium">Error Saving Code</h6>
</div>
<p className="whitespace-pre-wrap text-sm">{saveError}</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
className="mr-2"
onClick={handleResetCode}
>
Reset Code
</Button>
<Button
onClick={handleSaveCode}
disabled={isSaving || !updatedCode || !workflowFile}
>
Save & Restart Server
{isSaving && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</div>
</div>
</div>
</>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@
"@babel/traverse": "^7.27.0",
"@babel/types": "^7.27.0",
"@hookform/resolvers": "^5.0.1",
"@llamaindex/chat-ui": "0.4.3",
"@llamaindex/chat-ui": "0.4.4",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
+84 -1
View File
@@ -1,7 +1,9 @@
import { exec } from "child_process";
import fs from "fs";
import type { IncomingMessage, ServerResponse } from "http";
import path from "path";
import { promisify } from "util";
import { sendJSONResponse } from "../utils/request";
import { parseRequestBody, sendJSONResponse } from "../utils/request";
export const handleServeFiles = async (
req: IncomingMessage,
@@ -21,3 +23,84 @@ export const handleServeFiles = async (
return sendJSONResponse(res, 404, { error: "File not found" });
}
};
const DEFAULT_WORKFLOW_FILE_PATH = "src/app/workflow.ts"; // TODO: we can make it as a parameter in server later
export const getWorkflowFile = async (
req: IncomingMessage,
res: ServerResponse,
filePath: string = DEFAULT_WORKFLOW_FILE_PATH,
) => {
const fileExists = await promisify(fs.exists)(filePath);
if (!fileExists) {
return sendJSONResponse(res, 404, {
detail: `Dev mode is currently in beta. It only supports updating workflow file at ${DEFAULT_WORKFLOW_FILE_PATH}`,
});
}
const content = await promisify(fs.readFile)(filePath, "utf-8");
const last_modified = fs.statSync(filePath).mtime.getTime();
sendJSONResponse(res, 200, { content, file_path: filePath, last_modified });
};
export const updateWorkflowFile = async (
req: IncomingMessage,
res: ServerResponse,
filePath: string = DEFAULT_WORKFLOW_FILE_PATH,
) => {
const body = await parseRequestBody(req);
const { content } = body as { content: string };
const fileExists = await promisify(fs.exists)(filePath);
if (!fileExists) {
return sendJSONResponse(res, 404, {
detail: `Dev mode is currently in beta. It only supports updating workflow file at ${DEFAULT_WORKFLOW_FILE_PATH}`,
});
}
try {
const resolvedFilePath = path.resolve(DEFAULT_WORKFLOW_FILE_PATH);
const result = await validateTypeScriptFile(resolvedFilePath, content);
if (!result.isValid) {
return sendJSONResponse(res, 400, {
detail: result.errors.join("\n"),
});
}
await promisify(fs.writeFile)(filePath, content);
sendJSONResponse(res, 200, { content });
} catch (error) {
console.error("Error updating workflow file:", error);
sendJSONResponse(res, 500, { error: "Failed to update workflow file" });
}
};
// use typescript package to validate the file syntax and imports
async function validateTypeScriptFile(filePath: string, content: string) {
// Update workflow file directly will cause the server restart immediately.
// So we create a temporary file with the same content in the same directory as the workflow file
// This file will be used to validate the file syntax and imports. It will be deleted after validation.
const tempFilePath = path.join(
path.dirname(filePath),
`workflow_${Date.now()}.ts`,
);
fs.writeFileSync(tempFilePath, content);
const errors = [];
try {
const tscCommand = `npx tsc ${tempFilePath} --noEmit --skipLibCheck true`;
await promisify(exec)(tscCommand);
} catch (error) {
const errorMessage = (error as { stdout: string })?.stdout;
errors.push(errorMessage);
} finally {
// Clean up temporary file
if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath);
}
return {
isValid: errors.length === 0,
errors: errors,
};
}
+17 -2
View File
@@ -9,8 +9,13 @@ import { promisify } from "util";
import { handleChat } from "./handlers/chat";
import { getLlamaCloudConfig } from "./handlers/cloud";
import { getComponents } from "./handlers/components";
import { handleServeFiles } from "./handlers/files";
import {
getWorkflowFile,
handleServeFiles,
updateWorkflowFile,
} from "./handlers/files";
import type { LlamaIndexServerOptions } from "./types";
const nextDir = path.join(__dirname, "..", "server");
const configFile = path.join(__dirname, "..", "server", "public", "config.js");
const dev = process.env.NODE_ENV !== "production";
@@ -44,6 +49,7 @@ export class LlamaIndexServer {
? "/api/chat/config/llamacloud"
: undefined;
const componentsApi = this.componentsDir ? "/api/components" : undefined;
const devMode = uiConfig?.devMode ?? false;
// content in javascript format
const content = `
@@ -52,7 +58,8 @@ export class LlamaIndexServer {
APP_TITLE: ${JSON.stringify(appTitle)},
LLAMA_CLOUD_API: ${JSON.stringify(llamaCloudApi)},
STARTER_QUESTIONS: ${JSON.stringify(starterQuestions)},
COMPONENTS_API: ${JSON.stringify(componentsApi)}
COMPONENTS_API: ${JSON.stringify(componentsApi)},
DEV_MODE: ${JSON.stringify(devMode)}
}
`;
fs.writeFileSync(configFile, content);
@@ -96,6 +103,14 @@ export class LlamaIndexServer {
return getLlamaCloudConfig(req, res);
}
if (pathname === "/api/dev/files/workflow" && req.method === "GET") {
return getWorkflowFile(req, res);
}
if (pathname === "/api/dev/files/workflow" && req.method === "PUT") {
return updateWorkflowFile(req, res);
}
const handle = this.app.getRequestHandler();
handle(req, res, parsedUrl);
});
+1
View File
@@ -17,6 +17,7 @@ export type UIConfig = {
starterQuestions?: string[];
componentsDir?: string;
llamaCloudIndexSelector?: boolean;
devMode?: boolean;
};
export type LlamaIndexServerOptions = NextAppOptions & {
+5 -5
View File
@@ -181,8 +181,8 @@ importers:
specifier: ^5.0.1
version: 5.0.1(react-hook-form@7.56.1(react@19.1.0))
'@llamaindex/chat-ui':
specifier: 0.4.3
version: 0.4.3(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.7)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
specifier: 0.4.4
version: 0.4.4(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.7)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@llamaindex/env':
specifier: ^0.1.29
version: 0.1.29
@@ -1177,8 +1177,8 @@ packages:
zod:
optional: true
'@llamaindex/chat-ui@0.4.3':
resolution: {integrity: sha512-JeS5pzEXZRRzTL70lHRtbWbaPjeZuMMHlqkTNBgYqc2eMM+o9VMOzVKKsXoYfOeE+wunqJd+cBrtkECGEmhRZg==}
'@llamaindex/chat-ui@0.4.4':
resolution: {integrity: sha512-sE3mJxlmAV3eiIaqOnioUNYYBLJJL8sy2mdFP4xXDf3PfkcEn0wT2Om/ButpVgIXHlPG9lONtv8mzw7hWq2Atg==}
peerDependencies:
react: ^18.2.0 || ^19.0.0 || ^19.0.0-rc
@@ -7113,7 +7113,7 @@ snapshots:
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
zod: 3.24.3
'@llamaindex/chat-ui@0.4.3(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.7)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@llamaindex/chat-ui@0.4.4(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.7)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-html': 6.4.9
+16
View File
@@ -83,6 +83,7 @@ The LlamaIndexServer accepts the following configuration parameters:
- `ui_path`: Path for downloaded UI static files (default: ".ui")
- `component_dir`: The directory for custom UI components rendering events emitted by the workflow. The default is None, which does not render custom UI components.
- `llamacloud_index_selector`: Whether to show the LlamaCloud index selector in the chat UI (default: False). Requires `LLAMA_CLOUD_API_KEY` to be set.
- `dev_mode`: When enabled, you can update workflow code in the UI and see the changes immediately. It's currently in beta and only supports updating workflow code at `app/workflow.py`. You might also need to set `env="dev"` and start the server with the reload feature enabled.
- `verbose`: Enable verbose logging
- `api_prefix`: API route prefix (default: "/api")
- `server_url`: The deployment URL of the server (default is None)
@@ -120,6 +121,21 @@ In development mode (`env="dev"`), the server:
- Automatically includes the chat UI
- Provides more verbose logging
### Workflow Editor (Beta)
In development mode, you can set `dev_mode` to `True` in the UI configuration to enable the workflow editor, which allows you to edit the workflow code directly in the browser.
```python
app = LlamaIndexServer(
workflow_factory=create_workflow,
env="dev",
ui_config={"dev_mode": True},
)
```
**Note**: The workflow editor is currently in beta and only supports updating LlamaIndexServer projects created with [create-llama](https://github.com/run-llama/create-llama/). You also need to start the server via `fastapi dev` so that the server can hot reload the workflow code.
## API Endpoints
The server provides the following default endpoints:
@@ -0,0 +1,38 @@
# A simple chat app
This guide explains how to set up and use the LlamaIndex server with a simple chatbot agent.
## Prerequisites
- [uv](https://github.com/astral-sh/uv) installed (a fast Python package manager and runner)
- An OpenAI API key
## Steps
1. **Set the OpenAI API Key**
Export your OpenAI API key as an environment variable:
```sh
export OPENAI_API_KEY=your_openai_api_key_here
```
2. **Run the Server Using uv**
Start the server with the following command:
```sh
uv run workflow.py
```
This will launch the FastAPI server using the workflow defined in `main.py`.
3. **Access the Application**
Open your browser and go to:
```
http://localhost:8000
```
You will see the LlamaIndex chat app UI, where you can interact with the agent.
@@ -0,0 +1,14 @@
from typing import Optional
from llama_index.core.agent.workflow import AgentWorkflow
from llama_index.core.settings import Settings
from llama_index.llms.openai import OpenAI
from llama_index.server.api.models import ChatRequest
def create_workflow(chat_request: Optional[ChatRequest] = None) -> AgentWorkflow:
return AgentWorkflow.from_tools_or_functions(
tools_or_functions=[],
llm=Settings.llm or OpenAI(model="gpt-4o-mini"),
system_prompt="You are a helpful assistant that can tell a joke about Llama.",
)
@@ -0,0 +1,29 @@
from app.workflow import create_workflow
from fastapi import FastAPI
from llama_index.server import LlamaIndexServer, UIConfig
def create_app() -> FastAPI:
app = LlamaIndexServer(
workflow_factory=create_workflow,
ui_config=UIConfig(
app_title="Artifact",
starter_questions=[
"Tell me a funny joke.",
"Tell me some jokes about AI.",
],
component_dir="components",
dev_mode=True, # To show the dev UI, should disable this in production
),
)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
@@ -1,4 +1,9 @@
from llama_index.server.api.routers.chat import chat_router
from llama_index.server.api.routers.ui import custom_components_router
from llama_index.server.api.routers.dev import dev_router
__all__ = ["chat_router", "custom_components_router"]
__all__ = [
"chat_router",
"custom_components_router",
"dev_router",
]
@@ -0,0 +1,102 @@
import os
import tempfile
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from llama_index.server.settings import server_settings
from llama_index.server.utils.workflow_validation import validate_workflow_file
class WorkflowFile(BaseModel):
last_modified: int
file_path: str = Field(
default="app/workflow.py",
description="Relative path to the workflow file",
)
content: str
class WorkflowFileUpdate(BaseModel):
content: str
file_path: str = Field(
default="app/workflow.py",
description="Relative path to the workflow file",
)
class WorkflowValidationResult(BaseModel):
valid: bool
error: str
def dev_router() -> APIRouter:
# Use a prefix here to avoid conflicts with other routers
# but we probably don't need to do this
router = APIRouter(prefix="/dev", tags=["dev"])
default_workflow_file_path = "app/workflow.py"
@router.get("/files/workflow")
async def get_workflow_file() -> WorkflowFile:
"""
Fetch the current workflow code
"""
# Check if the file exists
if not os.path.exists(default_workflow_file_path):
raise HTTPException(
status_code=400,
detail="Dev mode is currently in beta. It only supports updating workflow file at 'app/workflow.py'",
)
stat = os.stat(default_workflow_file_path)
with open(default_workflow_file_path, "r") as f:
return WorkflowFile(
last_modified=int(stat.st_mtime),
file_path=default_workflow_file_path,
content=f.read(),
)
@router.post("/files/workflow/validate")
async def validate_workflow(file: WorkflowFileUpdate) -> WorkflowValidationResult:
"""
Validate the current workflow code
"""
try:
if file.file_path != default_workflow_file_path:
raise HTTPException(
status_code=400, detail=f"Updating {file.file_path} is not allowed"
)
validate_workflow_file(
workflow_content=file.content,
factory_signature=server_settings.workflow_factory_signature,
)
return WorkflowValidationResult(valid=True, error="")
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/files/workflow")
async def put_workflow_file(update: WorkflowFileUpdate) -> None:
"""
Update the current workflow code
"""
# Validations
if update.file_path != default_workflow_file_path:
raise HTTPException(
status_code=400, detail=f"Updating {update.file_path} is not allowed"
)
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as tmp:
tmp.write(update.content)
tmp_path = tmp.name
try:
# Validate workflow file using the actual callable name from the workflow_factory
factory_func_name = server_settings.workflow_factory_signature
validate_workflow_file(
workflow_path=tmp_path, factory_signature=factory_func_name
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# If all checks pass, overwrite the real file
with open(default_workflow_file_path, "w") as f:
f.write(update.content)
return router
@@ -10,7 +10,11 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from llama_index.core.workflow import Workflow
from llama_index.server.api.routers import chat_router, custom_components_router
from llama_index.server.api.routers import (
chat_router,
custom_components_router,
dev_router,
)
from llama_index.server.chat_ui import download_chat_ui
from llama_index.server.settings import server_settings
@@ -33,6 +37,9 @@ class UIConfig(BaseModel):
component_dir: Optional[str] = Field(
default=None, description="The directory to custom UI components code"
)
dev_mode: bool = Field(
default=False, description="Whether to enable the UI dev mode"
)
def get_config_content(self) -> str:
return json.dumps(
@@ -46,6 +53,7 @@ class UIConfig(BaseModel):
"COMPONENTS_API": f"{server_settings.api_url}/components"
if self.component_dir
else None,
"DEV_MODE": self.dev_mode,
},
indent=2,
)
@@ -100,24 +108,33 @@ class LlamaIndexServer(FastAPI):
server_settings.set_url(server_url)
if api_prefix:
server_settings.set_api_prefix(api_prefix)
if self.use_default_routers:
self.add_default_routers()
server_settings.set_workflow_factory(workflow_factory.__name__)
if str(env).lower() == "dev":
self.allow_cors("*")
if self.ui_config.enabled is None:
self.ui_config.enabled = True
else:
if self.ui_config.enabled and self.ui_config.dev_mode:
raise ValueError(
"UI dev mode requires the environment variable for LlamaIndexServer to be set to 'dev' and start the FastAPI app in dev mode."
)
if self.ui_config.enabled is None:
self.ui_config.enabled = False
# Routers
if self.use_default_routers:
self.add_default_routers()
# Should mount ui at the end
if self.ui_config.enabled:
self.mount_ui()
# Default routers
def add_default_routers(self) -> None:
self.add_chat_router()
if self.ui_config.enabled and self.ui_config.dev_mode:
self.include_router(dev_router(), prefix=server_settings.api_prefix)
self.mount_data_dir()
self.mount_output_dir()
@@ -11,6 +11,10 @@ class ServerSettings(BaseSettings):
default="/api",
description="The prefix for the API endpoints",
)
workflow_factory_signature: str = Field(
default="",
description="The signature of the workflow factory function",
)
@property
def file_server_url_prefix(self) -> str:
@@ -40,6 +44,9 @@ class ServerSettings(BaseSettings):
self.api_prefix = v
self.validate_api_prefix(v) # type: ignore
def set_workflow_factory(self, v: str) -> None:
self.workflow_factory_signature = v
class Config:
env_file_encoding = "utf-8"
@@ -0,0 +1,54 @@
"""
Utilities for validating workflow.py files (syntax and import checks only).
"""
import ast
import importlib.util
from typing import Optional
def validate_workflow_file(
workflow_path: Optional[str] = None,
workflow_content: Optional[str] = None,
factory_signature: Optional[str] = None,
) -> None:
"""
Validate that the workflow file is syntactically correct, can be imported, and defines a callable factory function with the given name.
Raises an exception if invalid.
"""
if workflow_path is None and workflow_content is None:
raise ValueError("Either workflow_path or workflow_content must be provided")
# 1. Syntax check
if workflow_path is not None:
with open(workflow_path, "r") as f:
content = f.read()
else:
if workflow_content is None:
raise ValueError(
"workflow_content must be provided if workflow_path is not specified"
)
content = workflow_content
try:
ast.parse(content)
except SyntaxError as e:
raise ValueError(f"Syntax error in workflow: {e}")
# 2. Import check (will catch missing modules, etc.)
spec = importlib.util.spec_from_file_location("workflow", workflow_path)
if spec is None or spec.loader is None:
raise ValueError(f"Could not load module specification for {workflow_path}")
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except Exception as e:
raise ValueError(f"Import error: {e}")
# 3. Contract validation: require the given factory function name
if factory_signature:
if not hasattr(mod, factory_signature):
raise ValueError(f"Missing required function: '{factory_signature}'")
obj = getattr(mod, factory_signature)
if not callable(obj):
raise ValueError(f"'{factory_signature}' is not callable")
+2678 -2677
View File
File diff suppressed because it is too large Load Diff