mirror of
https://github.com/run-llama/create-llama.git
synced 2026-07-19 23:13:36 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55f6a28f38 | |||
| 00c4ac1e62 | |||
| 89c9ce8bb5 | |||
| cd72078b7e | |||
| df5017da6c | |||
| 31c07d5b5c | |||
| 7774021eaa | |||
| 1d375d9e6a | |||
| fc3b4b6216 | |||
| 44a0f80955 | |||
| 36c9e09469 | |||
| c3d5d206cb | |||
| 63b670f4a1 | |||
| 3f8868f0f3 | |||
| 4dd395ec1d | |||
| bd943fd8c1 | |||
| 126430553a | |||
| 138a26deb5 | |||
| 8ce137d779 | |||
| 731fc09358 | |||
| d3226e833b | |||
| 97f6c26dd2 | |||
| 3d641bdacd | |||
| 9843960484 |
@@ -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
|
||||||
|
```
|
||||||
@@ -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 { DynamicEventsErrors } from "./custom/events/dynamic-events-errors";
|
||||||
import { fetchComponentDefinitions } from "./custom/events/loader";
|
import { fetchComponentDefinitions } from "./custom/events/loader";
|
||||||
import { ComponentDef } from "./custom/events/types";
|
import { ComponentDef } from "./custom/events/types";
|
||||||
|
import { DevModePanel } from "./dev-mode-panel";
|
||||||
|
|
||||||
export default function ChatSection() {
|
export default function ChatSection() {
|
||||||
const handler = useChat({
|
const handler = useChat({
|
||||||
@@ -35,12 +36,13 @@ export default function ChatSection() {
|
|||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
<ChatUI
|
<ChatUI
|
||||||
handler={handler}
|
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">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
<ChatSectionPanel />
|
<ChatSectionPanel />
|
||||||
<ChatCanvasPanel />
|
<ChatCanvasPanel />
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
<DevModePanel />
|
||||||
</ChatUI>
|
</ChatUI>
|
||||||
</div>
|
</div>
|
||||||
<ChatInjection />
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"@babel/traverse": "^7.27.0",
|
"@babel/traverse": "^7.27.0",
|
||||||
"@babel/types": "^7.27.0",
|
"@babel/types": "^7.27.0",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@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-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
|
import path from "path";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { sendJSONResponse } from "../utils/request";
|
import { parseRequestBody, sendJSONResponse } from "../utils/request";
|
||||||
|
|
||||||
export const handleServeFiles = async (
|
export const handleServeFiles = async (
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
@@ -21,3 +23,84 @@ export const handleServeFiles = async (
|
|||||||
return sendJSONResponse(res, 404, { error: "File not found" });
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import { promisify } from "util";
|
|||||||
import { handleChat } from "./handlers/chat";
|
import { handleChat } from "./handlers/chat";
|
||||||
import { getLlamaCloudConfig } from "./handlers/cloud";
|
import { getLlamaCloudConfig } from "./handlers/cloud";
|
||||||
import { getComponents } from "./handlers/components";
|
import { getComponents } from "./handlers/components";
|
||||||
import { handleServeFiles } from "./handlers/files";
|
import {
|
||||||
|
getWorkflowFile,
|
||||||
|
handleServeFiles,
|
||||||
|
updateWorkflowFile,
|
||||||
|
} from "./handlers/files";
|
||||||
import type { LlamaIndexServerOptions } from "./types";
|
import type { LlamaIndexServerOptions } from "./types";
|
||||||
|
|
||||||
const nextDir = path.join(__dirname, "..", "server");
|
const nextDir = path.join(__dirname, "..", "server");
|
||||||
const configFile = path.join(__dirname, "..", "server", "public", "config.js");
|
const configFile = path.join(__dirname, "..", "server", "public", "config.js");
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
@@ -44,6 +49,7 @@ export class LlamaIndexServer {
|
|||||||
? "/api/chat/config/llamacloud"
|
? "/api/chat/config/llamacloud"
|
||||||
: undefined;
|
: undefined;
|
||||||
const componentsApi = this.componentsDir ? "/api/components" : undefined;
|
const componentsApi = this.componentsDir ? "/api/components" : undefined;
|
||||||
|
const devMode = uiConfig?.devMode ?? false;
|
||||||
|
|
||||||
// content in javascript format
|
// content in javascript format
|
||||||
const content = `
|
const content = `
|
||||||
@@ -52,7 +58,8 @@ export class LlamaIndexServer {
|
|||||||
APP_TITLE: ${JSON.stringify(appTitle)},
|
APP_TITLE: ${JSON.stringify(appTitle)},
|
||||||
LLAMA_CLOUD_API: ${JSON.stringify(llamaCloudApi)},
|
LLAMA_CLOUD_API: ${JSON.stringify(llamaCloudApi)},
|
||||||
STARTER_QUESTIONS: ${JSON.stringify(starterQuestions)},
|
STARTER_QUESTIONS: ${JSON.stringify(starterQuestions)},
|
||||||
COMPONENTS_API: ${JSON.stringify(componentsApi)}
|
COMPONENTS_API: ${JSON.stringify(componentsApi)},
|
||||||
|
DEV_MODE: ${JSON.stringify(devMode)}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(configFile, content);
|
fs.writeFileSync(configFile, content);
|
||||||
@@ -96,6 +103,14 @@ export class LlamaIndexServer {
|
|||||||
return getLlamaCloudConfig(req, res);
|
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();
|
const handle = this.app.getRequestHandler();
|
||||||
handle(req, res, parsedUrl);
|
handle(req, res, parsedUrl);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type UIConfig = {
|
|||||||
starterQuestions?: string[];
|
starterQuestions?: string[];
|
||||||
componentsDir?: string;
|
componentsDir?: string;
|
||||||
llamaCloudIndexSelector?: boolean;
|
llamaCloudIndexSelector?: boolean;
|
||||||
|
devMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LlamaIndexServerOptions = NextAppOptions & {
|
export type LlamaIndexServerOptions = NextAppOptions & {
|
||||||
|
|||||||
Generated
+5
-5
@@ -181,8 +181,8 @@ importers:
|
|||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1(react-hook-form@7.56.1(react@19.1.0))
|
version: 5.0.1(react-hook-form@7.56.1(react@19.1.0))
|
||||||
'@llamaindex/chat-ui':
|
'@llamaindex/chat-ui':
|
||||||
specifier: 0.4.3
|
specifier: 0.4.4
|
||||||
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)
|
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':
|
'@llamaindex/env':
|
||||||
specifier: ^0.1.29
|
specifier: ^0.1.29
|
||||||
version: 0.1.29
|
version: 0.1.29
|
||||||
@@ -1177,8 +1177,8 @@ packages:
|
|||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@llamaindex/chat-ui@0.4.3':
|
'@llamaindex/chat-ui@0.4.4':
|
||||||
resolution: {integrity: sha512-JeS5pzEXZRRzTL70lHRtbWbaPjeZuMMHlqkTNBgYqc2eMM+o9VMOzVKKsXoYfOeE+wunqJd+cBrtkECGEmhRZg==}
|
resolution: {integrity: sha512-sE3mJxlmAV3eiIaqOnioUNYYBLJJL8sy2mdFP4xXDf3PfkcEn0wT2Om/ButpVgIXHlPG9lONtv8mzw7hWq2Atg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.2.0 || ^19.0.0 || ^19.0.0-rc
|
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)
|
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
|
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:
|
dependencies:
|
||||||
'@codemirror/lang-css': 6.3.1
|
'@codemirror/lang-css': 6.3.1
|
||||||
'@codemirror/lang-html': 6.4.9
|
'@codemirror/lang-html': 6.4.9
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ The LlamaIndexServer accepts the following configuration parameters:
|
|||||||
- `ui_path`: Path for downloaded UI static files (default: ".ui")
|
- `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.
|
- `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.
|
- `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
|
- `verbose`: Enable verbose logging
|
||||||
- `api_prefix`: API route prefix (default: "/api")
|
- `api_prefix`: API route prefix (default: "/api")
|
||||||
- `server_url`: The deployment URL of the server (default is None)
|
- `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
|
- Automatically includes the chat UI
|
||||||
- Provides more verbose logging
|
- 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
|
## API Endpoints
|
||||||
|
|
||||||
The server provides the following default 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.chat import chat_router
|
||||||
from llama_index.server.api.routers.ui import custom_components_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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from llama_index.core.workflow import Workflow
|
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.chat_ui import download_chat_ui
|
||||||
from llama_index.server.settings import server_settings
|
from llama_index.server.settings import server_settings
|
||||||
|
|
||||||
@@ -33,6 +37,9 @@ class UIConfig(BaseModel):
|
|||||||
component_dir: Optional[str] = Field(
|
component_dir: Optional[str] = Field(
|
||||||
default=None, description="The directory to custom UI components code"
|
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:
|
def get_config_content(self) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
@@ -46,6 +53,7 @@ class UIConfig(BaseModel):
|
|||||||
"COMPONENTS_API": f"{server_settings.api_url}/components"
|
"COMPONENTS_API": f"{server_settings.api_url}/components"
|
||||||
if self.component_dir
|
if self.component_dir
|
||||||
else None,
|
else None,
|
||||||
|
"DEV_MODE": self.dev_mode,
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
@@ -100,24 +108,33 @@ class LlamaIndexServer(FastAPI):
|
|||||||
server_settings.set_url(server_url)
|
server_settings.set_url(server_url)
|
||||||
if api_prefix:
|
if api_prefix:
|
||||||
server_settings.set_api_prefix(api_prefix)
|
server_settings.set_api_prefix(api_prefix)
|
||||||
|
server_settings.set_workflow_factory(workflow_factory.__name__)
|
||||||
if self.use_default_routers:
|
|
||||||
self.add_default_routers()
|
|
||||||
|
|
||||||
if str(env).lower() == "dev":
|
if str(env).lower() == "dev":
|
||||||
self.allow_cors("*")
|
self.allow_cors("*")
|
||||||
if self.ui_config.enabled is None:
|
if self.ui_config.enabled is None:
|
||||||
self.ui_config.enabled = True
|
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:
|
if self.ui_config.enabled is None:
|
||||||
self.ui_config.enabled = False
|
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:
|
if self.ui_config.enabled:
|
||||||
self.mount_ui()
|
self.mount_ui()
|
||||||
|
|
||||||
# Default routers
|
# Default routers
|
||||||
def add_default_routers(self) -> None:
|
def add_default_routers(self) -> None:
|
||||||
self.add_chat_router()
|
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_data_dir()
|
||||||
self.mount_output_dir()
|
self.mount_output_dir()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class ServerSettings(BaseSettings):
|
|||||||
default="/api",
|
default="/api",
|
||||||
description="The prefix for the API endpoints",
|
description="The prefix for the API endpoints",
|
||||||
)
|
)
|
||||||
|
workflow_factory_signature: str = Field(
|
||||||
|
default="",
|
||||||
|
description="The signature of the workflow factory function",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_server_url_prefix(self) -> str:
|
def file_server_url_prefix(self) -> str:
|
||||||
@@ -40,6 +44,9 @@ class ServerSettings(BaseSettings):
|
|||||||
self.api_prefix = v
|
self.api_prefix = v
|
||||||
self.validate_api_prefix(v) # type: ignore
|
self.validate_api_prefix(v) # type: ignore
|
||||||
|
|
||||||
|
def set_workflow_factory(self, v: str) -> None:
|
||||||
|
self.workflow_factory_signature = v
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file_encoding = "utf-8"
|
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")
|
||||||
Generated
+2678
-2677
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user