Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot] 51dc0e4334 Release 0.2.14 (#337)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-03 17:14:02 +07:00
Thuc Pham 5a7216e36d feat: implement artifact tool in TS (#328)
---------

Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-10-03 17:13:17 +07:00
github-actions[bot] 27a1b9fdf2 Release 0.2.13 (#335)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-02 17:45:23 +07:00
Huu Le 04ddebcd64 feat: Add publisher agent, merge code with streaming template (#324)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-10-02 17:44:33 +07:00
Marcus Schiesser 3e8057a83a improve saveDocument 2024-10-01 16:22:22 +07:00
Marcus Schiesser 12ed570a53 refactor: make saveDocument reusable (#332) 2024-10-01 12:39:42 +07:00
Marcus Schiesser bde3daae08 reorganize e2e tests (split Python and TS) (#329)
---------
Co-authored-by: leehuwuj <leehuwuj@gmail.com>
2024-10-01 11:50:21 +07:00
github-actions[bot] 727eb105ea Release 0.2.12 (#327)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-27 15:17:08 +07:00
Thuc Pham ef070c0b4b feat: support multi agent for ts (#300)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-09-26 17:11:49 +07:00
Thuc Pham 70f7dcacc8 feat: add test deps for llamaparse (#323) 2024-09-26 09:49:40 +07:00
89 changed files with 3401 additions and 1372 deletions
+74 -7
View File
@@ -9,16 +9,16 @@ env:
POETRY_VERSION: "1.6.1"
jobs:
e2e:
name: create-llama
e2e-python:
name: python
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
node-version: [18, 20]
node-version: [20]
python-version: ["3.11"]
os: [macos-latest, windows-latest, ubuntu-22.04]
frameworks: ["nextjs", "express", "fastapi"]
frameworks: ["fastapi"]
datasources: ["--no-files", "--example-file"]
defaults:
run:
@@ -60,8 +60,8 @@ jobs:
run: pnpm run pack-install
working-directory: .
- name: Run Playwright tests
run: pnpm run e2e
- name: Run Playwright tests for Python
run: pnpm run e2e:python
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLAMA_CLOUD_API_KEY: ${{ secrets.LLAMA_CLOUD_API_KEY }}
@@ -72,6 +72,73 @@ jobs:
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
name: playwright-report-python
path: ./playwright-report/
retention-days: 30
e2e-typescript:
name: typescript
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
node-version: [18, 20]
python-version: ["3.11"]
os: [macos-latest, windows-latest, ubuntu-22.04]
frameworks: ["nextjs", "express"]
datasources: ["--no-files", "--example-file"]
defaults:
run:
shell: bash
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: ${{ env.POETRY_VERSION }}
- uses: pnpm/action-setup@v3
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
working-directory: .
- name: Build create-llama
run: pnpm run build
working-directory: .
- name: Install
run: pnpm run pack-install
working-directory: .
- name: Run Playwright tests for TypeScript
run: pnpm run e2e:typescript
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLAMA_CLOUD_API_KEY: ${{ secrets.LLAMA_CLOUD_API_KEY }}
FRAMEWORK: ${{ matrix.frameworks }}
DATASOURCE: ${{ matrix.datasources }}
working-directory: .
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-typescript
path: ./playwright-report/
retention-days: 30
+20
View File
@@ -1,5 +1,25 @@
# create-llama
## 0.2.14
### Patch Changes
- 5a7216e: feat: implement artifact tool in TS
## 0.2.13
### Patch Changes
- 04ddebc: Add publisher agent to multi-agents for generating documents (PDF and HTML)
- 04ddebc: Allow tool selection for multi-agents (Python and TS)
## 0.2.12
### Patch Changes
- 70f7dca: feat: add test deps for llamaparse
- ef070c0: Add multi agents template for Typescript
## 0.2.11
### Patch Changes
@@ -3,8 +3,8 @@ import { exec } from "child_process";
import fs from "fs";
import path from "path";
import util from "util";
import { TemplateFramework, TemplateVectorDB } from "../helpers/types";
import { createTestDir, runCreateLlama } from "./utils";
import { TemplateFramework, TemplateVectorDB } from "../../helpers/types";
import { createTestDir, runCreateLlama } from "../utils";
const execAsync = util.promisify(exec);
@@ -16,8 +16,6 @@ const dataSource: string = process.env.DATASOURCE
: "--example-file";
if (
templateFramework == "fastapi" && // test is only relevant for fastapi
process.version.startsWith("v20.") && // XXX: Only run for Node.js version 20 (CI matrix will trigger other versions)
dataSource === "--example-file" // XXX: this test provides its own data source - only trigger it on one data source (usually the CI matrix will trigger multiple data sources)
) {
// vectorDBs, tools, and data source combinations to test
@@ -35,6 +33,7 @@ if (
const toolOptions = [
"wikipedia.WikipediaToolSpec",
"google.GoogleSearchToolSpec",
"document_generator",
];
const dataSources = [
@@ -53,21 +52,21 @@ if (
test(`options: ${optionDescription}`, async () => {
const cwd = await createTestDir();
const result = await runCreateLlama(
const result = await runCreateLlama({
cwd,
"streaming",
"fastapi",
templateType: "streaming",
templateFramework,
dataSource,
vectorDb,
3000, // port
8000, // externalPort
"none", // postInstallAction
undefined, // ui
"--no-frontend", // appType
undefined, // llamaCloudProjectName
undefined, // llamaCloudIndexName
tool,
);
port: 3000, // port
externalPort: 8000, // externalPort
postInstallAction: "none", // postInstallAction
templateUI: undefined, // ui
appType: "--no-frontend", // appType
llamaCloudProjectName: undefined, // llamaCloudProjectName
llamaCloudIndexName: undefined, // llamaCloudIndexName
tools: tool,
});
const name = result.projectName;
// Check if the app folder exists
-97
View File
@@ -1,97 +0,0 @@
import { expect, test } from "@playwright/test";
import { exec } from "child_process";
import fs from "fs";
import path from "path";
import util from "util";
import { TemplateFramework, TemplateVectorDB } from "../helpers/types";
import { createTestDir, runCreateLlama } from "./utils";
const execAsync = util.promisify(exec);
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
: "nextjs";
const dataSource: string = process.env.DATASOURCE
? process.env.DATASOURCE
: "--example-file";
if (
templateFramework == "nextjs" ||
templateFramework == "express" // test is only relevant for TS projects
) {
// vectorDBs combinations to test
const vectorDbs: TemplateVectorDB[] = [
"mongo",
"pg",
"qdrant",
"pinecone",
"milvus",
"astra",
"chroma",
"llamacloud",
"weaviate",
];
test.describe("Test resolve TS dependencies", () => {
for (const vectorDb of vectorDbs) {
const optionDescription = `vectorDb: ${vectorDb}, dataSource: ${dataSource}`;
test(`options: ${optionDescription}`, async () => {
const cwd = await createTestDir();
const result = await runCreateLlama(
cwd,
"streaming",
templateFramework,
dataSource,
vectorDb,
3000, // port
8000, // externalPort
"none", // postInstallAction
undefined, // ui
templateFramework === "nextjs" ? "" : "--no-frontend", // appType
undefined, // llamaCloudProjectName
undefined, // llamaCloudIndexName
);
const name = result.projectName;
// Check if the app folder exists
const appDir = path.join(cwd, name);
const dirExists = fs.existsSync(appDir);
expect(dirExists).toBeTruthy();
// Install dependencies using pnpm
try {
const { stderr: installStderr } = await execAsync(
"pnpm install --prefer-offline",
{
cwd: appDir,
},
);
expect(installStderr).toBeFalsy();
} catch (error) {
console.error("Error installing dependencies:", error);
throw error;
}
// Run tsc type check and capture the output
try {
const { stdout, stderr } = await execAsync(
"pnpm exec tsc -b --diagnostics",
{
cwd: appDir,
},
);
// Check if there's any error output
expect(stderr).toBeFalsy();
// Log the stdout for debugging purposes
console.log("TypeScript type-check output:", stdout);
} catch (error) {
console.error("Error running tsc:", error);
throw error;
}
});
}
});
}
@@ -3,8 +3,8 @@ import { expect, test } from "@playwright/test";
import { ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import { TemplateFramework } from "../helpers";
import { createTestDir, runCreateLlama } from "./utils";
import { TemplateFramework } from "../../helpers";
import { createTestDir, runCreateLlama } from "../utils";
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
@@ -16,9 +16,8 @@ const dataSource: string = process.env.DATASOURCE
// The extractor template currently only works with FastAPI and files (and not on Windows)
if (
process.platform !== "win32" &&
templateFramework !== "nextjs" &&
templateFramework !== "express" &&
dataSource !== "--no-files"
templateFramework === "fastapi" &&
dataSource === "--example-file"
) {
test.describe("Test extractor template", async () => {
let frontendPort: number;
@@ -32,16 +31,16 @@ if (
cwd = await createTestDir();
frontendPort = Math.floor(Math.random() * 10000) + 10000;
backendPort = frontendPort + 1;
const result = await runCreateLlama(
const result = await runCreateLlama({
cwd,
"extractor",
"fastapi",
"--example-file",
"none",
frontendPort,
backendPort,
"runApp",
);
templateType: "extractor",
templateFramework: "fastapi",
dataSource: "--example-file",
vectorDb: "none",
port: frontendPort,
externalPort: backendPort,
postInstallAction: "runApp",
});
name = result.projectName;
appProcess = result.appProcess;
});
@@ -7,22 +7,22 @@ import type {
TemplateFramework,
TemplatePostInstallAction,
TemplateUI,
} from "../helpers";
import { createTestDir, runCreateLlama, type AppType } from "./utils";
} from "../../helpers";
import { createTestDir, runCreateLlama, type AppType } from "../utils";
const templateFramework: TemplateFramework = "fastapi";
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
: "fastapi";
const dataSource: string = "--example-file";
const templateUI: TemplateUI = "shadcn";
const templatePostInstallAction: TemplatePostInstallAction = "runApp";
const appType: AppType = "--frontend";
const appType: AppType = templateFramework === "nextjs" ? "" : "--frontend";
const userMessage = "Write a blog post about physical standards for letters";
test.describe(`Test multiagent template ${templateFramework} ${dataSource} ${templateUI} ${appType} ${templatePostInstallAction}`, async () => {
test.skip(
process.platform !== "linux" ||
process.env.FRAMEWORK !== "fastapi" ||
process.env.DATASOURCE === "--no-files",
"The multiagent template currently only works with FastAPI and files. We also only run on Linux to speed up tests.",
process.platform !== "linux" || process.env.DATASOURCE === "--no-files",
"The multiagent template currently only works with files. We also only run on Linux to speed up tests.",
);
let port: number;
let externalPort: number;
@@ -36,18 +36,18 @@ test.describe(`Test multiagent template ${templateFramework} ${dataSource} ${tem
port = Math.floor(Math.random() * 10000) + 10000;
externalPort = port + 1;
cwd = await createTestDir();
const result = await runCreateLlama(
const result = await runCreateLlama({
cwd,
"multiagent",
templateType: "multiagent",
templateFramework,
dataSource,
vectorDb,
port,
externalPort,
templatePostInstallAction,
postInstallAction: templatePostInstallAction,
templateUI,
appType,
);
});
name = result.projectName;
appProcess = result.appProcess;
});
@@ -66,7 +66,7 @@ test.describe(`Test multiagent template ${templateFramework} ${dataSource} ${tem
page,
}) => {
await page.goto(`http://localhost:${port}`);
await page.fill("form input", userMessage);
await page.fill("form textarea", userMessage);
const responsePromise = page.waitForResponse((res) =>
res.url().includes("/api/chat"),
@@ -7,8 +7,8 @@ import type {
TemplateFramework,
TemplatePostInstallAction,
TemplateUI,
} from "../helpers";
import { createTestDir, runCreateLlama, type AppType } from "./utils";
} from "../../helpers";
import { createTestDir, runCreateLlama, type AppType } from "../utils";
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
@@ -39,20 +39,20 @@ test.describe(`Test streaming template ${templateFramework} ${dataSource} ${temp
port = Math.floor(Math.random() * 10000) + 10000;
externalPort = port + 1;
cwd = await createTestDir();
const result = await runCreateLlama(
const result = await runCreateLlama({
cwd,
"streaming",
templateType: "streaming",
templateFramework,
dataSource,
vectorDb,
port,
externalPort,
templatePostInstallAction,
postInstallAction: templatePostInstallAction,
templateUI,
appType,
llamaCloudProjectName,
llamaCloudIndexName,
);
});
name = result.projectName;
appProcess = result.appProcess;
});
@@ -72,7 +72,7 @@ test.describe(`Test streaming template ${templateFramework} ${dataSource} ${temp
}) => {
test.skip(templatePostInstallAction !== "runApp");
await page.goto(`http://localhost:${port}`);
await page.fill("form input", userMessage);
await page.fill("form textarea", userMessage);
const [response] = await Promise.all([
page.waitForResponse(
(res) => {
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
import { exec } from "child_process";
import fs from "fs";
import path from "path";
import util from "util";
import { TemplateFramework, TemplateVectorDB } from "../../helpers/types";
import { createTestDir, runCreateLlama } from "../utils";
const execAsync = util.promisify(exec);
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
: "nextjs";
const dataSource: string = process.env.DATASOURCE
? process.env.DATASOURCE
: "--example-file";
// vectorDBs combinations to test
const vectorDbs: TemplateVectorDB[] = [
"mongo",
"pg",
"qdrant",
"pinecone",
"milvus",
"astra",
"chroma",
"llamacloud",
"weaviate",
];
test.describe("Test resolve TS dependencies", () => {
// Test vector DBs without LlamaParse
for (const vectorDb of vectorDbs) {
const optionDescription = `vectorDb: ${vectorDb}, dataSource: ${dataSource}`;
test(`Vector DB test - ${optionDescription}`, async () => {
await runTest(vectorDb, false);
});
}
// Test LlamaParse with vectorDB 'none'
test(`LlamaParse test - vectorDb: none, dataSource: ${dataSource}, llamaParse: true`, async () => {
await runTest("none", true);
});
async function runTest(
vectorDb: TemplateVectorDB | "none",
useLlamaParse: boolean,
) {
const cwd = await createTestDir();
const result = await runCreateLlama({
cwd: cwd,
templateType: "streaming",
templateFramework: templateFramework,
dataSource: dataSource,
vectorDb: vectorDb,
port: 3000,
externalPort: 8000,
postInstallAction: "none",
templateUI: undefined,
appType: templateFramework === "nextjs" ? "" : "--no-frontend",
llamaCloudProjectName: undefined,
llamaCloudIndexName: undefined,
tools: undefined,
useLlamaParse: useLlamaParse,
});
const name = result.projectName;
// Check if the app folder exists
const appDir = path.join(cwd, name);
const dirExists = fs.existsSync(appDir);
expect(dirExists).toBeTruthy();
// Install dependencies using pnpm
try {
const { stderr: installStderr } = await execAsync(
"pnpm install --prefer-offline",
{
cwd: appDir,
},
);
} catch (error) {
console.error("Error installing dependencies:", error);
throw error;
}
// Run tsc type check and capture the output
try {
const { stdout, stderr } = await execAsync(
"pnpm exec tsc -b --diagnostics",
{
cwd: appDir,
},
);
// Check if there's any error output
expect(stderr).toBeFalsy();
// Log the stdout for debugging purposes
console.log("TypeScript type-check output:", stdout);
} catch (error) {
console.error("Error running tsc:", error);
throw error;
}
}
});
+38 -17
View File
@@ -18,22 +18,39 @@ export type CreateLlamaResult = {
appProcess: ChildProcess;
};
// eslint-disable-next-line max-params
export async function runCreateLlama(
cwd: string,
templateType: TemplateType,
templateFramework: TemplateFramework,
dataSource: string,
vectorDb: TemplateVectorDB,
port: number,
externalPort: number,
postInstallAction: TemplatePostInstallAction,
templateUI?: TemplateUI,
appType?: AppType,
llamaCloudProjectName?: string,
llamaCloudIndexName?: string,
tools?: string,
): Promise<CreateLlamaResult> {
export type RunCreateLlamaOptions = {
cwd: string;
templateType: TemplateType;
templateFramework: TemplateFramework;
dataSource: string;
vectorDb: TemplateVectorDB;
port: number;
externalPort: number;
postInstallAction: TemplatePostInstallAction;
templateUI?: TemplateUI;
appType?: AppType;
llamaCloudProjectName?: string;
llamaCloudIndexName?: string;
tools?: string;
useLlamaParse?: boolean;
};
export async function runCreateLlama({
cwd,
templateType,
templateFramework,
dataSource,
vectorDb,
port,
externalPort,
postInstallAction,
templateUI,
appType,
llamaCloudProjectName,
llamaCloudIndexName,
tools,
useLlamaParse,
}: RunCreateLlamaOptions): Promise<CreateLlamaResult> {
if (!process.env.OPENAI_API_KEY || !process.env.LLAMA_CLOUD_API_KEY) {
throw new Error(
"Setting the OPENAI_API_KEY and LLAMA_CLOUD_API_KEY is mandatory to run tests",
@@ -80,7 +97,6 @@ export async function runCreateLlama(
postInstallAction,
"--tools",
tools ?? "none",
"--no-llama-parse",
"--observability",
"none",
"--llama-cloud-key",
@@ -93,6 +109,11 @@ export async function runCreateLlama(
if (appType) {
commandArgs.push(appType);
}
if (useLlamaParse) {
commandArgs.push("--use-llama-parse");
} else {
commandArgs.push("--no-llama-parse");
}
const command = commandArgs.join(" ");
console.log(`running command '${command}' in ${cwd}`);
+20 -25
View File
@@ -397,12 +397,6 @@ const getEngineEnvs = (): EnvVar[] => {
description:
"The number of similar embeddings to return when retrieving documents.",
},
{
name: "STREAM_TIMEOUT",
description:
"The time in milliseconds to wait for the stream to return a response.",
value: "60000",
},
];
};
@@ -426,34 +420,35 @@ const getToolEnvs = (tools?: Tool[]): EnvVar[] => {
const getSystemPromptEnv = (
tools?: Tool[],
dataSources?: TemplateDataSource[],
framework?: TemplateFramework,
template?: TemplateType,
): EnvVar[] => {
const defaultSystemPrompt =
"You are a helpful assistant who helps users with their questions.";
const systemPromptEnv: EnvVar[] = [];
// build tool system prompt by merging all tool system prompts
let toolSystemPrompt = "";
tools?.forEach((tool) => {
const toolSystemPromptEnv = tool.envVars?.find(
(env) => env.name === TOOL_SYSTEM_PROMPT_ENV_VAR,
);
if (toolSystemPromptEnv) {
toolSystemPrompt += toolSystemPromptEnv.value + "\n";
}
});
// multiagent template doesn't need system prompt
if (template !== "multiagent") {
let toolSystemPrompt = "";
tools?.forEach((tool) => {
const toolSystemPromptEnv = tool.envVars?.find(
(env) => env.name === TOOL_SYSTEM_PROMPT_ENV_VAR,
);
if (toolSystemPromptEnv) {
toolSystemPrompt += toolSystemPromptEnv.value + "\n";
}
});
const systemPrompt = toolSystemPrompt
? `\"${toolSystemPrompt}\"`
: defaultSystemPrompt;
const systemPrompt = toolSystemPrompt
? `\"${toolSystemPrompt}\"`
: defaultSystemPrompt;
const systemPromptEnv = [
{
systemPromptEnv.push({
name: "SYSTEM_PROMPT",
description: "The system prompt for the AI model.",
value: systemPrompt,
},
];
});
}
if (tools?.length == 0 && (dataSources?.length ?? 0 > 0)) {
const citationPrompt = `'You have provided information from a knowledge base that has been passed to you in nodes of information.
Each node has useful metadata such as node ID, file name, page, etc.
@@ -559,7 +554,7 @@ export const createBackendEnvFile = async (
...getToolEnvs(opts.tools),
...getTemplateEnvs(opts.template),
...getObservabilityEnvs(opts.observability),
...getSystemPromptEnv(opts.tools, opts.dataSources, opts.framework),
...getSystemPromptEnv(opts.tools, opts.dataSources, opts.template),
];
// Render and write env file
const content = renderEnvVar(envVars);
+32 -8
View File
@@ -364,7 +364,12 @@ export const installPythonTemplate = async ({
| "modelConfig"
>) => {
console.log("\nInitializing Python project with template:", template, "\n");
const templatePath = path.join(templatesDir, "types", template, framework);
let templatePath;
if (template === "extractor") {
templatePath = path.join(templatesDir, "types", "extractor", framework);
} else {
templatePath = path.join(templatesDir, "types", "streaming", framework);
}
await copy("**", root, {
parents: true,
cwd: templatePath,
@@ -401,23 +406,42 @@ export const installPythonTemplate = async ({
cwd: path.join(compPath, "services", "python"),
});
}
if (template === "streaming") {
// For the streaming template only:
// Copy engine code
if (template === "streaming" || template === "multiagent") {
// Select and copy engine code based on data sources and tools
let engine;
if (dataSources.length > 0 && (!tools || tools.length === 0)) {
console.log("\nNo tools selected - use optimized context chat engine\n");
engine = "chat";
} else {
// Multiagent always uses agent engine
if (template === "multiagent") {
engine = "agent";
} else {
// For streaming, use chat engine by default
// Unless tools are selected, in which case use agent engine
if (dataSources.length > 0 && (!tools || tools.length === 0)) {
console.log(
"\nNo tools selected - use optimized context chat engine\n",
);
engine = "chat";
} else {
engine = "agent";
}
}
// Copy engine code
await copy("**", enginePath, {
parents: true,
cwd: path.join(compPath, "engines", "python", engine),
});
}
if (template === "multiagent") {
// Copy multi-agent code
await copy("**", path.join(root), {
parents: true,
cwd: path.join(compPath, "multiagent", "python"),
rename: assetRelocator,
});
}
console.log("Adding additional dependencies");
const addOnDependencies = getAdditionalDependencies(
+43
View File
@@ -110,6 +110,29 @@ For better results, you can specify the region parameter to get results from a s
},
],
},
{
display: "Document generator",
name: "document_generator",
supportedFrameworks: ["fastapi", "nextjs", "express"],
dependencies: [
{
name: "xhtml2pdf",
version: "^0.2.14",
},
{
name: "markdown",
version: "^3.7",
},
],
type: ToolType.LOCAL,
envVars: [
{
name: TOOL_SYSTEM_PROMPT_ENV_VAR,
description: "System prompt for document generator tool.",
value: `If user request for a report or a post, use document generator tool to create a file and reply with the link to the file.`,
},
],
},
{
display: "Code Interpreter",
name: "interpreter",
@@ -139,6 +162,26 @@ For better results, you can specify the region parameter to get results from a s
},
],
},
{
display: "Artifact Code Generator",
name: "artifact",
dependencies: [],
supportedFrameworks: ["express", "nextjs"],
type: ToolType.LOCAL,
envVars: [
{
name: "E2B_API_KEY",
description:
"E2B_API_KEY key is required to run artifact code generator tool. Get it here: https://e2b.dev/docs/getting-started/api-key",
},
{
name: TOOL_SYSTEM_PROMPT_ENV_VAR,
description: "System prompt for artifact code generator tool.",
value:
"You are a code assistant that can generate and execute code using its tools. Don't generate code yourself, use the provided tools instead. Do not show the code or sandbox url in chat, just describe the steps to build the application based on the code that is generated by your tools. Do not describe how to run the code, just the steps to build the application.",
},
],
},
{
display: "OpenAPI action",
name: "openapi_action.OpenAPIActionToolSpec",
+34 -3
View File
@@ -33,8 +33,7 @@ export const installTSTemplate = async ({
* Copy the template files to the target directory.
*/
console.log("\nInitializing project with template:", template, "\n");
const type = template === "multiagent" ? "streaming" : template; // use nextjs streaming template for multiagent
const templatePath = path.join(templatesDir, "types", type, framework);
const templatePath = path.join(templatesDir, "types", "streaming", framework);
const copySource = ["**"];
await copy(copySource, root, {
@@ -124,6 +123,30 @@ export const installTSTemplate = async ({
cwd: path.join(compPath, "vectordbs", "typescript", vectorDb ?? "none"),
});
if (template === "multiagent") {
const multiagentPath = path.join(compPath, "multiagent", "typescript");
// copy workflow code for multiagent template
await copy("**", path.join(root, relativeEngineDestPath, "workflow"), {
parents: true,
cwd: path.join(multiagentPath, "workflow"),
});
if (framework === "nextjs") {
// patch route.ts file
await copy("**", path.join(root, relativeEngineDestPath), {
parents: true,
cwd: path.join(multiagentPath, "nextjs"),
});
} else if (framework === "express") {
// patch chat.controller.ts file
await copy("**", path.join(root, relativeEngineDestPath), {
parents: true,
cwd: path.join(multiagentPath, "express"),
});
}
}
// copy loader component (TS only supports llama_parse and file for now)
const loaderFolder = useLlamaParse ? "llama_parse" : "file";
await copy("**", enginePath, {
@@ -134,7 +157,10 @@ export const installTSTemplate = async ({
// Select and copy engine code based on data sources and tools
let engine;
tools = tools ?? [];
if (dataSources.length > 0 && tools.length === 0) {
// multiagent template always uses agent engine
if (template === "multiagent") {
engine = "agent";
} else if (dataSources.length > 0 && tools.length === 0) {
console.log("\nNo tools selected - use optimized context chat engine\n");
engine = "chat";
} else {
@@ -145,6 +171,11 @@ export const installTSTemplate = async ({
cwd: path.join(compPath, "engines", "typescript", engine),
});
// copy settings to engine folder
await copy("**", enginePath, {
cwd: path.join(compPath, "settings", "typescript"),
});
/**
* Copy the selected UI files to the target directory and reference it.
*/
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-llama",
"version": "0.2.11",
"version": "0.2.14",
"description": "Create LlamaIndex-powered apps with one command",
"keywords": [
"rag",
@@ -25,6 +25,8 @@
"clean": "rimraf --glob ./dist ./templates/**/__pycache__ ./templates/**/node_modules ./templates/**/poetry.lock",
"dev": "ncc build ./index.ts -w -o dist/",
"e2e": "playwright test",
"e2e:python": "playwright test e2e/shared e2e/python",
"e2e:typescript": "playwright test e2e/shared e2e/typescript",
"format": "prettier --ignore-unknown --cache --check .",
"format:write": "prettier --ignore-unknown --write .",
"lint": "eslint . --ignore-pattern dist --ignore-pattern e2e/cache",
+9 -12
View File
@@ -141,12 +141,10 @@ export const getDataSourceChoices = (
});
}
if (selectedDataSource === undefined || selectedDataSource.length === 0) {
if (template !== "multiagent") {
choices.push({
title: "No datasource",
value: "none",
});
}
choices.push({
title: "No datasource",
value: "none",
});
choices.push({
title:
process.platform !== "linux"
@@ -410,10 +408,7 @@ export const askQuestions = async (
return; // early return - no further questions needed for llamapack projects
}
if (program.template === "multiagent") {
// TODO: multi-agents currently only supports FastAPI
program.framework = preferences.framework = "fastapi";
} else if (program.template === "extractor") {
if (program.template === "extractor") {
// Extractor template only supports FastAPI, empty data sources, and llamacloud
// So we just use example file for extractor template, this allows user to choose vector database later
program.dataSources = [EXAMPLE_FILE];
@@ -737,8 +732,10 @@ export const askQuestions = async (
}
}
if (!program.tools && program.template === "streaming") {
// TODO: allow to select tools also for multi-agent framework
if (
!program.tools &&
(program.template === "streaming" || program.template === "multiagent")
) {
if (ciInfo.isCI) {
program.tools = getPrefOrDefault("tools");
} else {
@@ -8,7 +8,7 @@ from llama_index.core.settings import Settings
from llama_index.core.tools.query_engine import QueryEngineTool
def get_chat_engine(filters=None, params=None, event_handlers=None):
def get_chat_engine(filters=None, params=None, event_handlers=None, **kwargs):
system_prompt = os.getenv("SYSTEM_PROMPT")
top_k = int(os.getenv("TOP_K", 0))
tools = []
@@ -1,8 +1,9 @@
import os
import yaml
import importlib
from llama_index.core.tools.tool_spec.base import BaseToolSpec
import os
import yaml
from llama_index.core.tools.function_tool import FunctionTool
from llama_index.core.tools.tool_spec.base import BaseToolSpec
class ToolType:
@@ -40,14 +41,26 @@ class ToolFactory:
raise ValueError(f"Failed to load tool {tool_name}: {e}")
@staticmethod
def from_env() -> list[FunctionTool]:
tools = []
def from_env(
map_result: bool = False,
) -> list[FunctionTool] | dict[str, FunctionTool]:
"""
Load tools from the configured file.
Params:
- use_map: if True, return map of tool name and the tool itself
"""
if map_result:
tools = {}
else:
tools = []
if os.path.exists("config/tools.yaml"):
with open("config/tools.yaml", "r") as f:
tool_configs = yaml.safe_load(f)
for tool_type, config_entries in tool_configs.items():
for tool_name, config in config_entries.items():
tools.extend(
ToolFactory.load_tools(tool_type, tool_name, config)
)
tool = ToolFactory.load_tools(tool_type, tool_name, config)
if map_result:
tools[tool_name] = tool
else:
tools.extend(tool)
return tools
@@ -0,0 +1,229 @@
import logging
import os
import re
from enum import Enum
from io import BytesIO
from llama_index.core.tools.function_tool import FunctionTool
OUTPUT_DIR = "output/tools"
class DocumentType(Enum):
PDF = "pdf"
HTML = "html"
COMMON_STYLES = """
body {
font-family: Arial, sans-serif;
line-height: 1.3;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.7em;
}
code {
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 4px;
}
pre {
background-color: #f4f4f4;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
"""
HTML_SPECIFIC_STYLES = """
body {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
"""
PDF_SPECIFIC_STYLES = """
@page {
size: letter;
margin: 2cm;
}
body {
font-size: 11pt;
}
h1 { font-size: 18pt; }
h2 { font-size: 16pt; }
h3 { font-size: 14pt; }
h4, h5, h6 { font-size: 12pt; }
pre, code {
font-family: Courier, monospace;
font-size: 0.9em;
}
"""
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
{common_styles}
{specific_styles}
</style>
</head>
<body>
{content}
</body>
</html>
"""
class DocumentGenerator:
@classmethod
def _generate_html_content(cls, original_content: str) -> str:
"""
Generate HTML content from the original markdown content.
"""
try:
import markdown
except ImportError:
raise ImportError(
"Failed to import required modules. Please install markdown."
)
# Convert markdown to HTML with fenced code and table extensions
html_content = markdown.markdown(
original_content, extensions=["fenced_code", "tables"]
)
return html_content
@classmethod
def _generate_pdf(cls, html_content: str) -> BytesIO:
"""
Generate a PDF from the HTML content.
"""
try:
from xhtml2pdf import pisa
except ImportError:
raise ImportError(
"Failed to import required modules. Please install xhtml2pdf."
)
pdf_html = HTML_TEMPLATE.format(
common_styles=COMMON_STYLES,
specific_styles=PDF_SPECIFIC_STYLES,
content=html_content,
)
buffer = BytesIO()
pdf = pisa.pisaDocument(
BytesIO(pdf_html.encode("UTF-8")), buffer, encoding="UTF-8"
)
if pdf.err:
logging.error(f"PDF generation failed: {pdf.err}")
raise ValueError("PDF generation failed")
buffer.seek(0)
return buffer
@classmethod
def _generate_html(cls, html_content: str) -> str:
"""
Generate a complete HTML document with the given HTML content.
"""
return HTML_TEMPLATE.format(
common_styles=COMMON_STYLES,
specific_styles=HTML_SPECIFIC_STYLES,
content=html_content,
)
@classmethod
def generate_document(
cls, original_content: str, document_type: str, file_name: str
) -> str:
"""
To generate document as PDF or HTML file.
Parameters:
original_content: str (markdown style)
document_type: str (pdf or html) specify the type of the file format based on the use case
file_name: str (name of the document file) must be a valid file name, no extensions needed
Returns:
str (URL to the document file): A file URL ready to serve.
"""
try:
document_type = DocumentType(document_type.lower())
except ValueError:
raise ValueError(
f"Invalid document type: {document_type}. Must be 'pdf' or 'html'."
)
# Always generate html content first
html_content = cls._generate_html_content(original_content)
# Based on the type of document, generate the corresponding file
if document_type == DocumentType.PDF:
content = cls._generate_pdf(html_content)
file_extension = "pdf"
elif document_type == DocumentType.HTML:
content = BytesIO(cls._generate_html(html_content).encode("utf-8"))
file_extension = "html"
else:
raise ValueError(f"Unexpected document type: {document_type}")
file_name = cls._validate_file_name(file_name)
file_path = os.path.join(OUTPUT_DIR, f"{file_name}.{file_extension}")
cls._write_to_file(content, file_path)
file_url = f"{os.getenv('FILESERVER_URL_PREFIX')}/{file_path}"
return file_url
@staticmethod
def _write_to_file(content: BytesIO, file_path: str):
"""
Write the content to a file.
"""
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as file:
file.write(content.getvalue())
except Exception as e:
raise e
@staticmethod
def _validate_file_name(file_name: str) -> str:
"""
Validate the file name.
"""
# Don't allow directory traversal
if os.path.isabs(file_name):
raise ValueError("File name is not allowed.")
# Don't allow special characters
if re.match(r"^[a-zA-Z0-9_.-]+$", file_name):
return file_name
else:
raise ValueError("File name is not allowed to contain special characters.")
def get_tools(**kwargs):
return [FunctionTool.from_defaults(DocumentGenerator.generate_document)]
@@ -32,5 +32,37 @@ def duckduckgo_search(
return results
def duckduckgo_image_search(
query: str,
region: str = "wt-wt",
max_results: int = 10,
):
"""
Use this function to search for images in DuckDuckGo.
Args:
query (str): The query to search in DuckDuckGo.
region Optional(str): The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc...
max_results Optional(int): The maximum number of results to be returned. Default is 10.
"""
try:
from duckduckgo_search import DDGS
except ImportError:
raise ImportError(
"duckduckgo_search package is required to use this function."
"Please install it by running: `poetry add duckduckgo_search` or `pip install duckduckgo_search`"
)
params = {
"keywords": query,
"region": region,
"max_results": max_results,
}
with DDGS() as ddg:
results = list(ddg.images(**params))
return results
def get_tools(**kwargs):
return [FunctionTool.from_defaults(duckduckgo_search)]
return [
FunctionTool.from_defaults(duckduckgo_search),
FunctionTool.from_defaults(duckduckgo_image_search),
]
@@ -1,10 +1,11 @@
import logging
import os
import uuid
import logging
import requests
from typing import Optional
from pydantic import BaseModel, Field
import requests
from llama_index.core.tools import FunctionTool
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ class ImageGeneratorToolOutput(BaseModel):
class ImageGeneratorTool:
_IMG_OUTPUT_FORMAT = "webp"
_IMG_OUTPUT_DIR = "output/tool"
_IMG_OUTPUT_DIR = "output/tools"
_IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core"
def __init__(self, api_key: str = None):
@@ -1,13 +1,13 @@
import os
import logging
import base64
import logging
import os
import uuid
from pydantic import BaseModel
from typing import List, Dict, Optional
from llama_index.core.tools import FunctionTool
from typing import Dict, List, Optional
from e2b_code_interpreter import CodeInterpreter
from e2b_code_interpreter.models import Logs
from llama_index.core.tools import FunctionTool
from pydantic import BaseModel
logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ class E2BToolOutput(BaseModel):
class E2BCodeInterpreter:
output_dir = "output/tool"
output_dir = "output/tools"
def __init__(self, api_key: str = None):
if api_key is None:
@@ -9,7 +9,7 @@ from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.settings import Settings
def get_chat_engine(filters=None, params=None, event_handlers=None):
def get_chat_engine(filters=None, params=None, event_handlers=None, **kwargs):
system_prompt = os.getenv("SYSTEM_PROMPT")
citation_prompt = os.getenv("SYSTEM_CITATION_PROMPT", None)
top_k = int(os.getenv("TOP_K", 0))
@@ -0,0 +1,129 @@
import type { JSONSchemaType } from "ajv";
import {
BaseTool,
ChatMessage,
JSONValue,
Settings,
ToolMetadata,
} from "llamaindex";
// prompt based on https://github.com/e2b-dev/ai-artifacts
const CODE_GENERATION_PROMPT = `You are a skilled software engineer. You do not make mistakes. Generate an artifact. You can install additional dependencies. You can use one of the following templates:\n
1. code-interpreter-multilang: "Runs code as a Jupyter notebook cell. Strong data analysis angle. Can use complex visualisation to explain results.". File: script.py. Dependencies installed: python, jupyter, numpy, pandas, matplotlib, seaborn, plotly. Port: none.
2. nextjs-developer: "A Next.js 13+ app that reloads automatically. Using the pages router.". File: pages/index.tsx. Dependencies installed: nextjs@14.2.5, typescript, @types/node, @types/react, @types/react-dom, postcss, tailwindcss, shadcn. Port: 3000.
3. vue-developer: "A Vue.js 3+ app that reloads automatically. Only when asked specifically for a Vue app.". File: app.vue. Dependencies installed: vue@latest, nuxt@3.13.0, tailwindcss. Port: 3000.
4. streamlit-developer: "A streamlit app that reloads automatically.". File: app.py. Dependencies installed: streamlit, pandas, numpy, matplotlib, request, seaborn, plotly. Port: 8501.
5. gradio-developer: "A gradio app. Gradio Blocks/Interface should be called demo.". File: app.py. Dependencies installed: gradio, pandas, numpy, matplotlib, request, seaborn, plotly. Port: 7860.
Provide detail information about the artifact you're about to generate in the following JSON format with the following keys:
commentary: Describe what you're about to do and the steps you want to take for generating the artifact in great detail.
template: Name of the template used to generate the artifact.
title: Short title of the artifact. Max 3 words.
description: Short description of the artifact. Max 1 sentence.
additional_dependencies: Additional dependencies required by the artifact. Do not include dependencies that are already included in the template.
has_additional_dependencies: Detect if additional dependencies that are not included in the template are required by the artifact.
install_dependencies_command: Command to install additional dependencies required by the artifact.
port: Port number used by the resulted artifact. Null when no ports are exposed.
file_path: Relative path to the file, including the file name.
code: Code generated by the artifact. Only runnable code is allowed.
Make sure to use the correct syntax for the programming language you're using. Make sure to generate only one code file. If you need to use CSS, make sure to include the CSS in the code file using Tailwind CSS syntax.
`;
// detail information to execute code
export type CodeArtifact = {
commentary: string;
template: string;
title: string;
description: string;
additional_dependencies: string[];
has_additional_dependencies: boolean;
install_dependencies_command: string;
port: number | null;
file_path: string;
code: string;
};
export type CodeGeneratorParameter = {
requirement: string;
oldCode?: string;
};
export type CodeGeneratorToolParams = {
metadata?: ToolMetadata<JSONSchemaType<CodeGeneratorParameter>>;
};
const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<CodeGeneratorParameter>> =
{
name: "artifact",
description: `Generate a code artifact based on the input. Don't call this tool if the user has not asked for code generation. E.g. if the user asks to write a description or specification, don't call this tool.`,
parameters: {
type: "object",
properties: {
requirement: {
type: "string",
description: "The description of the application you want to build.",
},
oldCode: {
type: "string",
description: "The existing code to be modified",
nullable: true,
},
},
required: ["requirement"],
},
};
export class CodeGeneratorTool implements BaseTool<CodeGeneratorParameter> {
metadata: ToolMetadata<JSONSchemaType<CodeGeneratorParameter>>;
constructor(params?: CodeGeneratorToolParams) {
this.metadata = params?.metadata || DEFAULT_META_DATA;
}
async call(input: CodeGeneratorParameter) {
try {
const artifact = await this.generateArtifact(
input.requirement,
input.oldCode,
);
return artifact as JSONValue;
} catch (error) {
return { isError: true };
}
}
// Generate artifact (code, environment, dependencies, etc.)
async generateArtifact(
query: string,
oldCode?: string,
): Promise<CodeArtifact> {
const userMessage = `
${query}
${oldCode ? `The existing code is: \n\`\`\`${oldCode}\`\`\`` : ""}
`;
const messages: ChatMessage[] = [
{ role: "system", content: CODE_GENERATION_PROMPT },
{ role: "user", content: userMessage },
];
try {
const response = await Settings.llm.chat({ messages });
const content = response.message.content.toString();
const jsonContent = content
.replace(/^```json\s*|\s*```$/g, "")
.replace(/^`+|`+$/g, "")
.trim();
const artifact = JSON.parse(jsonContent) as CodeArtifact;
return artifact;
} catch (error) {
console.log("Failed to generate artifact", error);
throw error;
}
}
}
@@ -0,0 +1,142 @@
import { JSONSchemaType } from "ajv";
import { BaseTool, ToolMetadata } from "llamaindex";
import { marked } from "marked";
import path from "node:path";
import { saveDocument } from "../../llamaindex/documents/helper";
const OUTPUT_DIR = "output/tools";
type DocumentParameter = {
originalContent: string;
fileName: string;
};
const DEFAULT_METADATA: ToolMetadata<JSONSchemaType<DocumentParameter>> = {
name: "document_generator",
description:
"Generate HTML document from markdown content. Return a file url to the document",
parameters: {
type: "object",
properties: {
originalContent: {
type: "string",
description: "The original markdown content to convert.",
},
fileName: {
type: "string",
description: "The name of the document file (without extension).",
},
},
required: ["originalContent", "fileName"],
},
};
const COMMON_STYLES = `
body {
font-family: Arial, sans-serif;
line-height: 1.3;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.7em;
}
code {
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 4px;
}
pre {
background-color: #f4f4f4;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
img {
max-width: 90%;
height: auto;
display: block;
margin: 1em auto;
border-radius: 10px;
}
`;
const HTML_SPECIFIC_STYLES = `
body {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
`;
const HTML_TEMPLATE = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
${COMMON_STYLES}
${HTML_SPECIFIC_STYLES}
</style>
</head>
<body>
{{content}}
</body>
</html>
`;
export interface DocumentGeneratorParams {
metadata?: ToolMetadata<JSONSchemaType<DocumentParameter>>;
}
export class DocumentGenerator implements BaseTool<DocumentParameter> {
metadata: ToolMetadata<JSONSchemaType<DocumentParameter>>;
constructor(params: DocumentGeneratorParams) {
this.metadata = params.metadata ?? DEFAULT_METADATA;
}
private static async generateHtmlContent(
originalContent: string,
): Promise<string> {
return await marked(originalContent);
}
private static generateHtmlDocument(htmlContent: string): string {
return HTML_TEMPLATE.replace("{{content}}", htmlContent);
}
async call(input: DocumentParameter): Promise<string> {
const { originalContent, fileName } = input;
const htmlContent =
await DocumentGenerator.generateHtmlContent(originalContent);
const fileContent = DocumentGenerator.generateHtmlDocument(htmlContent);
const filePath = path.join(OUTPUT_DIR, `${fileName}.html`);
return `URL: ${await saveDocument(filePath, fileContent)}`;
}
}
export function getTools(): BaseTool[] {
return [new DocumentGenerator({})];
}
@@ -5,15 +5,19 @@ import { BaseTool, ToolMetadata } from "llamaindex";
export type DuckDuckGoParameter = {
query: string;
region?: string;
maxResults?: number;
};
export type DuckDuckGoToolParams = {
metadata?: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>>;
};
const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>> = {
name: "duckduckgo",
description: "Use this function to search for any query in DuckDuckGo.",
const DEFAULT_SEARCH_METADATA: ToolMetadata<
JSONSchemaType<DuckDuckGoParameter>
> = {
name: "duckduckgo_search",
description:
"Use this function to search for information (only text) in the internet using DuckDuckGo.",
parameters: {
type: "object",
properties: {
@@ -27,6 +31,12 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>> = {
"Optional, The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc...",
nullable: true,
},
maxResults: {
type: "number",
description:
"Optional, The maximum number of results to be returned. Default is 10.",
nullable: true,
},
},
required: ["query"],
},
@@ -42,15 +52,18 @@ export class DuckDuckGoSearchTool implements BaseTool<DuckDuckGoParameter> {
metadata: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>>;
constructor(params: DuckDuckGoToolParams) {
this.metadata = params.metadata ?? DEFAULT_META_DATA;
this.metadata = params.metadata ?? DEFAULT_SEARCH_METADATA;
}
async call(input: DuckDuckGoParameter) {
const { query, region } = input;
const { query, region, maxResults = 10 } = input;
const options = region ? { region } : {};
// Temporarily sleep to reduce overloading the DuckDuckGo
await new Promise((resolve) => setTimeout(resolve, 1000));
const searchResults = await search(query, options);
return searchResults.results.map((result) => {
return searchResults.results.slice(0, maxResults).map((result) => {
return {
title: result.title,
description: result.description,
@@ -59,3 +72,7 @@ export class DuckDuckGoSearchTool implements BaseTool<DuckDuckGoParameter> {
});
}
}
export function getTools() {
return [new DuckDuckGoSearchTool({})];
}
@@ -37,7 +37,7 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<ImgGeneratorParameter>> = {
export class ImgGeneratorTool implements BaseTool<ImgGeneratorParameter> {
readonly IMG_OUTPUT_FORMAT = "webp";
readonly IMG_OUTPUT_DIR = "output/tool";
readonly IMG_OUTPUT_DIR = "output/tools";
readonly IMG_GEN_API =
"https://api.stability.ai/v2beta/stable-image/generate/core";
@@ -1,5 +1,10 @@
import { BaseToolWithCall } from "llamaindex";
import { ToolsFactory } from "llamaindex/tools/ToolsFactory";
import { CodeGeneratorTool, CodeGeneratorToolParams } from "./code-generator";
import {
DocumentGenerator,
DocumentGeneratorParams,
} from "./document-generator";
import { DuckDuckGoSearchTool, DuckDuckGoToolParams } from "./duckduckgo";
import { ImgGeneratorTool, ImgGeneratorToolParams } from "./img-gen";
import { InterpreterTool, InterpreterToolParams } from "./interpreter";
@@ -43,6 +48,12 @@ const toolFactory: Record<string, ToolCreator> = {
img_gen: async (config: unknown) => {
return [new ImgGeneratorTool(config as ImgGeneratorToolParams)];
},
artifact: async (config: unknown) => {
return [new CodeGeneratorTool(config as CodeGeneratorToolParams)];
},
document_generator: async (config: unknown) => {
return [new DocumentGenerator(config as DocumentGeneratorParams)];
},
};
async function createLocalTools(
@@ -56,7 +56,7 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<InterpreterParameter>> = {
};
export class InterpreterTool implements BaseTool<InterpreterParameter> {
private readonly outputDir = "output/tool";
private readonly outputDir = "output/tools";
private apiKey?: string;
private fileServerURLPrefix?: string;
metadata: ToolMetadata<JSONSchemaType<InterpreterParameter>>;
@@ -1,4 +1,5 @@
import fs from "fs";
import fs from "node:fs";
import path from "node:path";
import { getExtractors } from "../../engine/loader";
const MIME_TYPE_TO_EXT: Record<string, string> = {
@@ -15,8 +16,12 @@ export async function storeAndParseFile(
fileBuffer: Buffer,
mimeType: string,
) {
const fileExt = MIME_TYPE_TO_EXT[mimeType];
if (!fileExt) throw new Error(`Unsupported document type: ${mimeType}`);
const documents = await loadDocuments(fileBuffer, mimeType);
await saveDocument(filename, fileBuffer, mimeType);
const filepath = path.join(UPLOADED_FOLDER, filename);
await saveDocument(filepath, fileBuffer);
for (const document of documents) {
document.metadata = {
...document.metadata,
@@ -38,26 +43,31 @@ async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
return await reader.loadDataAsContent(fileBuffer);
}
async function saveDocument(
filename: string,
fileBuffer: Buffer,
mimeType: string,
) {
const fileExt = MIME_TYPE_TO_EXT[mimeType];
if (!fileExt) throw new Error(`Unsupported document type: ${mimeType}`);
const filepath = `${UPLOADED_FOLDER}/${filename}`;
const fileurl = `${process.env.FILESERVER_URL_PREFIX}/${filepath}`;
if (!fs.existsSync(UPLOADED_FOLDER)) {
fs.mkdirSync(UPLOADED_FOLDER, { recursive: true });
// Save document to file server and return the file url
export async function saveDocument(filepath: string, content: string | Buffer) {
if (path.isAbsolute(filepath)) {
throw new Error("Absolute file paths are not allowed.");
}
const fileName = path.basename(filepath);
if (!/^[a-zA-Z0-9_.-]+$/.test(fileName)) {
throw new Error(
"File name is not allowed to contain any special characters.",
);
}
if (!process.env.FILESERVER_URL_PREFIX) {
throw new Error("FILESERVER_URL_PREFIX environment variable is not set.");
}
await fs.promises.writeFile(filepath, fileBuffer);
console.log(`Saved document file to ${filepath}.\nURL: ${fileurl}`);
return {
filename,
filepath,
fileurl,
};
const dirPath = path.dirname(filepath);
await fs.promises.mkdir(dirPath, { recursive: true });
if (typeof content === "string") {
await fs.promises.writeFile(filepath, content, "utf-8");
} else {
await fs.promises.writeFile(filepath, content);
}
const fileurl = `${process.env.FILESERVER_URL_PREFIX}/${filepath}`;
console.log(`Saved document to ${filepath}. Reachable at URL: ${fileurl}`);
return fileurl;
}
@@ -1,4 +1,4 @@
import { JSONValue } from "ai";
import { JSONValue, Message } from "ai";
import { MessageContent, MessageContentDetail } from "llamaindex";
export type DocumentFileType = "csv" | "pdf" | "txt" | "docx";
@@ -21,13 +21,20 @@ type Annotation = {
data: object;
};
export function retrieveDocumentIds(annotations?: JSONValue[]): string[] {
if (!annotations) return [];
export function isValidMessages(messages: Message[]): boolean {
const lastMessage =
messages && messages.length > 0 ? messages[messages.length - 1] : null;
return lastMessage !== null && lastMessage.role === "user";
}
export function retrieveDocumentIds(messages: Message[]): string[] {
// retrieve document Ids from the annotations of all messages (if any)
const annotations = getAllAnnotations(messages);
if (annotations.length === 0) return [];
const ids: string[] = [];
for (const annotation of annotations) {
const { type, data } = getValidAnnotation(annotation);
for (const { type, data } of annotations) {
if (
type === "document_file" &&
"files" in data &&
@@ -37,9 +44,7 @@ export function retrieveDocumentIds(annotations?: JSONValue[]): string[] {
for (const file of files) {
if (Array.isArray(file.content.value)) {
// it's an array, so it's an array of doc IDs
for (const id of file.content.value) {
ids.push(id);
}
ids.push(...file.content.value);
}
}
}
@@ -48,24 +53,69 @@ export function retrieveDocumentIds(annotations?: JSONValue[]): string[] {
return ids;
}
export function convertMessageContent(
content: string,
annotations?: JSONValue[],
): MessageContent {
if (!annotations) return content;
export function retrieveMessageContent(messages: Message[]): MessageContent {
const userMessage = messages[messages.length - 1];
return [
{
type: "text",
text: content,
text: userMessage.content,
},
...convertAnnotations(annotations),
...retrieveLatestArtifact(messages),
...convertAnnotations(messages),
];
}
function convertAnnotations(annotations: JSONValue[]): MessageContentDetail[] {
function getAllAnnotations(messages: Message[]): Annotation[] {
return messages.flatMap((message) =>
(message.annotations ?? []).map((annotation) =>
getValidAnnotation(annotation),
),
);
}
// get latest artifact from annotations to append to the user message
function retrieveLatestArtifact(messages: Message[]): MessageContentDetail[] {
const annotations = getAllAnnotations(messages);
if (annotations.length === 0) return [];
for (const { type, data } of annotations.reverse()) {
if (
type === "tools" &&
"toolCall" in data &&
"toolOutput" in data &&
typeof data.toolCall === "object" &&
typeof data.toolOutput === "object" &&
data.toolCall !== null &&
data.toolOutput !== null &&
"name" in data.toolCall &&
data.toolCall.name === "artifact"
) {
const toolOutput = data.toolOutput as { output?: { code?: string } };
if (toolOutput.output?.code) {
return [
{
type: "text",
text: `The existing code is:\n\`\`\`\n${toolOutput.output.code}\n\`\`\``,
},
];
}
}
}
return [];
}
function convertAnnotations(messages: Message[]): MessageContentDetail[] {
// annotations from the last user message that has annotations
const annotations: Annotation[] =
messages
.slice()
.reverse()
.find((message) => message.role === "user" && message.annotations)
?.annotations?.map(getValidAnnotation) || [];
if (annotations.length === 0) return [];
const content: MessageContentDetail[] = [];
annotations.forEach((annotation: JSONValue) => {
const { type, data } = getValidAnnotation(annotation);
annotations.forEach(({ type, data }) => {
// convert image
if (type === "image" && "url" in data && typeof data.url === "string") {
content.push({
@@ -69,15 +69,6 @@ export function appendToolData(
});
}
export function createStreamTimeout(stream: StreamData) {
const timeout = Number(process.env.STREAM_TIMEOUT ?? 1000 * 60 * 5); // default to 5 minutes
const t = setTimeout(() => {
appendEventData(stream, `Stream timed out after ${timeout / 1000} seconds`);
stream.close();
}, timeout);
return t;
}
export function createCallbackManager(stream: StreamData) {
const callbackManager = new CallbackManager();
@@ -8,7 +8,7 @@ from app.agents.single import (
)
from llama_index.core.tools.types import ToolMetadata, ToolOutput
from llama_index.core.tools.utils import create_schema_from_function
from llama_index.core.workflow import Context, Workflow
from llama_index.core.workflow import Context, StopEvent, Workflow
class AgentCallTool(ContextAwareTool):
@@ -25,7 +25,11 @@ class AgentCallTool(ContextAwareTool):
name=name,
description=(
f"Use this tool to delegate a sub task to the {agent.name} agent."
+ (f" The agent is an {agent.role}." if agent.role else "")
+ (
f" The agent is an {agent.description}."
if agent.description
else ""
)
),
fn_schema=fn_schema,
)
@@ -35,7 +39,8 @@ class AgentCallTool(ContextAwareTool):
handler = self.agent.run(input=input)
# bubble all events while running the agent to the calling agent
async for ev in handler.stream_events():
ctx.write_event_to_stream(ev)
if type(ev) is not StopEvent:
ctx.write_event_to_stream(ev)
ret: AgentRunResult = await handler
response = ret.response.message.content
return ToolOutput(
@@ -11,6 +11,7 @@ from llama_index.core.agent.runner.planner import (
SubTask,
)
from llama_index.core.bridge.pydantic import ValidationError
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.llms.function_calling import FunctionCallingLLM
from llama_index.core.prompts import PromptTemplate
from llama_index.core.settings import Settings
@@ -24,6 +25,18 @@ from llama_index.core.workflow import (
step,
)
INITIAL_PLANNER_PROMPT = """\
Think step-by-step. Given a conversation, set of tools and a user request. Your responsibility is to create a plan to complete the task.
The plan must adapt with the user request and the conversation. It's fine to just start with needed tasks first and asking user for the next step approval.
The tools available are:
{tools_str}
Conversation: {chat_history}
Overall Task: {task}
"""
class ExecutePlanEvent(Event):
pass
@@ -62,14 +75,21 @@ class StructuredPlannerAgent(Workflow):
tools: List[BaseTool] | None = None,
timeout: float = 360.0,
refine_plan: bool = False,
chat_history: Optional[List[ChatMessage]] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, timeout=timeout, **kwargs)
self.name = name
self.refine_plan = refine_plan
self.chat_history = chat_history
self.tools = tools or []
self.planner = Planner(llm=llm, tools=self.tools, verbose=self._verbose)
self.planner = Planner(
llm=llm,
tools=self.tools,
initial_plan_prompt=INITIAL_PLANNER_PROMPT,
verbose=self._verbose,
)
# The executor is keeping the memory of all tool calls and decides to call the right tool for the task
self.executor = FunctionCallingAgent(
name="executor",
@@ -89,7 +109,9 @@ class StructuredPlannerAgent(Workflow):
ctx.data["streaming"] = getattr(ev, "streaming", False)
ctx.data["task"] = ev.input
plan_id, plan = await self.planner.create_plan(input=ev.input)
plan_id, plan = await self.planner.create_plan(
input=ev.input, chat_history=self.chat_history
)
ctx.data["act_plan_id"] = plan_id
# inform about the new plan
@@ -106,11 +128,12 @@ class StructuredPlannerAgent(Workflow):
ctx.data["act_plan_id"]
)
ctx.data["num_sub_tasks"] = len(upcoming_sub_tasks)
# send an event per sub task
events = [SubTaskEvent(sub_task=sub_task) for sub_task in upcoming_sub_tasks]
for event in events:
ctx.send_event(event)
if upcoming_sub_tasks:
# Execute only the first sub-task
# otherwise the executor will get over-lapping messages
# alternatively, we could use one executor for all sub tasks
next_sub_task = upcoming_sub_tasks[0]
return SubTaskEvent(sub_task=next_sub_task)
return None
@@ -120,7 +143,7 @@ class StructuredPlannerAgent(Workflow):
) -> SubTaskResultEvent:
if self._verbose:
print(f"=== Executing sub task: {ev.sub_task.name} ===")
is_last_tasks = ctx.data["num_sub_tasks"] == self.get_remaining_subtasks(ctx)
is_last_tasks = self.get_remaining_subtasks(ctx) == 1
# TODO: streaming only works without plan refining
streaming = is_last_tasks and ctx.data["streaming"] and not self.refine_plan
handler = self.executor.run(
@@ -142,22 +165,17 @@ class StructuredPlannerAgent(Workflow):
async def gather_results(
self, ctx: Context, ev: SubTaskResultEvent
) -> ExecutePlanEvent | StopEvent:
# wait for all sub tasks to finish
num_sub_tasks = ctx.data["num_sub_tasks"]
results = ctx.collect_events(ev, [SubTaskResultEvent] * num_sub_tasks)
if results is None:
return None
result = ev
upcoming_sub_tasks = self.get_upcoming_sub_tasks(ctx)
# if no more tasks to do, stop workflow and send result of last step
if upcoming_sub_tasks == 0:
return StopEvent(result=results[-1].result)
return StopEvent(result=result.result)
if self.refine_plan:
# store all results for refining the plan
# store the result for refining the plan
ctx.data["results"] = ctx.data.get("results", {})
for result in results:
ctx.data["results"][result.sub_task.name] = result.result
ctx.data["results"][result.sub_task.name] = result.result
new_plan = await self.planner.refine_plan(
ctx.data["task"], ctx.data["act_plan_id"], ctx.data["results"]
@@ -213,7 +231,9 @@ class Planner:
plan_refine_prompt = PromptTemplate(plan_refine_prompt)
self.plan_refine_prompt = plan_refine_prompt
async def create_plan(self, input: str) -> Tuple[str, Plan]:
async def create_plan(
self, input: str, chat_history: Optional[List[ChatMessage]] = None
) -> Tuple[str, Plan]:
tools = self.tools
tools_str = ""
for tool in tools:
@@ -225,6 +245,7 @@ class Planner:
self.initial_plan_prompt,
tools_str=tools_str,
task=input,
chat_history=chat_history,
)
except (ValueError, ValidationError):
if self.verbose:
@@ -5,10 +5,8 @@ from llama_index.core.llms import ChatMessage, ChatResponse
from llama_index.core.llms.function_calling import FunctionCallingLLM
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.settings import Settings
from llama_index.core.tools import ToolOutput, ToolSelection
from llama_index.core.tools import FunctionTool, ToolOutput, ToolSelection
from llama_index.core.tools.types import BaseTool
from llama_index.core.tools import FunctionTool
from llama_index.core.workflow import (
Context,
Event,
@@ -64,14 +62,14 @@ class FunctionCallingAgent(Workflow):
timeout: float = 360.0,
name: str,
write_events: bool = True,
role: Optional[str] = None,
description: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, verbose=verbose, timeout=timeout, **kwargs)
self.tools = tools or []
self.name = name
self.role = role
self.write_events = write_events
self.description = description
if llm is None:
llm = Settings.llm
@@ -0,0 +1,46 @@
import logging
from app.api.routers.events import EventCallbackHandler
from app.api.routers.models import (
ChatData,
)
from app.api.routers.vercel_response import VercelStreamResponse
from app.engine import get_chat_engine
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
chat_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
@r.post("")
async def chat(
request: Request,
data: ChatData,
background_tasks: BackgroundTasks,
):
try:
last_message_content = data.get_last_message_content()
messages = data.get_history_messages(include_agent_messages=True)
event_handler = EventCallbackHandler()
# The chat API supports passing private document filters and chat params
# but agent workflow does not support them yet
# ignore chat params and use all documents for now
# TODO: generate filters based on doc_ids
# TODO: use chat params
engine = get_chat_engine(chat_history=messages)
event_handler = engine.run(input=last_message_content, streaming=True)
return VercelStreamResponse(
request=request,
chat_data=data,
event_handler=event_handler,
events=engine.stream_events(),
)
except Exception as e:
logger.exception("Error in chat engine", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error in chat engine: {e}",
) from e
@@ -1,6 +1,6 @@
import json
import logging
from asyncio import Task
from abc import ABC
from typing import AsyncGenerator, List
from aiostream import stream
@@ -13,14 +13,88 @@ from fastapi.responses import StreamingResponse
logger = logging.getLogger("uvicorn")
class VercelStreamResponse(StreamingResponse):
class VercelStreamResponse(StreamingResponse, ABC):
"""
Class to convert the response from the chat engine to the streaming format expected by Vercel
Base class to convert the response from the chat engine to the streaming format expected by Vercel
"""
TEXT_PREFIX = "0:"
DATA_PREFIX = "8:"
def __init__(self, request: Request, chat_data: ChatData, *args, **kwargs):
self.request = request
stream = self._create_stream(request, chat_data, *args, **kwargs)
content = self.content_generator(stream)
super().__init__(content=content)
async def content_generator(self, stream):
is_stream_started = False
async with stream.stream() as streamer:
async for output in streamer:
if not is_stream_started:
is_stream_started = True
# Stream a blank message to start the stream
yield self.convert_text("")
yield output
if await self.request.is_disconnected():
break
def _create_stream(
self,
request: Request,
chat_data: ChatData,
event_handler: AgentRunResult | AsyncGenerator,
events: AsyncGenerator[AgentRunEvent, None],
verbose: bool = True,
):
# Yield the text response
async def _chat_response_generator():
result = await event_handler
final_response = ""
if isinstance(result, AgentRunResult):
for token in result.response.message.content:
final_response += token
yield self.convert_text(token)
if isinstance(result, AsyncGenerator):
async for token in result:
final_response += token.delta
yield self.convert_text(token.delta)
# Generate next questions if next question prompt is configured
question_data = await self._generate_next_questions(
chat_data.messages, final_response
)
if question_data:
yield self.convert_data(question_data)
# TODO: stream sources
# Yield the events from the event handler
async def _event_generator():
async for event in events:
event_response = self._event_to_response(event)
if verbose:
logger.debug(event_response)
if event_response is not None:
yield self.convert_data(event_response)
combine = stream.merge(_chat_response_generator(), _event_generator())
return combine
@staticmethod
def _event_to_response(event: AgentRunEvent) -> dict:
return {
"type": "agent",
"data": {"agent": event.name, "text": event.msg},
}
@classmethod
def convert_text(cls, token: str):
# Escape newlines and double quotes to avoid breaking the stream
@@ -32,82 +106,6 @@ class VercelStreamResponse(StreamingResponse):
data_str = json.dumps(data)
return f"{cls.DATA_PREFIX}[{data_str}]\n"
def __init__(
self,
request: Request,
task: Task[AgentRunResult | AsyncGenerator],
events: AsyncGenerator[AgentRunEvent, None],
chat_data: ChatData,
verbose: bool = True,
):
content = VercelStreamResponse.content_generator(
request, task, events, chat_data, verbose
)
super().__init__(content=content)
@classmethod
async def content_generator(
cls,
request: Request,
task: Task[AgentRunResult | AsyncGenerator],
events: AsyncGenerator[AgentRunEvent, None],
chat_data: ChatData,
verbose: bool = True,
):
# Yield the text response
async def _chat_response_generator():
result = await task
final_response = ""
if isinstance(result, AgentRunResult):
for token in result.response.message.content:
final_response += token
yield cls.convert_text(token)
if isinstance(result, AsyncGenerator):
async for token in result:
final_response += token.delta
yield cls.convert_text(token.delta)
# Generate next questions if next question prompt is configured
question_data = await cls._generate_next_questions(
chat_data.messages, final_response
)
if question_data:
yield cls.convert_data(question_data)
# TODO: stream sources
# Yield the events from the event handler
async def _event_generator():
async for event in events():
event_response = cls._event_to_response(event)
if verbose:
logger.debug(event_response)
if event_response is not None:
yield cls.convert_data(event_response)
combine = stream.merge(_chat_response_generator(), _event_generator())
is_stream_started = False
async with combine.stream() as streamer:
if not is_stream_started:
is_stream_started = True
# Stream a blank message to start the stream
yield cls.convert_text("")
async for output in streamer:
yield output
if await request.is_disconnected():
break
@staticmethod
def _event_to_response(event: AgentRunEvent) -> dict:
return {
"type": "agent",
"data": {"agent": event.name, "text": event.msg},
}
@staticmethod
async def _generate_next_questions(chat_history: List[Message], response: str):
questions = await NextQuestionSuggestion.suggest_next_questions(
@@ -1,20 +1,20 @@
import logging
import os
from typing import List, Optional
from app.examples.choreography import create_choreography
from app.examples.orchestrator import create_orchestrator
from app.examples.workflow import create_workflow
from llama_index.core.workflow import Workflow
from llama_index.core.chat_engine.types import ChatMessage
import os
from llama_index.core.workflow import Workflow
logger = logging.getLogger("uvicorn")
def create_agent(chat_history: Optional[List[ChatMessage]] = None) -> Workflow:
def get_chat_engine(
chat_history: Optional[List[ChatMessage]] = None, **kwargs
) -> Workflow:
# TODO: the EXAMPLE_TYPE could be passed as a chat config parameter?
agent_type = os.getenv("EXAMPLE_TYPE", "").lower()
match agent_type:
case "choreography":
@@ -0,0 +1,32 @@
from textwrap import dedent
from typing import List, Optional
from app.agents.multi import AgentCallingAgent
from app.agents.single import FunctionCallingAgent
from app.examples.publisher import create_publisher
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
def create_choreography(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(chat_history)
publisher = create_publisher(chat_history)
reviewer = FunctionCallingAgent(
name="reviewer",
description="expert in reviewing blog posts, needs a written post to review",
system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. If the post is good, you can say 'The post is good.'",
chat_history=chat_history,
)
return AgentCallingAgent(
name="writer",
agents=[researcher, reviewer, publisher],
description="expert in writing blog posts, needs researched information and images to write a blog post",
system_prompt=dedent("""
You are an expert in writing blog posts. You are given a task to write a blog post. Before starting to write the post, consult the researcher agent to get the information you need. Don't make up any information yourself.
After creating a draft for the post, send it to the reviewer agent to receive feedback and make sure to incorporate the feedback from the reviewer.
You can consult the reviewer and researcher a maximum of two times. Your output should contain only the blog post.
Finally, always request the publisher to create a document (PDF, HTML) and publish the blog post.
"""),
# TODO: add chat_history support to AgentCallingAgent
# chat_history=chat_history,
)
@@ -0,0 +1,40 @@
from textwrap import dedent
from typing import List, Optional
from app.agents.multi import AgentOrchestrator
from app.agents.single import FunctionCallingAgent
from app.examples.publisher import create_publisher
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
def create_orchestrator(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(chat_history)
writer = FunctionCallingAgent(
name="writer",
description="expert in writing blog posts, need information and images to write a post",
system_prompt=dedent("""
You are an expert in writing blog posts.
You are given a task to write a blog post. Do not make up any information yourself.
If you don't have the necessary information to write a blog post, reply "I need information about the topic to write the blog post".
If you need to use images, reply "I need images about the topic to write the blog post". Do not use any dummy images made up by you.
If you have all the information needed, write the blog post.
"""),
chat_history=chat_history,
)
reviewer = FunctionCallingAgent(
name="reviewer",
description="expert in reviewing blog posts, needs a written blog post to review",
system_prompt=dedent("""
You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post and fix any issues found yourself. You must output a final blog post.
A post must include at least one valid image. If not, reply "I need images about the topic to write the blog post". An image URL starting with "example" or "your website" is not valid.
Especially check for logical inconsistencies and proofread the post for grammar and spelling errors.
"""),
chat_history=chat_history,
)
publisher = create_publisher(chat_history)
return AgentOrchestrator(
agents=[writer, reviewer, researcher, publisher],
refine_plan=False,
chat_history=chat_history,
)
@@ -0,0 +1,35 @@
from textwrap import dedent
from typing import List, Tuple
from app.agents.single import FunctionCallingAgent
from app.engine.tools import ToolFactory
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.tools import FunctionTool
def get_publisher_tools() -> Tuple[List[FunctionTool], str, str]:
tools = []
# Get configured tools from the tools.yaml file
configured_tools = ToolFactory.from_env(map_result=True)
if "document_generator" in configured_tools.keys():
tools.extend(configured_tools["document_generator"])
prompt_instructions = dedent("""
Normally, reply the blog post content to the user directly.
But if user requested to generate a file, use the document_generator tool to generate the file and reply the link to the file.
""")
description = "Expert in publishing the blog post, able to publish the blog post in PDF or HTML format."
else:
prompt_instructions = "You don't have a tool to generate document. Please reply the content directly."
description = "Expert in publishing the blog post"
return tools, prompt_instructions, description
def create_publisher(chat_history: List[ChatMessage]):
tools, prompt_instructions, description = get_publisher_tools()
return FunctionCallingAgent(
name="publisher",
tools=tools,
description=description,
system_prompt=prompt_instructions,
chat_history=chat_history,
)
@@ -0,0 +1,82 @@
import os
from textwrap import dedent
from typing import List
from app.agents.single import FunctionCallingAgent
from app.engine.index import get_index
from app.engine.tools import ToolFactory
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.tools import QueryEngineTool, ToolMetadata
def _create_query_engine_tool() -> QueryEngineTool:
"""
Provide an agent worker that can be used to query the index.
"""
index = get_index()
if index is None:
return None
top_k = int(os.getenv("TOP_K", 0))
query_engine = index.as_query_engine(
**({"similarity_top_k": top_k} if top_k != 0 else {})
)
return QueryEngineTool(
query_engine=query_engine,
metadata=ToolMetadata(
name="query_index",
description="""
Use this tool to retrieve information about the text corpus from the index.
""",
),
)
def _get_research_tools() -> QueryEngineTool:
"""
Researcher take responsibility for retrieving information.
Try init wikipedia or duckduckgo tool if available.
"""
tools = []
query_engine_tool = _create_query_engine_tool()
if query_engine_tool is not None:
tools.append(query_engine_tool)
researcher_tool_names = ["duckduckgo", "wikipedia.WikipediaToolSpec"]
configured_tools = ToolFactory.from_env(map_result=True)
for tool_name, tool in configured_tools.items():
if tool_name in researcher_tool_names:
tools.extend(tool)
return tools
def create_researcher(chat_history: List[ChatMessage]):
"""
Researcher is an agent that take responsibility for using tools to complete a given task.
"""
tools = _get_research_tools()
return FunctionCallingAgent(
name="researcher",
tools=tools,
description="expert in retrieving any unknown content or searching for images from the internet",
system_prompt=dedent("""
You are a researcher agent. You are given a research task.
If the conversation already includes the information and there is no new request for additional information from the user, you should return the appropriate content to the writer.
Otherwise, you must use tools to retrieve information or images needed for the task.
It's normal for the task to include some ambiguity. You must always think carefully about the context of the user's request to understand what are the main content needs to be retrieved.
Example:
Request: "Create a blog post about the history of the internet, write in English and publish in PDF format."
->Though: The main content is "history of the internet", while "write in English and publish in PDF format" is a requirement for other agents.
Your task: Look for information in English about the history of the Internet.
This is not your task: Create a blog post or look for how to create a PDF.
Next request: "Publish the blog post in HTML format."
->Though: User just asking for a format change, the previous content is still valid.
Your task: Return the previous content of the post to the writer. No need to do any research.
This is not your task: Look for how to create an HTML file.
If you use the tools but don't find any related information, please return "I didn't find any new information for {the topic}." along with the content you found. Don't try to make up information yourself.
If the request doesn't need any new information because it was in the conversation history, please return "The task doesn't need any new information. Please reuse the existing content in the conversation history."
"""),
chat_history=chat_history,
)
@@ -0,0 +1,250 @@
from textwrap import dedent
from typing import AsyncGenerator, List, Optional
from llama_index.core.settings import Settings
from llama_index.core.prompts import PromptTemplate
from app.agents.single import AgentRunEvent, AgentRunResult, FunctionCallingAgent
from app.examples.publisher import create_publisher
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
def create_workflow(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(
chat_history=chat_history,
)
publisher = create_publisher(
chat_history=chat_history,
)
writer = FunctionCallingAgent(
name="writer",
description="expert in writing blog posts, need information and images to write a post.",
system_prompt=dedent(
"""
You are an expert in writing blog posts.
You are given the task of writing a blog post based on research content provided by the researcher agent. Do not invent any information yourself.
It's important to read the entire conversation history to write the blog post accurately.
If you receive a review from the reviewer, update the post according to the feedback and return the new post content.
If the user requests an update with new information but no research content is provided, you must respond with: "I don't have any research content to write about."
If the content is not valid (e.g., broken link, broken image, etc.), do not use it.
It's normal for the task to include some ambiguity, so you must define the user's initial request to write the post correctly.
If you update the post based on the reviewer's feedback, first explain what changes you made to the post, then provide the new post content. Do not include the reviewer's comments.
Example:
Task: "Here is the information I found about the history of the internet:
Create a blog post about the history of the internet, write in English, and publish in PDF format."
-> Your task: Use the research content {...} to write a blog post in English.
-> This is not your task: Create a PDF
Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid.
"""
),
chat_history=chat_history,
)
reviewer = FunctionCallingAgent(
name="reviewer",
description="expert in reviewing blog posts, needs a written blog post to review.",
system_prompt=dedent(
"""
You are an expert in reviewing blog posts.
You are given a task to review a blog post. As a reviewer, it's important that your review aligns with the user's request. Please focus on the user's request when reviewing the post.
Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement.
Furthermore, proofread the post for grammar and spelling errors.
Only if the post is good enough for publishing should you return 'The post is good.' In all other cases, return your review.
It's normal for the task to include some ambiguity, so you must define the user's initial request to review the post correctly.
Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid.
Example:
Task: "Create a blog post about the history of the internet, write in English and publish in PDF format."
-> Your task: Review whether the main content of the post is about the history of the internet and if it is written in English.
-> This is not your task: Create blog post, create PDF, write in English.
"""
),
chat_history=chat_history,
)
workflow = BlogPostWorkflow(
timeout=360, chat_history=chat_history
) # Pass chat_history here
workflow.add_workflows(
researcher=researcher,
writer=writer,
reviewer=reviewer,
publisher=publisher,
)
return workflow
class ResearchEvent(Event):
input: str
class WriteEvent(Event):
input: str
is_good: bool = False
class ReviewEvent(Event):
input: str
class PublishEvent(Event):
input: str
class BlogPostWorkflow(Workflow):
def __init__(
self, timeout: int = 360, chat_history: Optional[List[ChatMessage]] = None
):
super().__init__(timeout=timeout)
self.chat_history = chat_history or []
@step()
async def start(self, ctx: Context, ev: StartEvent) -> ResearchEvent | PublishEvent:
# set streaming
ctx.data["streaming"] = getattr(ev, "streaming", False)
# start the workflow with researching about a topic
ctx.data["task"] = ev.input
ctx.data["user_input"] = ev.input
# Decision-making process
decision = await self._decide_workflow(ev.input, self.chat_history)
if decision != "publish":
return ResearchEvent(input=f"Research for this task: {ev.input}")
else:
chat_history_str = "\n".join(
[f"{msg.role}: {msg.content}" for msg in self.chat_history]
)
return PublishEvent(
input=f"Please publish content based on the chat history\n{chat_history_str}\n\n and task: {ev.input}"
)
async def _decide_workflow(
self, input: str, chat_history: List[ChatMessage]
) -> str:
prompt_template = PromptTemplate(
"Given the following chat history and new task, decide whether to publish based on existing information.\n"
"Chat history:\n{chat_history}\n"
"New task: {input}\n"
"Decision (respond with either 'not_publish' or 'publish'):"
)
chat_history_str = "\n".join(
[f"{msg.role}: {msg.content}" for msg in chat_history]
)
prompt = prompt_template.format(chat_history=chat_history_str, input=input)
output = await Settings.llm.acomplete(prompt)
decision = output.text.strip().lower()
return "publish" if decision == "publish" else "research"
@step()
async def research(
self, ctx: Context, ev: ResearchEvent, researcher: FunctionCallingAgent
) -> WriteEvent:
result: AgentRunResult = await self.run_agent(ctx, researcher, ev.input)
content = result.response.message.content
return WriteEvent(
input=f"Write a blog post given this task: {ctx.data['task']} using this research content: {content}"
)
@step()
async def write(
self, ctx: Context, ev: WriteEvent, writer: FunctionCallingAgent
) -> ReviewEvent | StopEvent:
MAX_ATTEMPTS = 2
ctx.data["attempts"] = ctx.data.get("attempts", 0) + 1
too_many_attempts = ctx.data["attempts"] > MAX_ATTEMPTS
if too_many_attempts:
ctx.write_event_to_stream(
AgentRunEvent(
name=writer.name,
msg=f"Too many attempts ({MAX_ATTEMPTS}) to write the blog post. Proceeding with the current version.",
)
)
if ev.is_good or too_many_attempts:
# too many attempts or the blog post is good - stream final response if requested
result = await self.run_agent(
ctx, writer, ev.input, streaming=ctx.data["streaming"]
)
return StopEvent(result=result)
result: AgentRunResult = await self.run_agent(ctx, writer, ev.input)
ctx.data["result"] = result
return ReviewEvent(input=result.response.message.content)
@step()
async def review(
self, ctx: Context, ev: ReviewEvent, reviewer: FunctionCallingAgent
) -> WriteEvent:
result: AgentRunResult = await self.run_agent(ctx, reviewer, ev.input)
review = result.response.message.content
old_content = ctx.data["result"].response.message.content
post_is_good = "post is good" in review.lower()
ctx.write_event_to_stream(
AgentRunEvent(
name=reviewer.name,
msg=f"The post is {'not ' if not post_is_good else ''}good enough for publishing. Sending back to the writer{' for publication.' if post_is_good else '.'}",
)
)
if post_is_good:
return WriteEvent(
input=f"You're blog post is ready for publication. Please respond with just the blog post. Blog post: ```{old_content}```",
is_good=True,
)
else:
return WriteEvent(
input=dedent(
f"""
Improve the writing of a given blog post by using a given review.
Blog post:
```
{old_content}
```
Review:
```
{review}
```
"""
),
)
@step()
async def publish(
self,
ctx: Context,
ev: PublishEvent,
publisher: FunctionCallingAgent,
) -> StopEvent:
try:
result: AgentRunResult = await self.run_agent(ctx, publisher, ev.input)
return StopEvent(result=result)
except Exception as e:
ctx.write_event_to_stream(
AgentRunEvent(
name=publisher.name,
msg=f"Error publishing: {e}",
)
)
return StopEvent(result=None)
async def run_agent(
self,
ctx: Context,
agent: FunctionCallingAgent,
input: str,
streaming: bool = False,
) -> AgentRunResult | AsyncGenerator:
handler = agent.run(input=input, streaming=streaming)
# bubble all events while running the executor to the planner
async for event in handler.stream_events():
# Don't write the StopEvent from sub task to the stream
if type(event) is not StopEvent:
ctx.write_event_to_stream(event)
return await handler
@@ -0,0 +1,41 @@
import { StopEvent } from "@llamaindex/core/workflow";
import { Message, streamToResponse } from "ai";
import { Request, Response } from "express";
import { ChatMessage, ChatResponseChunk } from "llamaindex";
import { createWorkflow } from "./workflow/factory";
import { toDataStream, workflowEventsToStreamData } from "./workflow/stream";
export const chat = async (req: Request, res: Response) => {
try {
const { messages }: { messages: Message[] } = req.body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
return res.status(400).json({
error:
"messages are required in the request body and the last message must be from the user",
});
}
const chatHistory = messages as ChatMessage[];
const agent = createWorkflow(chatHistory);
const result = agent.run<AsyncGenerator<ChatResponseChunk>>(
userMessage.content,
) as unknown as Promise<StopEvent<AsyncGenerator<ChatResponseChunk>>>;
// convert the workflow events to a vercel AI stream data object
const agentStreamData = await workflowEventsToStreamData(
agent.streamEvents(),
);
// convert the workflow result to a vercel AI content stream
const stream = toDataStream(result, {
onFinal: () => agentStreamData.close(),
});
return streamToResponse(stream, res, {}, agentStreamData);
} catch (error) {
console.error("[LlamaIndex]", error);
return res.status(500).json({
detail: (error as Error).message,
});
}
};
@@ -0,0 +1,57 @@
import { initObservability } from "@/app/observability";
import { StopEvent } from "@llamaindex/core/workflow";
import { Message, StreamingTextResponse } from "ai";
import { ChatMessage, ChatResponseChunk } from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { initSettings } from "./engine/settings";
import { createWorkflow } from "./workflow/factory";
import { toDataStream, workflowEventsToStreamData } from "./workflow/stream";
initObservability();
initSettings();
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { messages }: { messages: Message[] } = body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
return NextResponse.json(
{
error:
"messages are required in the request body and the last message must be from the user",
},
{ status: 400 },
);
}
const chatHistory = messages as ChatMessage[];
const agent = createWorkflow(chatHistory);
// TODO: fix type in agent.run in LITS
const result = agent.run<AsyncGenerator<ChatResponseChunk>>(
userMessage.content,
) as unknown as Promise<StopEvent<AsyncGenerator<ChatResponseChunk>>>;
// convert the workflow events to a vercel AI stream data object
const agentStreamData = await workflowEventsToStreamData(
agent.streamEvents(),
);
// convert the workflow result to a vercel AI content stream
const stream = toDataStream(result, {
onFinal: () => agentStreamData.close(),
});
return new StreamingTextResponse(stream, {}, agentStreamData);
} catch (error) {
console.error("[LlamaIndex]", error);
return NextResponse.json(
{
detail: (error as Error).message,
},
{
status: 500,
},
);
}
}
@@ -0,0 +1,94 @@
import { ChatMessage } from "llamaindex";
import { FunctionCallingAgent } from "./single-agent";
import { lookupTools } from "./tools";
export const createResearcher = async (chatHistory: ChatMessage[]) => {
const tools = await lookupTools([
"query_index",
"wikipedia_tool",
"duckduckgo_search",
"image_generator",
]);
return new FunctionCallingAgent({
name: "researcher",
tools: tools,
systemPrompt: `You are a researcher agent. You are given a research task.
If the conversation already includes the information and there is no new request for additional information from the user, you should return the appropriate content to the writer.
Otherwise, you must use tools to retrieve information or images needed for the task.
It's normal for the task to include some ambiguity. You must always think carefully about the context of the user's request to understand what are the main content needs to be retrieved.
Example:
Request: "Create a blog post about the history of the internet, write in English and publish in PDF format."
->Though: The main content is "history of the internet", while "write in English and publish in PDF format" is a requirement for other agents.
Your task: Look for information in English about the history of the Internet.
This is not your task: Create a blog post or look for how to create a PDF.
Next request: "Publish the blog post in HTML format."
->Though: User just asking for a format change, the previous content is still valid.
Your task: Return the previous content of the post to the writer. No need to do any research.
This is not your task: Look for how to create an HTML file.
If you use the tools but don't find any related information, please return "I didn't find any new information for {the topic}." along with the content you found. Don't try to make up information yourself.
If the request doesn't need any new information because it was in the conversation history, please return "The task doesn't need any new information. Please reuse the existing content in the conversation history.
`,
chatHistory,
});
};
export const createWriter = (chatHistory: ChatMessage[]) => {
return new FunctionCallingAgent({
name: "writer",
systemPrompt: `You are an expert in writing blog posts.
You are given the task of writing a blog post based on research content provided by the researcher agent. Do not invent any information yourself.
It's important to read the entire conversation history to write the blog post accurately.
If you receive a review from the reviewer, update the post according to the feedback and return the new post content.
If the user requests an update with new information but no research content is provided, you must respond with: "I don't have any research content to write about."
If the content is not valid (e.g., broken link, broken image, etc.), do not use it.
It's normal for the task to include some ambiguity, so you must define the user's initial request to write the post correctly.
If you update the post based on the reviewer's feedback, first explain what changes you made to the post, then provide the new post content. Do not include the reviewer's comments.
Example:
Task: "Here is the information I found about the history of the internet:
Create a blog post about the history of the internet, write in English, and publish in PDF format."
-> Your task: Use the research content {...} to write a blog post in English.
-> This is not your task: Create a PDF
Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid.`,
chatHistory,
});
};
export const createReviewer = (chatHistory: ChatMessage[]) => {
return new FunctionCallingAgent({
name: "reviewer",
systemPrompt: `You are an expert in reviewing blog posts.
You are given a task to review a blog post. As a reviewer, it's important that your review aligns with the user's request. Please focus on the user's request when reviewing the post.
Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement.
Furthermore, proofread the post for grammar and spelling errors.
Only if the post is good enough for publishing should you return 'The post is good.' In all other cases, return your review.
It's normal for the task to include some ambiguity, so you must define the user's initial request to review the post correctly.
Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid.
Example:
Task: "Create a blog post about the history of the internet, write in English and publish in PDF format."
-> Your task: Review whether the main content of the post is about the history of the internet and if it is written in English.
-> This is not your task: Create blog post, create PDF, write in English.`,
chatHistory,
});
};
export const createPublisher = async (chatHistory: ChatMessage[]) => {
const tools = await lookupTools(["document_generator"]);
let systemPrompt = `You are an expert in publishing blog posts. You are given a task to publish a blog post.
If the writer says that there was an error, you should reply with the error and not publish the post.`;
if (tools.length > 0) {
systemPrompt = `${systemPrompt}.
If the user requests to generate a file, use the document_generator tool to generate the file and reply with the link to the file.
Otherwise, simply return the content of the post.`;
}
return new FunctionCallingAgent({
name: "publisher",
tools: tools,
systemPrompt: systemPrompt,
chatHistory,
});
};
@@ -0,0 +1,187 @@
import {
Context,
StartEvent,
StopEvent,
Workflow,
WorkflowEvent,
} from "@llamaindex/core/workflow";
import { ChatMessage, ChatResponseChunk } from "llamaindex";
import {
createPublisher,
createResearcher,
createReviewer,
createWriter,
} from "./agents";
import { AgentInput, AgentRunEvent } from "./type";
const TIMEOUT = 360 * 1000;
const MAX_ATTEMPTS = 2;
class ResearchEvent extends WorkflowEvent<{ input: string }> {}
class WriteEvent extends WorkflowEvent<{
input: string;
isGood: boolean;
}> {}
class ReviewEvent extends WorkflowEvent<{ input: string }> {}
class PublishEvent extends WorkflowEvent<{ input: string }> {}
const prepareChatHistory = (chatHistory: ChatMessage[]) => {
// By default, the chat history only contains the assistant and user messages
// all the agents messages are stored in annotation data which is not visible to the LLM
const MAX_AGENT_MESSAGES = 10;
// Construct a new agent message from agent messages
// Get annotations from assistant messages
const agentAnnotations = chatHistory
.filter((msg) => msg.role === "assistant")
.flatMap((msg) => msg.annotations || [])
.filter((annotation) => annotation.type === "agent")
.slice(-MAX_AGENT_MESSAGES);
const agentMessages = agentAnnotations
.map(
(annotation) =>
`\n<${annotation.data.agent}>\n${annotation.data.text}\n</${annotation.data.agent}>`,
)
.join("\n");
const agentContent = agentMessages
? "Here is the previous conversation of agents:\n" + agentMessages
: "";
if (agentContent) {
const agentMessage: ChatMessage = {
role: "assistant",
content: agentContent,
};
return [
...chatHistory.slice(0, -1),
agentMessage,
chatHistory.slice(-1)[0],
];
}
return chatHistory;
};
export const createWorkflow = (chatHistory: ChatMessage[]) => {
const chatHistoryWithAgentMessages = prepareChatHistory(chatHistory);
const runAgent = async (
context: Context,
agent: Workflow,
input: AgentInput,
) => {
const run = agent.run(new StartEvent({ input }));
for await (const event of agent.streamEvents()) {
if (event.data instanceof AgentRunEvent) {
context.writeEventToStream(event.data);
}
}
return await run;
};
const start = async (context: Context, ev: StartEvent) => {
context.set("task", ev.data.input);
return new ResearchEvent({
input: `Research for this task: ${ev.data.input}`,
});
};
const research = async (context: Context, ev: ResearchEvent) => {
const researcher = await createResearcher(chatHistoryWithAgentMessages);
const researchRes = await runAgent(context, researcher, {
message: ev.data.input,
});
const researchResult = researchRes.data.result;
return new WriteEvent({
input: `Write a blog post given this task: ${context.get("task")} using this research content: ${researchResult}`,
isGood: false,
});
};
const write = async (context: Context, ev: WriteEvent) => {
context.set("attempts", context.get("attempts", 0) + 1);
const tooManyAttempts = context.get("attempts") > MAX_ATTEMPTS;
if (tooManyAttempts) {
context.writeEventToStream(
new AgentRunEvent({
name: "writer",
msg: `Too many attempts (${MAX_ATTEMPTS}) to write the blog post. Proceeding with the current version.`,
}),
);
}
if (ev.data.isGood || tooManyAttempts) {
return new PublishEvent({
input: "Please help me to publish the blog post.",
});
}
const writer = createWriter(chatHistoryWithAgentMessages);
const writeRes = await runAgent(context, writer, {
message: ev.data.input,
});
const writeResult = writeRes.data.result;
context.set("result", writeResult); // store the last result
return new ReviewEvent({ input: writeResult });
};
const review = async (context: Context, ev: ReviewEvent) => {
const reviewer = createReviewer(chatHistoryWithAgentMessages);
const reviewRes = await reviewer.run(
new StartEvent<AgentInput>({ input: { message: ev.data.input } }),
);
const reviewResult = reviewRes.data.result;
const oldContent = context.get("result");
const postIsGood = reviewResult.toLowerCase().includes("post is good");
context.writeEventToStream(
new AgentRunEvent({
name: "reviewer",
msg: `The post is ${postIsGood ? "" : "not "}good enough for publishing. Sending back to the writer${
postIsGood ? " for publication." : "."
}`,
}),
);
if (postIsGood) {
return new WriteEvent({
input: "",
isGood: true,
});
}
return new WriteEvent({
input: `Improve the writing of a given blog post by using a given review.
Blog post:
\`\`\`
${oldContent}
\`\`\`
Review:
\`\`\`
${reviewResult}
\`\`\``,
isGood: false,
});
};
const publish = async (context: Context, ev: PublishEvent) => {
const publisher = await createPublisher(chatHistoryWithAgentMessages);
const publishResult = await runAgent(context, publisher, {
message: `${ev.data.input}`,
streaming: true,
});
return publishResult as unknown as StopEvent<
AsyncGenerator<ChatResponseChunk>
>;
};
const workflow = new Workflow({ timeout: TIMEOUT, validate: true });
workflow.addStep(StartEvent, start, { outputs: ResearchEvent });
workflow.addStep(ResearchEvent, research, { outputs: WriteEvent });
workflow.addStep(WriteEvent, write, { outputs: [ReviewEvent, PublishEvent] });
workflow.addStep(ReviewEvent, review, { outputs: WriteEvent });
workflow.addStep(PublishEvent, publish, { outputs: StopEvent });
return workflow;
};
@@ -0,0 +1,236 @@
import {
Context,
StartEvent,
StopEvent,
Workflow,
WorkflowEvent,
} from "@llamaindex/core/workflow";
import {
BaseToolWithCall,
ChatMemoryBuffer,
ChatMessage,
ChatResponse,
ChatResponseChunk,
Settings,
ToolCall,
ToolCallLLM,
ToolCallLLMMessageOptions,
callTool,
} from "llamaindex";
import { AgentInput, AgentRunEvent } from "./type";
class InputEvent extends WorkflowEvent<{
input: ChatMessage[];
}> {}
class ToolCallEvent extends WorkflowEvent<{
toolCalls: ToolCall[];
}> {}
export class FunctionCallingAgent extends Workflow {
name: string;
llm: ToolCallLLM;
memory: ChatMemoryBuffer;
tools: BaseToolWithCall[];
systemPrompt?: string;
writeEvents: boolean;
role?: string;
constructor(options: {
name: string;
llm?: ToolCallLLM;
chatHistory?: ChatMessage[];
tools?: BaseToolWithCall[];
systemPrompt?: string;
writeEvents?: boolean;
role?: string;
verbose?: boolean;
timeout?: number;
}) {
super({
verbose: options?.verbose ?? false,
timeout: options?.timeout ?? 360,
});
this.name = options?.name;
this.llm = options.llm ?? (Settings.llm as ToolCallLLM);
this.checkToolCallSupport();
this.memory = new ChatMemoryBuffer({
llm: this.llm,
chatHistory: options.chatHistory,
});
this.tools = options?.tools ?? [];
this.systemPrompt = options.systemPrompt;
this.writeEvents = options?.writeEvents ?? true;
this.role = options?.role;
// add steps
this.addStep(StartEvent<AgentInput>, this.prepareChatHistory, {
outputs: InputEvent,
});
this.addStep(InputEvent, this.handleLLMInput, {
outputs: [ToolCallEvent, StopEvent],
});
this.addStep(ToolCallEvent, this.handleToolCalls, {
outputs: InputEvent,
});
}
private get chatHistory() {
return this.memory.getMessages();
}
private async prepareChatHistory(
ctx: Context,
ev: StartEvent<AgentInput>,
): Promise<InputEvent> {
const { message, streaming } = ev.data.input;
ctx.set("streaming", streaming);
this.writeEvent(`Start to work on: ${message}`, ctx);
if (this.systemPrompt) {
this.memory.put({ role: "system", content: this.systemPrompt });
}
this.memory.put({ role: "user", content: message });
return new InputEvent({ input: this.chatHistory });
}
private async handleLLMInput(
ctx: Context,
ev: InputEvent,
): Promise<StopEvent<string | AsyncGenerator> | ToolCallEvent> {
if (ctx.get("streaming")) {
return await this.handleLLMInputStream(ctx, ev);
}
const result = await this.llm.chat({
messages: this.chatHistory,
tools: this.tools,
});
this.memory.put(result.message);
const toolCalls = this.getToolCallsFromResponse(result);
if (toolCalls.length) {
return new ToolCallEvent({ toolCalls });
}
this.writeEvent("Finished task", ctx);
return new StopEvent({ result: result.message.content.toString() });
}
private async handleLLMInputStream(
context: Context,
ev: InputEvent,
): Promise<StopEvent<AsyncGenerator> | ToolCallEvent> {
const { llm, tools, memory } = this;
const llmArgs = { messages: this.chatHistory, tools };
const responseGenerator = async function* () {
const responseStream = await llm.chat({ ...llmArgs, stream: true });
let fullResponse = null;
let yieldedIndicator = false;
for await (const chunk of responseStream) {
const hasToolCalls = chunk.options && "toolCall" in chunk.options;
if (!hasToolCalls) {
if (!yieldedIndicator) {
yield false;
yieldedIndicator = true;
}
yield chunk;
} else if (!yieldedIndicator) {
yield true;
yieldedIndicator = true;
}
fullResponse = chunk;
}
if (fullResponse) {
memory.put({
role: "assistant",
content: "",
options: fullResponse.options,
});
yield fullResponse;
}
};
const generator = responseGenerator();
const isToolCall = await generator.next();
if (isToolCall.value) {
const fullResponse = await generator.next();
const toolCalls = this.getToolCallsFromResponse(
fullResponse.value as ChatResponseChunk<ToolCallLLMMessageOptions>,
);
return new ToolCallEvent({ toolCalls });
}
this.writeEvent("Finished task", context);
return new StopEvent({ result: generator });
}
private async handleToolCalls(
ctx: Context,
ev: ToolCallEvent,
): Promise<InputEvent> {
const { toolCalls } = ev.data;
const toolMsgs: ChatMessage[] = [];
for (const call of toolCalls) {
const targetTool = this.tools.find(
(tool) => tool.metadata.name === call.name,
);
// TODO: make logger optional in callTool in framework
const toolOutput = await callTool(targetTool, call, {
log: () => {},
error: console.error.bind(console),
warn: () => {},
});
toolMsgs.push({
content: JSON.stringify(toolOutput.output),
role: "user",
options: {
toolResult: {
result: toolOutput.output,
isError: toolOutput.isError,
id: call.id,
},
},
});
}
for (const msg of toolMsgs) {
this.memory.put(msg);
}
return new InputEvent({ input: this.memory.getMessages() });
}
private writeEvent(msg: string, context: Context) {
if (!this.writeEvents) return;
context.writeEventToStream({
data: new AgentRunEvent({ name: this.name, msg }),
});
}
private checkToolCallSupport() {
const { supportToolCall } = this.llm as ToolCallLLM;
if (!supportToolCall) throw new Error("LLM does not support tool calls");
}
private getToolCallsFromResponse(
response:
| ChatResponse<ToolCallLLMMessageOptions>
| ChatResponseChunk<ToolCallLLMMessageOptions>,
): ToolCall[] {
let options;
if ("message" in response) {
options = response.message.options;
} else {
options = response.options;
}
if (options && "toolCall" in options) {
return options.toolCall as ToolCall[];
}
return [];
}
}
@@ -0,0 +1,65 @@
import { StopEvent } from "@llamaindex/core/workflow";
import {
createCallbacksTransformer,
createStreamDataTransformer,
StreamData,
trimStartOfStreamHelper,
type AIStreamCallbacksAndOptions,
} from "ai";
import { ChatResponseChunk } from "llamaindex";
import { AgentRunEvent } from "./type";
export function toDataStream(
result: Promise<StopEvent<AsyncGenerator<ChatResponseChunk>>>,
callbacks?: AIStreamCallbacksAndOptions,
) {
return toReadableStream(result)
.pipeThrough(createCallbacksTransformer(callbacks))
.pipeThrough(createStreamDataTransformer());
}
function toReadableStream(
result: Promise<StopEvent<AsyncGenerator<ChatResponseChunk>>>,
) {
const trimStartOfStream = trimStartOfStreamHelper();
return new ReadableStream<string>({
start(controller) {
controller.enqueue(""); // Kickstart the stream
},
async pull(controller): Promise<void> {
const stopEvent = await result;
const generator = stopEvent.data.result;
const { value, done } = await generator.next();
if (done) {
controller.close();
return;
}
const text = trimStartOfStream(value.delta ?? "");
if (text) controller.enqueue(text);
},
});
}
export async function workflowEventsToStreamData(
events: AsyncIterable<AgentRunEvent>,
): Promise<StreamData> {
const streamData = new StreamData();
(async () => {
for await (const event of events) {
if (event instanceof AgentRunEvent) {
const { name, msg } = event.data;
if ((streamData as any).isClosed) {
break;
}
streamData.appendMessageAnnotation({
type: "agent",
data: { agent: name, text: msg },
});
}
}
})();
return streamData;
}
@@ -0,0 +1,52 @@
import fs from "fs/promises";
import { BaseToolWithCall, QueryEngineTool } from "llamaindex";
import path from "path";
import { getDataSource } from "../engine";
import { createTools } from "../engine/tools/index";
const getQueryEngineTool = async (): Promise<QueryEngineTool | null> => {
const index = await getDataSource();
if (!index) {
return null;
}
const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined;
return new QueryEngineTool({
queryEngine: index.asQueryEngine({
similarityTopK: topK,
}),
metadata: {
name: "query_index",
description: `Use this tool to retrieve information about the text corpus from the index.`,
},
});
};
export const getAvailableTools = async () => {
const configFile = path.join("config", "tools.json");
let toolConfig: any;
const tools: BaseToolWithCall[] = [];
try {
toolConfig = JSON.parse(await fs.readFile(configFile, "utf8"));
} catch (e) {
console.info(`Could not read ${configFile} file. Using no tools.`);
}
if (toolConfig) {
tools.push(...(await createTools(toolConfig)));
}
const queryEngineTool = await getQueryEngineTool();
if (queryEngineTool) {
tools.push(queryEngineTool);
}
return tools;
};
export const lookupTools = async (
toolNames: string[],
): Promise<BaseToolWithCall[]> => {
const availableTools = await getAvailableTools();
return availableTools.filter((tool) =>
toolNames.includes(tool.metadata.name),
);
};
@@ -0,0 +1,11 @@
import { WorkflowEvent } from "@llamaindex/core/workflow";
export type AgentInput = {
message: string;
streaming?: boolean;
};
export class AgentRunEvent extends WorkflowEvent<{
name: string;
msg: string;
}> {}
@@ -1,39 +0,0 @@
import logging
from app.api.routers.models import (
ChatData,
)
from app.api.routers.vercel_response import VercelStreamResponse
from app.examples.factory import create_agent
from fastapi import APIRouter, HTTPException, Request, status
from llama_index.core.workflow import Workflow
chat_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
@r.post("")
async def chat(
request: Request,
data: ChatData,
):
try:
last_message_content = data.get_last_message_content()
messages = data.get_history_messages()
# TODO: generate filters based on doc_ids
# for now just use all documents
# doc_ids = data.get_chat_document_ids()
# TODO: use params
# params = data.data or {}
agent: Workflow = create_agent(chat_history=messages)
handler = agent.run(input=last_message_content, streaming=True)
return VercelStreamResponse(request, handler, agent.stream_events, data)
except Exception as e:
logger.exception("Error in agent", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error in agent: {e}",
) from e
@@ -1,48 +0,0 @@
import logging
import os
from fastapi import APIRouter
from app.api.routers.models import ChatConfig
config_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
@r.get("")
async def chat_config() -> ChatConfig:
starter_questions = None
conversation_starters = os.getenv("CONVERSATION_STARTERS")
if conversation_starters and conversation_starters.strip():
starter_questions = conversation_starters.strip().split("\n")
return ChatConfig(starter_questions=starter_questions)
try:
from app.engine.service import LLamaCloudFileService
logger.info("LlamaCloud is configured. Adding /config/llamacloud route.")
@r.get("/llamacloud")
async def chat_llama_cloud_config():
projects = LLamaCloudFileService.get_all_projects_with_pipelines()
pipeline = os.getenv("LLAMA_CLOUD_INDEX_NAME")
project = os.getenv("LLAMA_CLOUD_PROJECT_NAME")
pipeline_config = None
if pipeline and project:
pipeline_config = {
"pipeline": pipeline,
"project": project,
}
return {
"projects": projects,
"pipeline": pipeline_config,
}
except ImportError:
logger.debug(
"LlamaCloud is not configured. Skipping adding /config/llamacloud route."
)
pass
@@ -1,227 +0,0 @@
import logging
import os
from typing import Any, Dict, List, Literal, Optional
from llama_index.core.llms import ChatMessage, MessageRole
from llama_index.core.schema import NodeWithScore
from pydantic import BaseModel, Field, validator
from pydantic.alias_generators import to_camel
from app.config import DATA_DIR
logger = logging.getLogger("uvicorn")
class FileContent(BaseModel):
type: Literal["text", "ref"]
# If the file is pure text then the value is be a string
# otherwise, it's a list of document IDs
value: str | List[str]
class File(BaseModel):
id: str
content: FileContent
filename: str
filesize: int
filetype: str
class AnnotationFileData(BaseModel):
files: List[File] = Field(
default=[],
description="List of files",
)
class Config:
json_schema_extra = {
"example": {
"csvFiles": [
{
"content": "Name, Age\nAlice, 25\nBob, 30",
"filename": "example.csv",
"filesize": 123,
"id": "123",
"type": "text/csv",
}
]
}
}
alias_generator = to_camel
class Annotation(BaseModel):
type: str
data: AnnotationFileData | List[str]
def to_content(self) -> str | None:
if self.type == "document_file":
# We only support generating context content for CSV files for now
csv_files = [file for file in self.data.files if file.filetype == "csv"]
if len(csv_files) > 0:
return "Use data from following CSV raw content\n" + "\n".join(
[f"```csv\n{csv_file.content.value}\n```" for csv_file in csv_files]
)
else:
logger.warning(
f"The annotation {self.type} is not supported for generating context content"
)
return None
class Message(BaseModel):
role: MessageRole
content: str
annotations: List[Annotation] | None = None
class ChatData(BaseModel):
messages: List[Message]
data: Any = None
class Config:
json_schema_extra = {
"example": {
"messages": [
{
"role": "user",
"content": "What standards for letters exist?",
}
]
}
}
@validator("messages")
def messages_must_not_be_empty(cls, v):
if len(v) == 0:
raise ValueError("Messages must not be empty")
return v
def get_last_message_content(self) -> str:
"""
Get the content of the last message along with the data content if available.
Fallback to use data content from previous messages
"""
if len(self.messages) == 0:
raise ValueError("There is not any message in the chat")
last_message = self.messages[-1]
message_content = last_message.content
for message in reversed(self.messages):
if message.role == MessageRole.USER and message.annotations is not None:
annotation_contents = filter(
None,
[annotation.to_content() for annotation in message.annotations],
)
if not annotation_contents:
continue
annotation_text = "\n".join(annotation_contents)
message_content = f"{message_content}\n{annotation_text}"
break
return message_content
def get_history_messages(self) -> List[ChatMessage]:
"""
Get the history messages
"""
return [
ChatMessage(role=message.role, content=message.content)
for message in self.messages[:-1]
]
def is_last_message_from_user(self) -> bool:
return self.messages[-1].role == MessageRole.USER
def get_chat_document_ids(self) -> List[str]:
"""
Get the document IDs from the chat messages
"""
document_ids: List[str] = []
for message in self.messages:
if message.role == MessageRole.USER and message.annotations is not None:
for annotation in message.annotations:
if (
annotation.type == "document_file"
and annotation.data.files is not None
):
for fi in annotation.data.files:
if fi.content.type == "ref":
document_ids += fi.content.value
return list(set(document_ids))
class SourceNodes(BaseModel):
id: str
metadata: Dict[str, Any]
score: Optional[float]
text: str
url: Optional[str]
@classmethod
def from_source_node(cls, source_node: NodeWithScore):
metadata = source_node.node.metadata
url = cls.get_url_from_metadata(metadata)
return cls(
id=source_node.node.node_id,
metadata=metadata,
score=source_node.score,
text=source_node.node.text, # type: ignore
url=url,
)
@classmethod
def get_url_from_metadata(cls, metadata: Dict[str, Any]) -> str:
url_prefix = os.getenv("FILESERVER_URL_PREFIX")
if not url_prefix:
logger.warning(
"Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server"
)
file_name = metadata.get("file_name")
if file_name and url_prefix:
# file_name exists and file server is configured
pipeline_id = metadata.get("pipeline_id")
if pipeline_id:
# file is from LlamaCloud
file_name = f"{pipeline_id}${file_name}"
return f"{url_prefix}/output/llamacloud/{file_name}"
is_private = metadata.get("private", "false") == "true"
if is_private:
# file is a private upload
return f"{url_prefix}/output/uploaded/{file_name}"
# file is from calling the 'generate' script
# Get the relative path of file_path to data_dir
file_path = metadata.get("file_path")
data_dir = os.path.abspath(DATA_DIR)
if file_path and data_dir:
relative_path = os.path.relpath(file_path, data_dir)
return f"{url_prefix}/data/{relative_path}"
# fallback to URL in metadata (e.g. for websites)
return metadata.get("URL")
@classmethod
def from_source_nodes(cls, source_nodes: List[NodeWithScore]):
return [cls.from_source_node(node) for node in source_nodes]
class Result(BaseModel):
result: Message
nodes: List[SourceNodes]
class ChatConfig(BaseModel):
starter_questions: Optional[List[str]] = Field(
default=None,
description="List of starter questions",
serialization_alias="starterQuestions",
)
class Config:
json_schema_extra = {
"example": {
"starterQuestions": [
"What standards for letters exist?",
"What are the requirements for a letter to be considered a letter?",
]
}
}
@@ -1,29 +0,0 @@
import logging
from typing import List, Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.api.services.file import PrivateFileService
file_upload_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
class FileUploadRequest(BaseModel):
base64: str
filename: str
params: Any = None
@r.post("")
def upload_file(request: FileUploadRequest) -> List[str]:
try:
logger.info("Processing file")
return PrivateFileService.process_file(
request.filename, request.base64, request.params
)
except Exception as e:
logger.error(f"Error processing file: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Error processing file")
@@ -1 +0,0 @@
DATA_DIR = "data"
@@ -1,25 +0,0 @@
from typing import List, Optional
from app.agents.single import FunctionCallingAgent
from app.agents.multi import AgentCallingAgent
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
def create_choreography(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(chat_history)
reviewer = FunctionCallingAgent(
name="reviewer",
role="expert in reviewing blog posts",
system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. If the post is good, you can say 'The post is good.'",
chat_history=chat_history,
)
return AgentCallingAgent(
name="writer",
agents=[researcher, reviewer],
role="expert in writing blog posts",
system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Before starting to write the post, consult the researcher agent to get the information you need. Don't make up any information yourself.
After creating a draft for the post, send it to the reviewer agent to receive some feedback and make sure to incorporate the feedback from the reviewer.
You can consult the reviewer and researcher maximal two times. Your output should just contain the blog post.""",
# TODO: add chat_history support to AgentCallingAgent
# chat_history=chat_history,
)
@@ -1,27 +0,0 @@
from typing import List, Optional
from app.agents.single import FunctionCallingAgent
from app.agents.multi import AgentOrchestrator
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
def create_orchestrator(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(chat_history)
writer = FunctionCallingAgent(
name="writer",
role="expert in writing blog posts",
system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Don't make up any information yourself. If you don't have the necessary information to write a blog post, reply "I need information about the topic to write the blog post". If you have all the information needed, write the blog post.""",
chat_history=chat_history,
)
reviewer = FunctionCallingAgent(
name="reviewer",
role="expert in reviewing blog posts",
system_prompt="""You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post and fix the issues found yourself. You must output a final blog post.
Especially check for logical inconsistencies and proofread the post for grammar and spelling errors.""",
chat_history=chat_history,
)
return AgentOrchestrator(
agents=[writer, reviewer, researcher],
refine_plan=False,
)
@@ -1,39 +0,0 @@
import os
from typing import List
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from app.agents.single import FunctionCallingAgent
from app.engine.index import get_index
from llama_index.core.chat_engine.types import ChatMessage
def get_query_engine_tool() -> QueryEngineTool:
"""
Provide an agent worker that can be used to query the index.
"""
index = get_index()
if index is None:
raise ValueError("Index not found. Please create an index first.")
top_k = int(os.getenv("TOP_K", 0))
query_engine = index.as_query_engine(
**({"similarity_top_k": top_k} if top_k != 0 else {})
)
return QueryEngineTool(
query_engine=query_engine,
metadata=ToolMetadata(
name="query_index",
description="""
Use this tool to retrieve information about the text corpus from the index.
""",
),
)
def create_researcher(chat_history: List[ChatMessage]):
return FunctionCallingAgent(
name="researcher",
tools=[get_query_engine_tool()],
role="expert in retrieving any unknown content",
system_prompt="You are a researcher agent. You are given a researching task. You must use your tools to complete the research.",
chat_history=chat_history,
)
@@ -1,139 +0,0 @@
from typing import AsyncGenerator, List, Optional
from app.agents.single import AgentRunEvent, AgentRunResult, FunctionCallingAgent
from app.examples.researcher import create_researcher
from llama_index.core.chat_engine.types import ChatMessage
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
def create_workflow(chat_history: Optional[List[ChatMessage]] = None):
researcher = create_researcher(
chat_history=chat_history,
)
writer = FunctionCallingAgent(
name="writer",
role="expert in writing blog posts",
system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Don't make up any information yourself.""",
chat_history=chat_history,
)
reviewer = FunctionCallingAgent(
name="reviewer",
role="expert in reviewing blog posts",
system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. Only if the post is good enough for publishing, then you MUST return 'The post is good.'. In all other cases return your review.",
chat_history=chat_history,
)
workflow = BlogPostWorkflow(timeout=360)
workflow.add_workflows(researcher=researcher, writer=writer, reviewer=reviewer)
return workflow
class ResearchEvent(Event):
input: str
class WriteEvent(Event):
input: str
is_good: bool = False
class ReviewEvent(Event):
input: str
class BlogPostWorkflow(Workflow):
@step()
async def start(self, ctx: Context, ev: StartEvent) -> ResearchEvent:
# set streaming
ctx.data["streaming"] = getattr(ev, "streaming", False)
# start the workflow with researching about a topic
ctx.data["task"] = ev.input
return ResearchEvent(input=f"Research for this task: {ev.input}")
@step()
async def research(
self, ctx: Context, ev: ResearchEvent, researcher: FunctionCallingAgent
) -> WriteEvent:
result: AgentRunResult = await self.run_agent(ctx, researcher, ev.input)
content = result.response.message.content
return WriteEvent(
input=f"Write a blog post given this task: {ctx.data['task']} using this research content: {content}"
)
@step()
async def write(
self, ctx: Context, ev: WriteEvent, writer: FunctionCallingAgent
) -> ReviewEvent | StopEvent:
MAX_ATTEMPTS = 2
ctx.data["attempts"] = ctx.data.get("attempts", 0) + 1
too_many_attempts = ctx.data["attempts"] > MAX_ATTEMPTS
if too_many_attempts:
ctx.write_event_to_stream(
AgentRunEvent(
name=writer.name,
msg=f"Too many attempts ({MAX_ATTEMPTS}) to write the blog post. Proceeding with the current version.",
)
)
if ev.is_good or too_many_attempts:
# too many attempts or the blog post is good - stream final response if requested
result = await self.run_agent(
ctx, writer, ev.input, streaming=ctx.data["streaming"]
)
return StopEvent(result=result)
result: AgentRunResult = await self.run_agent(ctx, writer, ev.input)
ctx.data["result"] = result
return ReviewEvent(input=result.response.message.content)
@step()
async def review(
self, ctx: Context, ev: ReviewEvent, reviewer: FunctionCallingAgent
) -> WriteEvent:
result: AgentRunResult = await self.run_agent(ctx, reviewer, ev.input)
review = result.response.message.content
old_content = ctx.data["result"].response.message.content
post_is_good = "post is good" in review.lower()
ctx.write_event_to_stream(
AgentRunEvent(
name=reviewer.name,
msg=f"The post is {'not ' if not post_is_good else ''}good enough for publishing. Sending back to the writer{' for publication.' if post_is_good else '.'}",
)
)
if post_is_good:
return WriteEvent(
input=f"You're blog post is ready for publication. Please respond with just the blog post. Blog post: ```{old_content}```",
is_good=True,
)
else:
return WriteEvent(
input=f"""Improve the writing of a given blog post by using a given review.
Blog post:
```
{old_content}
```
Review:
```
{review}
```"""
)
async def run_agent(
self,
ctx: Context,
agent: FunctionCallingAgent,
input: str,
streaming: bool = False,
) -> AgentRunResult | AsyncGenerator:
handler = agent.run(input=input, streaming=streaming)
# bubble all events while running the executor to the planner
async for event in handler.stream_events():
# Don't write the StopEvent from sub task to the stream
if type(event) is not StopEvent:
ctx.write_event_to_stream(event)
return await handler
@@ -1,2 +0,0 @@
def init_observability():
pass
@@ -1,8 +0,0 @@
import os
def load_from_env(var: str, throw_error: bool = True) -> str:
res = os.getenv(var)
if res is None and throw_error:
raise ValueError(f"Missing environment variable: {var}")
return res
@@ -1,4 +0,0 @@
__pycache__
storage
.env
output
@@ -1,72 +0,0 @@
# flake8: noqa: E402
import os
from dotenv import load_dotenv
from app.config import DATA_DIR
load_dotenv()
import logging
import uvicorn
from app.api.routers.chat import chat_router
from app.api.routers.chat_config import config_router
from app.api.routers.upload import file_upload_router
from app.observability import init_observability
from app.settings import init_settings
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
app = FastAPI()
init_settings()
init_observability()
environment = os.getenv("ENVIRONMENT", "dev") # Default to 'development' if not set
logger = logging.getLogger("uvicorn")
if environment == "dev":
logger.warning("Running in development mode - allowing CORS for all origins")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Redirect to documentation page when accessing base URL
@app.get("/")
async def redirect_to_docs():
return RedirectResponse(url="/docs")
def mount_static_files(directory, path):
if os.path.exists(directory):
logger.info(f"Mounting static files '{directory}' at '{path}'")
app.mount(
path,
StaticFiles(directory=directory, check_dir=False),
name=f"{directory}-static",
)
# Mount the data files to serve the file viewer
mount_static_files(DATA_DIR, "/api/files/data")
# Mount the output files from tools
mount_static_files("output", "/api/files/output")
app.include_router(chat_router, prefix="/api/chat")
app.include_router(config_router, prefix="/api/chat/config")
app.include_router(file_upload_router, prefix="/api/chat/upload")
if __name__ == "__main__":
app_host = os.getenv("APP_HOST", "0.0.0.0")
app_port = int(os.getenv("APP_PORT", "8000"))
reload = True if environment == "dev" else False
uvicorn.run(app="main:app", host=app_host, port=app_port, reload=reload)
@@ -1,27 +0,0 @@
[tool]
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Marcus Schiesser <mail@marcusschiesser.de>"]
readme = "README.md"
[tool.poetry.scripts]
generate = "app.engine.generate:generate_datasource"
[tool.poetry.dependencies]
python = ">=3.11,<3.13"
llama-index-agent-openai = ">=0.3.0,<0.4.0"
llama-index = "0.11.11"
fastapi = "^0.112.2"
python-dotenv = "^1.0.0"
uvicorn = { extras = ["standard"], version = "^0.23.2" }
cachetools = "^5.3.3"
aiostream = "^0.5.2"
[tool.poetry.dependencies.docx2txt]
version = "^0.8"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
@@ -2,6 +2,7 @@
import cors from "cors";
import "dotenv/config";
import express, { Express, Request, Response } from "express";
import { sandbox } from "./src/controllers/sandbox.controller";
import { initObservability } from "./src/observability";
import chatRouter from "./src/routes/chat.route";
@@ -40,6 +41,7 @@ app.get("/", (req: Request, res: Response) => {
});
app.use("/api/chat", chatRouter);
app.use("/api/sandbox", sandbox);
app.listen(port, () => {
console.log(`⚡️[server]: Server is running at http://localhost:${port}`);
@@ -15,6 +15,7 @@
"dev": "concurrently \"tsup index.ts --format esm --dts --watch\" \"nodemon --watch dist/index.js\""
},
"dependencies": {
"@llamaindex/core": "^0.2.6",
"ai": "3.3.42",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
@@ -23,10 +24,11 @@
"llamaindex": "0.6.2",
"pdf2json": "3.0.5",
"ajv": "^8.12.0",
"@e2b/code-interpreter": "^0.0.5",
"@e2b/code-interpreter": "0.0.9-beta.3",
"got": "^14.4.1",
"@apidevtools/swagger-parser": "^10.1.0",
"formdata-node": "^6.0.3"
"formdata-node": "^6.0.3",
"marked": "^14.1.2"
},
"devDependencies": {
"@types/cors": "^2.8.16",
@@ -14,5 +14,11 @@ export const chatUpload = async (req: Request, res: Response) => {
});
}
const index = await getDataSource(params);
if (!index) {
return res.status(500).json({
error:
"StorageContext is empty - call 'npm run generate' to generate the storage first",
});
}
return res.status(200).json(await uploadDocument(index, filename, base64));
};
@@ -1,64 +1,34 @@
import {
JSONValue,
LlamaIndexAdapter,
Message,
StreamData,
streamToResponse,
} from "ai";
import { LlamaIndexAdapter, Message, StreamData, streamToResponse } from "ai";
import { Request, Response } from "express";
import { ChatMessage, Settings } from "llamaindex";
import { createChatEngine } from "./engine/chat";
import {
convertMessageContent,
isValidMessages,
retrieveDocumentIds,
retrieveMessageContent,
} from "./llamaindex/streaming/annotations";
import {
createCallbackManager,
createStreamTimeout,
} from "./llamaindex/streaming/events";
import { createCallbackManager } from "./llamaindex/streaming/events";
import { generateNextQuestions } from "./llamaindex/streaming/suggestion";
export const chat = async (req: Request, res: Response) => {
// Init Vercel AI StreamData and timeout
const vercelStreamData = new StreamData();
const streamTimeout = createStreamTimeout(vercelStreamData);
try {
const { messages, data }: { messages: Message[]; data?: any } = req.body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
if (!isValidMessages(messages)) {
return res.status(400).json({
error:
"messages are required in the request body and the last message must be from the user",
});
}
let annotations = userMessage.annotations;
if (!annotations) {
// the user didn't send any new annotations with the last message
// so use the annotations from the last user message that has annotations
// REASON: GPT4 doesn't consider MessageContentDetail from previous messages, only strings
annotations = messages
.slice()
.reverse()
.find(
(message) => message.role === "user" && message.annotations,
)?.annotations;
}
// retrieve document Ids from the annotations of all messages (if any) and create chat engine with index
const allAnnotations: JSONValue[] = [...messages, userMessage].flatMap(
(message) => {
return message.annotations ?? [];
},
);
const ids = retrieveDocumentIds(allAnnotations);
// retrieve document ids from the annotations of all messages (if any)
const ids = retrieveDocumentIds(messages);
// create chat engine with index using the document ids
const chatEngine = await createChatEngine(ids, data);
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
annotations,
);
// retrieve user message content from Vercel/AI format
const userMessageContent = retrieveMessageContent(messages);
// Setup callbacks
const callbackManager = createCallbackManager(vercelStreamData);
@@ -96,7 +66,5 @@ export const chat = async (req: Request, res: Response) => {
return res.status(500).json({
detail: (error as Error).message,
});
} finally {
clearTimeout(streamTimeout);
}
};
@@ -0,0 +1,140 @@
/*
* Copyright 2023 FoundryLabs, Inc.
* Portions of this file are copied from the e2b project (https://github.com/e2b-dev/ai-artifacts)
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CodeInterpreter,
ExecutionError,
Result,
Sandbox,
} from "@e2b/code-interpreter";
import { Request, Response } from "express";
import { saveDocument } from "./llamaindex/documents/helper";
type CodeArtifact = {
commentary: string;
template: string;
title: string;
description: string;
additional_dependencies: string[];
has_additional_dependencies: boolean;
install_dependencies_command: string;
port: number | null;
file_path: string;
code: string;
};
const sandboxTimeout = 10 * 60 * 1000; // 10 minute in ms
export const maxDuration = 60;
export type ExecutionResult = {
template: string;
stdout: string[];
stderr: string[];
runtimeError?: ExecutionError;
outputUrls: Array<{ url: string; filename: string }>;
url: string;
};
export const sandbox = async (req: Request, res: Response) => {
const { artifact }: { artifact: CodeArtifact } = req.body;
let sbx: Sandbox | CodeInterpreter | undefined = undefined;
// Create a interpreter or a sandbox
if (artifact.template === "code-interpreter-multilang") {
sbx = await CodeInterpreter.create({
metadata: { template: artifact.template },
timeoutMs: sandboxTimeout,
});
console.log("Created code interpreter", sbx.sandboxID);
} else {
sbx = await Sandbox.create(artifact.template, {
metadata: { template: artifact.template, userID: "default" },
timeoutMs: sandboxTimeout,
});
console.log("Created sandbox", sbx.sandboxID);
}
// Install packages
if (artifact.has_additional_dependencies) {
if (sbx instanceof CodeInterpreter) {
await sbx.notebook.execCell(artifact.install_dependencies_command);
console.log(
`Installed dependencies: ${artifact.additional_dependencies.join(", ")} in code interpreter ${sbx.sandboxID}`,
);
} else if (sbx instanceof Sandbox) {
await sbx.commands.run(artifact.install_dependencies_command);
console.log(
`Installed dependencies: ${artifact.additional_dependencies.join(", ")} in sandbox ${sbx.sandboxID}`,
);
}
}
// Copy code to fs
if (artifact.code && Array.isArray(artifact.code)) {
artifact.code.forEach(async (file) => {
await sbx.files.write(file.file_path, file.file_content);
console.log(`Copied file to ${file.file_path} in ${sbx.sandboxID}`);
});
} else {
await sbx.files.write(artifact.file_path, artifact.code);
console.log(`Copied file to ${artifact.file_path} in ${sbx.sandboxID}`);
}
// Execute code or return a URL to the running sandbox
if (artifact.template === "code-interpreter-multilang") {
const result = await (sbx as CodeInterpreter).notebook.execCell(
artifact.code || "",
);
await (sbx as CodeInterpreter).close();
const outputUrls = await downloadCellResults(result.results);
return res.status(200).json({
template: artifact.template,
stdout: result.logs.stdout,
stderr: result.logs.stderr,
runtimeError: result.error,
outputUrls: outputUrls,
});
} else {
return res.status(200).json({
template: artifact.template,
url: `https://${sbx?.getHost(artifact.port || 80)}`,
});
}
};
async function downloadCellResults(
cellResults?: Result[],
): Promise<Array<{ url: string; filename: string }>> {
if (!cellResults) return [];
const results = await Promise.all(
cellResults.map(async (res) => {
const formats = res.formats(); // available formats in the result
const formatResults = await Promise.all(
formats.map(async (ext) => {
const filename = `${crypto.randomUUID()}.${ext}`;
const base64 = res[ext as keyof Result];
const buffer = Buffer.from(base64, "base64");
const fileurl = await saveDocument(filename, buffer);
return { url: fileurl, filename };
}),
);
return formatResults;
}),
);
return results.flat();
}
@@ -1,8 +1,8 @@
import logging
from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from llama_index.core.chat_engine.types import BaseChatEngine, NodeWithScore
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from llama_index.core.chat_engine.types import NodeWithScore
from llama_index.core.llms import MessageRole
from app.api.routers.events import EventCallbackHandler
@@ -58,11 +58,19 @@ async def chat(
@r.post("/request")
async def chat_request(
data: ChatData,
chat_engine: BaseChatEngine = Depends(get_chat_engine),
) -> Result:
last_message_content = data.get_last_message_content()
messages = data.get_history_messages()
doc_ids = data.get_chat_document_ids()
filters = generate_filters(doc_ids)
params = data.data or {}
logger.info(
f"Creating chat engine with filters: {str(filters)}",
)
chat_engine = get_chat_engine(filters=filters, params=params)
response = await chat_engine.achat(last_message_content, messages)
return Result(
result=Message(role=MessageRole.ASSISTANT, content=response.response),
@@ -50,9 +50,14 @@ class AnnotationFileData(BaseModel):
alias_generator = to_camel
class AgentAnnotation(BaseModel):
agent: str
text: str
class Annotation(BaseModel):
type: str
data: AnnotationFileData | List[str]
data: AnnotationFileData | List[str] | AgentAnnotation
def to_content(self) -> str | None:
if self.type == "document_file":
@@ -119,14 +124,48 @@ class ChatData(BaseModel):
break
return message_content
def get_history_messages(self) -> List[ChatMessage]:
def _get_agent_messages(self, max_messages: int = 10) -> List[str]:
"""
Construct agent messages from the annotations in the chat messages
"""
agent_messages = []
for message in self.messages:
if (
message.role == MessageRole.ASSISTANT
and message.annotations is not None
):
for annotation in message.annotations:
if annotation.type == "agent" and isinstance(
annotation.data, AgentAnnotation
):
text = annotation.data.text
agent_messages.append(
f"\nAgent: {annotation.data.agent}\nsaid: {text}\n"
)
if len(agent_messages) >= max_messages:
break
return agent_messages
def get_history_messages(
self, include_agent_messages: bool = False
) -> List[ChatMessage]:
"""
Get the history messages
"""
return [
chat_messages = [
ChatMessage(role=message.role, content=message.content)
for message in self.messages[:-1]
]
if include_agent_messages:
agent_messages = self._get_agent_messages(max_messages=5)
if len(agent_messages) > 0:
message = ChatMessage(
role=MessageRole.ASSISTANT,
content="Previous agent events: \n" + "\n".join(agent_messages),
)
chat_messages.append(message)
return chat_messages
def is_last_message_from_user(self) -> bool:
return self.messages[-1].role == MessageRole.USER
@@ -1,179 +0,0 @@
import {
ALL_AVAILABLE_MISTRAL_MODELS,
Anthropic,
GEMINI_EMBEDDING_MODEL,
GEMINI_MODEL,
Gemini,
GeminiEmbedding,
Groq,
MistralAI,
MistralAIEmbedding,
MistralAIEmbeddingModelType,
OpenAI,
OpenAIEmbedding,
Settings,
} from "llamaindex";
import { HuggingFaceEmbedding } from "llamaindex/embeddings/HuggingFaceEmbedding";
import { OllamaEmbedding } from "llamaindex/embeddings/OllamaEmbedding";
import { ALL_AVAILABLE_ANTHROPIC_MODELS } from "llamaindex/llm/anthropic";
import { Ollama } from "llamaindex/llm/ollama";
const CHUNK_SIZE = 512;
const CHUNK_OVERLAP = 20;
export const initSettings = async () => {
// HINT: you can delete the initialization code for unused model providers
console.log(`Using '${process.env.MODEL_PROVIDER}' model provider`);
if (!process.env.MODEL || !process.env.EMBEDDING_MODEL) {
throw new Error("'MODEL' and 'EMBEDDING_MODEL' env variables must be set.");
}
switch (process.env.MODEL_PROVIDER) {
case "ollama":
initOllama();
break;
case "groq":
initGroq();
break;
case "anthropic":
initAnthropic();
break;
case "gemini":
initGemini();
break;
case "mistral":
initMistralAI();
break;
case "azure-openai":
initAzureOpenAI();
break;
default:
initOpenAI();
break;
}
Settings.chunkSize = CHUNK_SIZE;
Settings.chunkOverlap = CHUNK_OVERLAP;
};
function initOpenAI() {
Settings.llm = new OpenAI({
model: process.env.MODEL ?? "gpt-4o-mini",
maxTokens: process.env.LLM_MAX_TOKENS
? Number(process.env.LLM_MAX_TOKENS)
: undefined,
});
Settings.embedModel = new OpenAIEmbedding({
model: process.env.EMBEDDING_MODEL,
dimensions: process.env.EMBEDDING_DIM
? parseInt(process.env.EMBEDDING_DIM)
: undefined,
});
}
function initAzureOpenAI() {
// Map Azure OpenAI model names to OpenAI model names (only for TS)
const AZURE_OPENAI_MODEL_MAP: Record<string, string> = {
"gpt-35-turbo": "gpt-3.5-turbo",
"gpt-35-turbo-16k": "gpt-3.5-turbo-16k",
"gpt-4o": "gpt-4o",
"gpt-4": "gpt-4",
"gpt-4-32k": "gpt-4-32k",
"gpt-4-turbo": "gpt-4-turbo",
"gpt-4-turbo-2024-04-09": "gpt-4-turbo",
"gpt-4-vision-preview": "gpt-4-vision-preview",
"gpt-4-1106-preview": "gpt-4-1106-preview",
"gpt-4o-2024-05-13": "gpt-4o-2024-05-13",
};
const azureConfig = {
apiKey: process.env.AZURE_OPENAI_KEY,
endpoint: process.env.AZURE_OPENAI_ENDPOINT,
apiVersion:
process.env.AZURE_OPENAI_API_VERSION || process.env.OPENAI_API_VERSION,
};
Settings.llm = new OpenAI({
model:
AZURE_OPENAI_MODEL_MAP[process.env.MODEL ?? "gpt-35-turbo"] ??
"gpt-3.5-turbo",
maxTokens: process.env.LLM_MAX_TOKENS
? Number(process.env.LLM_MAX_TOKENS)
: undefined,
azure: {
...azureConfig,
deployment: process.env.AZURE_OPENAI_LLM_DEPLOYMENT,
},
});
Settings.embedModel = new OpenAIEmbedding({
model: process.env.EMBEDDING_MODEL,
dimensions: process.env.EMBEDDING_DIM
? parseInt(process.env.EMBEDDING_DIM)
: undefined,
azure: {
...azureConfig,
deployment: process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT,
},
});
}
function initOllama() {
const config = {
host: process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434",
};
Settings.llm = new Ollama({
model: process.env.MODEL ?? "",
config,
});
Settings.embedModel = new OllamaEmbedding({
model: process.env.EMBEDDING_MODEL ?? "",
config,
});
}
function initGroq() {
const embedModelMap: Record<string, string> = {
"all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2",
"all-mpnet-base-v2": "Xenova/all-mpnet-base-v2",
};
Settings.llm = new Groq({
model: process.env.MODEL!,
});
Settings.embedModel = new HuggingFaceEmbedding({
modelType: embedModelMap[process.env.EMBEDDING_MODEL!],
});
}
function initAnthropic() {
const embedModelMap: Record<string, string> = {
"all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2",
"all-mpnet-base-v2": "Xenova/all-mpnet-base-v2",
};
Settings.llm = new Anthropic({
model: process.env.MODEL as keyof typeof ALL_AVAILABLE_ANTHROPIC_MODELS,
});
Settings.embedModel = new HuggingFaceEmbedding({
modelType: embedModelMap[process.env.EMBEDDING_MODEL!],
});
}
function initGemini() {
Settings.llm = new Gemini({
model: process.env.MODEL as GEMINI_MODEL,
});
Settings.embedModel = new GeminiEmbedding({
model: process.env.EMBEDDING_MODEL as GEMINI_EMBEDDING_MODEL,
});
}
function initMistralAI() {
Settings.llm = new MistralAI({
model: process.env.MODEL as keyof typeof ALL_AVAILABLE_MISTRAL_MODELS,
});
Settings.embedModel = new MistralAIEmbedding({
model: process.env.EMBEDDING_MODEL as MistralAIEmbeddingModelType,
});
}
@@ -1,17 +1,15 @@
import { initObservability } from "@/app/observability";
import { JSONValue, LlamaIndexAdapter, Message, StreamData } from "ai";
import { LlamaIndexAdapter, Message, StreamData } from "ai";
import { ChatMessage, Settings } from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { createChatEngine } from "./engine/chat";
import { initSettings } from "./engine/settings";
import {
convertMessageContent,
isValidMessages,
retrieveDocumentIds,
retrieveMessageContent,
} from "./llamaindex/streaming/annotations";
import {
createCallbackManager,
createStreamTimeout,
} from "./llamaindex/streaming/events";
import { createCallbackManager } from "./llamaindex/streaming/events";
import { generateNextQuestions } from "./llamaindex/streaming/suggestion";
initObservability();
@@ -23,13 +21,11 @@ export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
// Init Vercel AI StreamData and timeout
const vercelStreamData = new StreamData();
const streamTimeout = createStreamTimeout(vercelStreamData);
try {
const body = await request.json();
const { messages, data }: { messages: Message[]; data?: any } = body;
const userMessage = messages.pop();
if (!messages || !userMessage || userMessage.role !== "user") {
if (!isValidMessages(messages)) {
return NextResponse.json(
{
error:
@@ -39,33 +35,13 @@ export async function POST(request: NextRequest) {
);
}
let annotations = userMessage.annotations;
if (!annotations) {
// the user didn't send any new annotations with the last message
// so use the annotations from the last user message that has annotations
// REASON: GPT4 doesn't consider MessageContentDetail from previous messages, only strings
annotations = messages
.slice()
.reverse()
.find(
(message) => message.role === "user" && message.annotations,
)?.annotations;
}
// retrieve document Ids from the annotations of all messages (if any) and create chat engine with index
const allAnnotations: JSONValue[] = [...messages, userMessage].flatMap(
(message) => {
return message.annotations ?? [];
},
);
const ids = retrieveDocumentIds(allAnnotations);
// retrieve document ids from the annotations of all messages (if any)
const ids = retrieveDocumentIds(messages);
// create chat engine with index using the document ids
const chatEngine = await createChatEngine(ids, data);
// Convert message content from Vercel/AI format to LlamaIndex/OpenAI format
const userMessageContent = convertMessageContent(
userMessage.content,
annotations,
);
// retrieve user message content from Vercel/AI format
const userMessageContent = retrieveMessageContent(messages);
// Setup callbacks
const callbackManager = createCallbackManager(vercelStreamData);
@@ -110,7 +86,5 @@ export async function POST(request: NextRequest) {
status: 500,
},
);
} finally {
clearTimeout(streamTimeout);
}
}
@@ -0,0 +1,142 @@
/*
* Copyright 2023 FoundryLabs, Inc.
* Portions of this file are copied from the e2b project (https://github.com/e2b-dev/ai-artifacts)
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CodeInterpreter,
ExecutionError,
Result,
Sandbox,
} from "@e2b/code-interpreter";
import { saveDocument } from "../chat/llamaindex/documents/helper";
type CodeArtifact = {
commentary: string;
template: string;
title: string;
description: string;
additional_dependencies: string[];
has_additional_dependencies: boolean;
install_dependencies_command: string;
port: number | null;
file_path: string;
code: string;
};
const sandboxTimeout = 10 * 60 * 1000; // 10 minute in ms
export const maxDuration = 60;
export type ExecutionResult = {
template: string;
stdout: string[];
stderr: string[];
runtimeError?: ExecutionError;
outputUrls: Array<{ url: string; filename: string }>;
url: string;
};
export async function POST(req: Request) {
const { artifact }: { artifact: CodeArtifact } = await req.json();
let sbx: Sandbox | CodeInterpreter | undefined = undefined;
// Create a interpreter or a sandbox
if (artifact.template === "code-interpreter-multilang") {
sbx = await CodeInterpreter.create({
metadata: { template: artifact.template },
timeoutMs: sandboxTimeout,
});
console.log("Created code interpreter", sbx.sandboxID);
} else {
sbx = await Sandbox.create(artifact.template, {
metadata: { template: artifact.template, userID: "default" },
timeoutMs: sandboxTimeout,
});
console.log("Created sandbox", sbx.sandboxID);
}
// Install packages
if (artifact.has_additional_dependencies) {
if (sbx instanceof CodeInterpreter) {
await sbx.notebook.execCell(artifact.install_dependencies_command);
console.log(
`Installed dependencies: ${artifact.additional_dependencies.join(", ")} in code interpreter ${sbx.sandboxID}`,
);
} else if (sbx instanceof Sandbox) {
await sbx.commands.run(artifact.install_dependencies_command);
console.log(
`Installed dependencies: ${artifact.additional_dependencies.join(", ")} in sandbox ${sbx.sandboxID}`,
);
}
}
// Copy code to fs
if (artifact.code && Array.isArray(artifact.code)) {
artifact.code.forEach(async (file) => {
await sbx.files.write(file.file_path, file.file_content);
console.log(`Copied file to ${file.file_path} in ${sbx.sandboxID}`);
});
} else {
await sbx.files.write(artifact.file_path, artifact.code);
console.log(`Copied file to ${artifact.file_path} in ${sbx.sandboxID}`);
}
// Execute code or return a URL to the running sandbox
if (artifact.template === "code-interpreter-multilang") {
const result = await (sbx as CodeInterpreter).notebook.execCell(
artifact.code || "",
);
await (sbx as CodeInterpreter).close();
const outputUrls = await downloadCellResults(result.results);
return new Response(
JSON.stringify({
template: artifact.template,
stdout: result.logs.stdout,
stderr: result.logs.stderr,
runtimeError: result.error,
outputUrls: outputUrls,
}),
);
} else {
return new Response(
JSON.stringify({
template: artifact.template,
url: `https://${sbx?.getHost(artifact.port || 80)}`,
}),
);
}
}
async function downloadCellResults(
cellResults?: Result[],
): Promise<Array<{ url: string; filename: string }>> {
if (!cellResults) return [];
const results = await Promise.all(
cellResults.map(async (res) => {
const formats = res.formats(); // available formats in the result
const formatResults = await Promise.all(
formats.map(async (ext) => {
const filename = `${crypto.randomUUID()}.${ext}`;
const base64 = res[ext as keyof Result];
const buffer = Buffer.from(base64, "base64");
const fileurl = await saveDocument(filename, buffer);
return { url: fileurl, filename };
}),
);
return formatResults;
}),
);
return results.flat();
}
@@ -1,8 +1,9 @@
import { JSONValue } from "ai";
import React from "react";
import { Button } from "../button";
import { DocumentPreview } from "../document-preview";
import FileUploader from "../file-uploader";
import { Input } from "../input";
import { Textarea } from "../textarea";
import UploadImagePreview from "../upload-image-preview";
import { ChatHandler } from "./chat.interface";
import { useFile } from "./hooks/use-file";
@@ -54,6 +55,7 @@ export default function ChatInput(
};
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const annotations = getAnnotations();
if (annotations.length) {
handleSubmitWithAnnotations(e, annotations);
@@ -76,6 +78,13 @@ export default function ChatInput(
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit(e as unknown as React.FormEvent<HTMLFormElement>);
}
};
return (
<form
onSubmit={onSubmit}
@@ -96,13 +105,15 @@ export default function ChatInput(
</div>
)}
<div className="flex w-full items-start justify-between gap-4 ">
<Input
<Textarea
id="chat-input"
autoFocus
name="message"
placeholder="Type a message"
className="flex-1"
className="flex-1 min-h-0 h-[40px]"
value={props.input}
onChange={props.handleInputChange}
onKeyDown={handleKeyDown}
/>
<FileUploader
onFileUpload={handleUploadFile}
@@ -17,6 +17,7 @@ const AgentIcons: Record<string, LucideIcon> = {
researcher: icons.ScanSearch,
writer: icons.PenLine,
reviewer: icons.MessageCircle,
publisher: icons.BookCheck,
};
type MergedEvent = {
@@ -1,8 +1,15 @@
import { ToolData } from "../index";
import { Artifact, CodeArtifact } from "../widgets/Artifact";
import { WeatherCard, WeatherData } from "../widgets/WeatherCard";
// TODO: If needed, add displaying more tool outputs here
export default function ChatTools({ data }: { data: ToolData }) {
export default function ChatTools({
data,
artifactVersion,
}: {
data: ToolData;
artifactVersion?: number;
}) {
if (!data) return null;
const { toolCall, toolOutput } = data;
@@ -20,6 +27,13 @@ export default function ChatTools({ data }: { data: ToolData }) {
case "get_weather_information":
const weatherData = toolOutput.output as unknown as WeatherData;
return <WeatherCard data={weatherData} />;
case "artifact":
return (
<Artifact
artifact={toolOutput.output as CodeArtifact}
version={artifactVersion}
/>
);
default:
return null;
}
@@ -37,11 +37,13 @@ function ChatMessageContent({
isLoading,
append,
isLastMessage,
artifactVersion,
}: {
message: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
isLastMessage: boolean;
artifactVersion: number | undefined;
}) {
const annotations = message.annotations as MessageAnnotation[] | undefined;
if (!annotations?.length) return <Markdown content={message.content} />;
@@ -104,7 +106,9 @@ function ChatMessageContent({
},
{
order: -1,
component: toolData[0] ? <ChatTools data={toolData[0]} /> : null,
component: toolData[0] ? (
<ChatTools data={toolData[0]} artifactVersion={artifactVersion} />
) : null,
},
{
order: 0,
@@ -142,11 +146,13 @@ export default function ChatMessage({
isLoading,
append,
isLastMessage,
artifactVersion,
}: {
chatMessage: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
isLastMessage: boolean;
artifactVersion: number | undefined;
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
return (
@@ -158,6 +164,7 @@ export default function ChatMessage({
isLoading={isLoading}
append={append}
isLastMessage={isLastMessage}
artifactVersion={artifactVersion}
/>
<Button
onClick={() => copyToClipboard(chatMessage.content)}
@@ -1,6 +1,7 @@
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ToolData } from ".";
import { Button } from "../button";
import ChatActions from "./chat-actions";
import ChatMessage from "./chat-message";
@@ -55,6 +56,29 @@ export default function ChatMessages(
}
}, [starterQuestions, backend]);
// build a map of message id to artifact version
const artifactVersionMap = useMemo(() => {
const map = new Map<string, number | undefined>();
let versionIndex = 1;
props.messages.forEach((m) => {
m.annotations?.forEach((annotation) => {
if (
typeof annotation === "object" &&
annotation != null &&
"type" in annotation &&
annotation.type === "tools"
) {
const data = annotation.data as ToolData;
if (data?.toolCall?.name === "artifact") {
map.set(m.id, versionIndex);
versionIndex++;
}
}
});
});
return map;
}, [props.messages]);
return (
<div
className="flex-1 w-full rounded-xl bg-white p-4 shadow-xl relative overflow-y-auto"
@@ -70,6 +94,7 @@ export default function ChatMessages(
isLoading={isLoadingMessage}
append={props.append!}
isLastMessage={i === messageLength - 1}
artifactVersion={artifactVersionMap.get(m.id)}
/>
);
})}
@@ -10,7 +10,7 @@ export interface ChatHandler {
data?: any;
},
) => void;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
reload?: () => void;
stop?: () => void;
onFileUpload?: (file: File) => Promise<void>;
@@ -0,0 +1,379 @@
"use client";
import { Check, ChevronDown, Code, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button, buttonVariants } from "../../button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../../collapsible";
import { cn } from "../../lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tabs";
import Markdown from "../chat-message/markdown";
import { useClientConfig } from "../hooks/use-config";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
// detail information to execute code
export type CodeArtifact = {
commentary: string;
template: string;
title: string;
description: string;
additional_dependencies: string[];
has_additional_dependencies: boolean;
install_dependencies_command: string;
port: number | null;
file_path: string;
code: string;
};
type ArtifactResult = {
template: string;
stdout: string[];
stderr: string[];
runtimeError?: { name: string; value: string; tracebackRaw: string[] };
outputUrls: Array<{ url: string; filename: string }>;
url: string;
};
export function Artifact({
artifact,
version,
}: {
artifact: CodeArtifact | null;
version?: number;
}) {
const [result, setResult] = useState<ArtifactResult | null>(null);
const [sandboxCreationError, setSandboxCreationError] = useState<string>();
const [sandboxCreating, setSandboxCreating] = useState(false);
const [openOutputPanel, setOpenOutputPanel] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const { backend } = useClientConfig();
const handleOpenOutput = async () => {
setOpenOutputPanel(true);
openPanel();
panelRef.current?.classList.remove("hidden");
};
const fetchArtifactResult = async () => {
try {
setSandboxCreating(true);
const response = await fetch(`${backend}/api/sandbox`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ artifact }),
});
if (!response.ok) {
throw new Error("Failure running code artifact");
}
const fetchedResult = await response.json();
setResult(fetchedResult);
} catch (error) {
console.error("Error fetching artifact result:", error);
setSandboxCreationError(
error instanceof Error
? error.message
: "An unknown error occurred when executing code",
);
} finally {
setSandboxCreating(false);
}
};
useEffect(() => {
// auto trigger code execution
!result && fetchArtifactResult();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!artifact || version === undefined) return null;
return (
<div>
<div
onClick={handleOpenOutput}
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto cursor-pointer px-6 py-3 w-full flex gap-4 items-center justify-start border border-gray-200 rounded-md",
)}
>
<Code className="h-6 w-6" />
<div className="flex flex-col gap-1">
<h4 className="font-semibold m-0">
{artifact.title} v{version}
</h4>
<span className="text-xs text-gray-500">Click to open code</span>
</div>
</div>
{openOutputPanel && (
<div
className="w-[45vw] fixed top-0 right-0 h-screen z-50 artifact-panel animate-slideIn"
ref={panelRef}
>
{sandboxCreating && (
<div className="flex justify-center items-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
{sandboxCreationError && (
<div className="p-4 bg-red-100 text-red-800 rounded-md m-4">
<h3 className="font-bold mb-2 mt-0">
Error when creating Sandbox:
</h3>
<p className="font-semibold">{sandboxCreationError}</p>
</div>
)}
{result && (
<ArtifactOutput
artifact={artifact}
result={result}
version={version}
/>
)}
</div>
)}
</div>
);
}
function ArtifactOutput({
artifact,
result,
version,
}: {
artifact: CodeArtifact;
result: ArtifactResult;
version: number;
}) {
const fileExtension = artifact.file_path.split(".").pop() || "";
const markdownCode = `\`\`\`${fileExtension}\n${artifact.code}\n\`\`\``;
const { url: sandboxUrl, outputUrls, runtimeError, stderr, stdout } = result;
return (
<>
<div className="flex justify-between items-center pl-5 pr-10 py-6">
<div className="space-y-2">
<h2 className="text-2xl font-bold m-0">{artifact.title}</h2>
<span className="text-sm text-gray-500">Version: v{version}</span>
</div>
<Button onClick={closePanel}>Close</Button>
</div>
<Tabs defaultValue="code" className="h-full p-4 overflow-auto">
<TabsList className="grid grid-cols-2 max-w-[400px] mx-auto">
<TabsTrigger value="code">Code</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="code" className="h-[80%] mb-4 overflow-auto">
<div className="m-4 overflow-auto">
<Markdown content={markdownCode} />
</div>
</TabsContent>
<TabsContent
value="preview"
className="h-[80%] mb-4 overflow-auto mt-4 space-y-4"
>
{runtimeError && <RunTimeError runtimeError={runtimeError} />}
<ArtifactLogs stderr={stderr} stdout={stdout} />
{sandboxUrl && <CodeSandboxPreview url={sandboxUrl} />}
{outputUrls && <InterpreterOutput outputUrls={outputUrls} />}
</TabsContent>
</Tabs>
</>
);
}
function RunTimeError({
runtimeError,
}: {
runtimeError: { name: string; value: string; tracebackRaw: string[] };
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
const contentToCopy = `Fix this error:\n${runtimeError.name}\n${runtimeError.value}\n${runtimeError.tracebackRaw.join("\n")}`;
return (
<Collapsible className="bg-red-100 text-red-800 rounded-md py-2 px-4 space-y-4">
<CollapsibleTrigger className="font-bold w-full text-start flex items-center justify-between">
<span>Runtime Error:</span>
<ChevronDown className="w-4 h-4" />
</CollapsibleTrigger>
<CollapsibleContent className="text-sm flex gap-2">
<div className="flex flex-col gap-2">
<p className="font-semibold">{runtimeError.name}</p>
<p>{runtimeError.value}</p>
{runtimeError.tracebackRaw.map((trace, index) => (
<pre key={index} className="whitespace-pre-wrap text-sm mb-2">
{trace}
</pre>
))}
</div>
<Button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(contentToCopy);
}}
size="icon"
variant="ghost"
className="h-12 w-12 shrink-0"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</CollapsibleContent>
</Collapsible>
);
}
function CodeSandboxPreview({ url }: { url: string }) {
const [loading, setLoading] = useState(true);
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (!loading && iframeRef.current) {
iframeRef.current.focus();
}
}, [loading]);
return (
<>
<iframe
key={url}
ref={iframeRef}
className="h-full w-full"
sandbox="allow-forms allow-scripts allow-same-origin"
loading="lazy"
src={url}
onLoad={() => setLoading(false)}
/>
{loading && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
)}
</>
);
}
function InterpreterOutput({
outputUrls,
}: {
outputUrls: Array<{ url: string; filename: string }>;
}) {
return (
<ul className="flex flex-col gap-2 mt-4">
{outputUrls.map((url) => (
<li key={url.url}>
<div className="mt-4">
{isImageFile(url.filename) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url.url} alt={url.filename} className="my-4 w-1/2" />
) : (
<a
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline"
>
{url.filename}
</a>
)}
</div>
</li>
))}
</ul>
);
}
function ArtifactLogs({
stderr,
stdout,
}: {
stderr?: string[];
stdout?: string[];
}) {
if (!stderr?.length && !stdout?.length) return null;
return (
<div className="flex flex-col gap-4">
{stdout && stdout.length > 0 && (
<Collapsible className="bg-green-100 text-green-800 rounded-md py-2 px-4 space-y-4">
<CollapsibleTrigger className="font-bold w-full text-start flex items-center justify-between">
<span>Output log:</span>
<ChevronDown className="w-4 h-4" />
</CollapsibleTrigger>
<CollapsibleContent className="text-sm">
<ArtifactLogItems logs={stdout} />
</CollapsibleContent>
</Collapsible>
)}
{stderr && stderr.length > 0 && (
<Collapsible className="bg-yellow-100 text-yellow-800 rounded-md py-2 px-4 space-y-4">
<CollapsibleTrigger className="font-bold w-full text-start flex items-center justify-between">
<span>Error log:</span>
<ChevronDown className="w-4 h-4" />
</CollapsibleTrigger>
<CollapsibleContent className="text-sm">
<ArtifactLogItems logs={stderr} />
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
function ArtifactLogItems({ logs }: { logs: string[] }) {
return (
<ul className="flex flex-col gap-2">
{logs.map((log, index) => (
<li key={index}>
<pre className="whitespace-pre-wrap text-sm">{log}</pre>
</li>
))}
</ul>
);
}
function isImageFile(filename: string): boolean {
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
return imageExtensions.some((ext) => filename.toLowerCase().endsWith(ext));
}
// this is just a hack to handle the layout when opening or closing the output panel
// for real world application, you should use a global state management to control layout
function openPanel() {
// hide all current artifact panel
const artifactPanels = document.querySelectorAll(".artifact-panel");
artifactPanels.forEach((panel) => {
panel.classList.add("hidden");
});
// make the main div width smaller to have space for the output panel
const mainDiv = document.querySelector("main");
mainDiv?.classList.remove("w-screen");
mainDiv?.classList.add("w-[55vw]");
mainDiv?.classList.add("px-8");
}
function closePanel() {
// reset the main div width
const mainDiv = document.querySelector("main");
mainDiv?.classList.remove("w-[55vw]");
mainDiv?.classList.remove("px-8");
mainDiv?.classList.add("w-screen");
// hide all current artifact panel
const artifactPanels = document.querySelectorAll(".artifact-panel");
artifactPanels.forEach((panel) => {
panel.classList.add("hidden");
});
}
@@ -0,0 +1,54 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "./lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "./lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -108,4 +108,19 @@
animation-name: fadeIn;
animation-duration: 1.5s;
}
@keyframes slideIn {
from {
transform: translateX(10%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slideIn {
animation: slideIn 0.5s ease-out;
}
}
@@ -11,12 +11,14 @@
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@e2b/code-interpreter": "^0.0.5",
"@e2b/code-interpreter": "0.0.9-beta.3",
"@llamaindex/core": "^0.2.6",
"@llamaindex/pdf-viewer": "^1.1.3",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.0",
"ai": "3.3.42",
"ajv": "^8.12.0",
"class-variance-authority": "^0.7.0",
@@ -41,7 +43,8 @@
"tailwind-merge": "^2.1.0",
"tiktoken": "^1.0.15",
"uuid": "^9.0.1",
"vaul": "^0.9.1"
"vaul": "^0.9.1",
"marked": "^14.1.2"
},
"devDependencies": {
"@types/node": "^20.10.3",