add rowboat app
1
apps/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.DS_Store
|
||||
3
apps/rowboat/.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
42
apps/rowboat/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# crawler script artifacts
|
||||
chunked.jsonl
|
||||
crawled.jsonl
|
||||
embeddings.jsonl
|
||||
rewritten.jsonl
|
||||
36
apps/rowboat/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
976
apps/rowboat/app/actions.ts
Normal file
|
|
@ -0,0 +1,976 @@
|
|||
'use server';
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { SimulationData, EmbeddingDoc, GetInformationToolResult, DataSource, PlaygroundChat, AgenticAPIChatRequest, AgenticAPIChatResponse, convertFromAgenticAPIChatMessages, WebpageCrawlResponse, Workflow, WorkflowAgent, CopilotAPIRequest, CopilotAPIResponse, CopilotMessage, CopilotWorkflow, convertToCopilotWorkflow, convertToCopilotApiMessage, convertToCopilotMessage, CopilotAssistantMessage, CopilotChatContext, convertToCopilotApiChatContext, Scenario, ClientToolCallRequestBody, ClientToolCallJwt, ClientToolCallRequest, WithStringId, Project } from "./lib/types";
|
||||
import { ObjectId, WithId } from "mongodb";
|
||||
import { generateObject, generateText, tool, embed } from "ai";
|
||||
import { dataSourcesCollection, embeddingsCollection, projectsCollection, webpagesCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection } from "@/app/lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
|
||||
import { embeddingModel } from "./lib/embedding";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import crypto from 'crypto';
|
||||
import { SignJWT } from "jose";
|
||||
import { Claims, getSession } from "@auth0/nextjs-auth0";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { baseWorkflow } from "./lib/utils";
|
||||
|
||||
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY });
|
||||
|
||||
export async function authCheck(): Promise<Claims> {
|
||||
const { user } = await getSession() || {};
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function projectAuthCheck(projectId: string) {
|
||||
const user = await authCheck();
|
||||
const membership = await projectMembersCollection.findOne({
|
||||
projectId,
|
||||
userId: user.sub,
|
||||
});
|
||||
if (!membership) {
|
||||
throw new Error('User not a member of project');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkflow(projectId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// get the next workflow number
|
||||
const doc = await projectsCollection.findOneAndUpdate({
|
||||
_id: projectId,
|
||||
}, {
|
||||
$inc: {
|
||||
nextWorkflowNumber: 1,
|
||||
},
|
||||
}, {
|
||||
returnDocument: 'after'
|
||||
});
|
||||
if (!doc) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const nextWorkflowNumber = doc.nextWorkflowNumber;
|
||||
|
||||
// create the workflow
|
||||
const workflow = {
|
||||
...baseWorkflow,
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
name: `Version ${nextWorkflowNumber}`,
|
||||
};
|
||||
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
|
||||
const { _id, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
|
||||
return {
|
||||
...rest,
|
||||
_id: insertedId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function cloneWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// create a new workflow with the same content
|
||||
const newWorkflow = {
|
||||
...workflow,
|
||||
_id: new ObjectId(),
|
||||
name: `Copy of ${workflow.name || 'Unnamed workflow'}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { insertedId } = await agentWorkflowsCollection.insertOne(newWorkflow);
|
||||
const { _id, ...rest } = newWorkflow as WithId<z.infer<typeof Workflow>>;
|
||||
return {
|
||||
...rest,
|
||||
_id: insertedId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameWorkflow(projectId: string, workflowId: string, name: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await agentWorkflowsCollection.updateOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
}, {
|
||||
$set: {
|
||||
name,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveWorkflow(projectId: string, workflowId: string, workflow: z.infer<typeof Workflow>) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// check if workflow exists
|
||||
const existingWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!existingWorkflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// ensure that this is not the published workflow for this project
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
if (publishedWorkflowId && publishedWorkflowId === workflowId) {
|
||||
throw new Error('Cannot save published workflow');
|
||||
}
|
||||
|
||||
// update the workflow, except name and description
|
||||
const { _id, name, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
|
||||
await agentWorkflowsCollection.updateOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
}, {
|
||||
$set: {
|
||||
...rest,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishWorkflow(projectId: string, workflowId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// check if workflow exists
|
||||
const existingWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!existingWorkflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// publish the workflow
|
||||
await projectsCollection.updateOne({
|
||||
"_id": projectId,
|
||||
}, {
|
||||
$set: {
|
||||
publishedWorkflowId: workflowId,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPublishedWorkflowId(projectId: string): Promise<string | null> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
return project?.publishedWorkflowId || null;
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
const { _id, ...rest } = workflow;
|
||||
return {
|
||||
...rest,
|
||||
_id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listWorkflows(
|
||||
projectId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{
|
||||
workflows: (WithStringId<z.infer<typeof Workflow>>)[];
|
||||
total: number;
|
||||
publishedWorkflowId: string | null;
|
||||
}> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// fetch total count
|
||||
const total = await agentWorkflowsCollection.countDocuments({ projectId });
|
||||
|
||||
// fetch published workflow
|
||||
let publishedWorkflowId: string | null = null;
|
||||
let publishedWorkflow: WithId<z.infer<typeof Workflow>> | null = null;
|
||||
if (page === 1) {
|
||||
publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
if (publishedWorkflowId) {
|
||||
publishedWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(publishedWorkflowId),
|
||||
projectId,
|
||||
}, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
description: 1,
|
||||
createdAt: 1,
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// fetch workflows with pagination
|
||||
let workflows: WithId<z.infer<typeof Workflow>>[] = await agentWorkflowsCollection.find(
|
||||
{
|
||||
projectId,
|
||||
...(publishedWorkflowId ? {
|
||||
_id: {
|
||||
$ne: new ObjectId(publishedWorkflowId)
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
{
|
||||
sort: { lastUpdatedAt: -1 },
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
description: 1,
|
||||
createdAt: 1,
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
skip: (page - 1) * limit,
|
||||
limit: limit,
|
||||
}
|
||||
).toArray();
|
||||
workflows = [
|
||||
...(publishedWorkflow ? [publishedWorkflow] : []),
|
||||
...workflows,
|
||||
];
|
||||
|
||||
// return workflows
|
||||
return {
|
||||
workflows: workflows.map((w) => {
|
||||
const { _id, ...rest } = w;
|
||||
return {
|
||||
...rest,
|
||||
_id: _id.toString(),
|
||||
};
|
||||
}),
|
||||
total,
|
||||
publishedWorkflowId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
|
||||
const page = await webpagesCollection.findOne({
|
||||
"_id": url,
|
||||
lastUpdatedAt: {
|
||||
'$gte': new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 24 hours
|
||||
},
|
||||
});
|
||||
if (page) {
|
||||
// console.log("found webpage in db", url);
|
||||
return {
|
||||
title: page.title,
|
||||
content: page.contentSimple,
|
||||
};
|
||||
}
|
||||
|
||||
// otherwise use firecrawl
|
||||
const scrapeResult = await crawler.scrapeUrl(
|
||||
url,
|
||||
{
|
||||
formats: ['markdown'],
|
||||
onlyMainContent: true
|
||||
}
|
||||
) as ScrapeResponse;
|
||||
|
||||
// save the webpage using upsert
|
||||
await webpagesCollection.updateOne(
|
||||
{ _id: url },
|
||||
{
|
||||
$set: {
|
||||
title: scrapeResult.metadata?.title || '',
|
||||
contentSimple: scrapeResult.markdown || '',
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// console.log("crawled webpage", url);
|
||||
return {
|
||||
title: scrapeResult.metadata?.title || '',
|
||||
content: scrapeResult.markdown || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProject(formData: FormData) {
|
||||
const user = await authCheck();
|
||||
const name = formData.get('name') as string;
|
||||
const projectId = crypto.randomUUID();
|
||||
const chatClientId = crypto.randomBytes(16).toString('base64url');
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
await projectsCollection.insertOne({
|
||||
_id: projectId,
|
||||
name: name,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
createdByUserId: user.sub,
|
||||
chatClientId,
|
||||
secret,
|
||||
});
|
||||
// add user to project
|
||||
await projectMembersCollection.insertOne({
|
||||
userId: user.sub,
|
||||
projectId: projectId,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
});
|
||||
redirect(`/projects/${projectId}/workflow`);
|
||||
}
|
||||
|
||||
export async function getProjectConfig(projectId: string): Promise<z.infer<typeof Project>> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
if (!project) {
|
||||
throw new Error('Project config not found');
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
|
||||
const user = await authCheck();
|
||||
const memberships = await projectMembersCollection.find({
|
||||
userId: user.sub,
|
||||
}).toArray();
|
||||
const projectIds = memberships.map((m) => m.projectId);
|
||||
const projects = await projectsCollection.find({
|
||||
_id: { $in: projectIds },
|
||||
}).toArray();
|
||||
return projects;
|
||||
}
|
||||
|
||||
export async function listSources(projectId: string): Promise<WithStringId<z.infer<typeof DataSource>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
const sources = await dataSourcesCollection.find({
|
||||
projectId: projectId,
|
||||
}).toArray();
|
||||
return sources.map((s) => ({
|
||||
...s,
|
||||
_id: s._id.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createCrawlDataSource(projectId: string, formData: FormData) {
|
||||
await projectAuthCheck(projectId);
|
||||
const url = formData.get('url') as string;
|
||||
const name = formData.get('name') as string;
|
||||
const limit = Number(formData.get('limit'));
|
||||
|
||||
const result = await dataSourcesCollection.insertOne({
|
||||
projectId: projectId,
|
||||
active: true,
|
||||
name: name,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
status: "new",
|
||||
data: {
|
||||
type: 'crawl',
|
||||
startUrl: url,
|
||||
limit: limit,
|
||||
}
|
||||
});
|
||||
|
||||
redirect(`/projects/${projectId}/sources/${result.insertedId}`);
|
||||
}
|
||||
|
||||
export async function createUrlsDataSource(projectId: string, formData: FormData) {
|
||||
await projectAuthCheck(projectId);
|
||||
const urls = formData.get('urls') as string;
|
||||
// take first 100 urls
|
||||
const limitedUrls = urls.split('\n').slice(0, 100).map((url) => url.trim());
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
const result = await dataSourcesCollection.insertOne({
|
||||
projectId: projectId,
|
||||
active: true,
|
||||
name: name,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
status: "new",
|
||||
data: {
|
||||
type: 'urls',
|
||||
urls: limitedUrls,
|
||||
}
|
||||
});
|
||||
|
||||
redirect(`/projects/${projectId}/sources/${result.insertedId}`);
|
||||
}
|
||||
|
||||
export async function recrawlWebDataSource(projectId: string, sourceId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const source = await dataSourcesCollection.findOne({
|
||||
"_id": new ObjectId(sourceId),
|
||||
"projectId": projectId,
|
||||
});
|
||||
if (!source) {
|
||||
throw new Error('Data source not found');
|
||||
}
|
||||
|
||||
await dataSourcesCollection.updateOne({
|
||||
"_id": new ObjectId(sourceId),
|
||||
}, {
|
||||
$set: {
|
||||
"status": "new",
|
||||
"attempts": 0,
|
||||
},
|
||||
$unset: {
|
||||
'data.firecrawlId': '',
|
||||
'data.crawledUrls': '',
|
||||
'data.scrapedUrls': '',
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${projectId}/sources/${sourceId}`);
|
||||
}
|
||||
|
||||
export async function deleteDataSource(projectId: string, sourceId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await dataSourcesCollection.deleteOne({
|
||||
_id: new ObjectId(sourceId),
|
||||
});
|
||||
|
||||
await embeddingsCollection.deleteMany({
|
||||
sourceId: sourceId,
|
||||
});
|
||||
|
||||
redirect(`/projects/${projectId}/sources`);
|
||||
}
|
||||
|
||||
export async function getAssistantResponse(
|
||||
projectId: string,
|
||||
request: z.infer<typeof AgenticAPIChatRequest>,
|
||||
): Promise<{
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[],
|
||||
state: unknown,
|
||||
rawAPIResponse: unknown,
|
||||
}> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// call agentic api
|
||||
const response = await fetch(process.env.AGENTIC_API_URL + '/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Failed to call agentic api', response);
|
||||
throw new Error(`Failed to call agentic api: ${response.statusText}`);
|
||||
}
|
||||
const responseJson = await response.json();
|
||||
const result: z.infer<typeof AgenticAPIChatResponse> = responseJson;
|
||||
return {
|
||||
messages: convertFromAgenticAPIChatMessages(result.messages),
|
||||
state: result.state,
|
||||
rawAPIResponse: result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCopilotResponse(
|
||||
projectId: string,
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
current_workflow_config: z.infer<typeof Workflow>,
|
||||
context: z.infer<typeof CopilotChatContext> | null,
|
||||
): Promise<z.infer<typeof CopilotAssistantMessage>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
|
||||
context: context ? convertToCopilotApiChatContext(context) : null,
|
||||
};
|
||||
console.log(`copilot request`, JSON.stringify(request, null, 2));
|
||||
|
||||
// call copilot api
|
||||
const response = await fetch(process.env.COPILOT_API_URL + '/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Failed to call copilot api', response);
|
||||
throw new Error(`Failed to call copilot api: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// parse and return response
|
||||
const json: z.infer<typeof CopilotAPIResponse> = await response.json();
|
||||
console.log(`copilot response`, JSON.stringify(json, null, 2));
|
||||
if ('error' in json) {
|
||||
throw new Error(`Failed to call copilot api: ${json.error}`);
|
||||
}
|
||||
// remove leading ```json and trailing ```
|
||||
const msg = convertToCopilotMessage({
|
||||
role: 'assistant',
|
||||
content: json.response.replace(/^```json\n/, '').replace(/\n```$/, ''),
|
||||
});
|
||||
return msg as z.infer<typeof CopilotAssistantMessage>;
|
||||
}
|
||||
|
||||
export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer<typeof apiV1.ChatMessage>[]): Promise<string> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const prompt = `
|
||||
# Your Specific Task:
|
||||
Here is a chat between a user and a customer support assistant.
|
||||
The assistant has requested a tool call with ID {{toolID}}.
|
||||
Your job is to come up with an example of the data that the tool call should return.
|
||||
The current date is {{date}}.
|
||||
|
||||
CONVERSATION:
|
||||
{{messages}}
|
||||
`
|
||||
.replace('{{toolID}}', toolId)
|
||||
.replace(`{{date}}`, new Date().toISOString())
|
||||
.replace('{{messages}}', JSON.stringify(messages.map((m) => {
|
||||
let tool_calls;
|
||||
if ('tool_calls' in m && m.role == 'assistant') {
|
||||
tool_calls = m.tool_calls;
|
||||
}
|
||||
let { role, content } = m;
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
tool_calls,
|
||||
}
|
||||
})));
|
||||
// console.log(prompt);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: openai("gpt-4o"),
|
||||
prompt: prompt,
|
||||
schema: z.object({
|
||||
result: z.any(),
|
||||
}),
|
||||
});
|
||||
|
||||
return JSON.stringify(object);
|
||||
}
|
||||
|
||||
export async function getDataSource(projectId: string, sourceId: string): Promise<z.infer<typeof DataSource>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const source = await dataSourcesCollection.findOne({
|
||||
"_id": new ObjectId(sourceId),
|
||||
"projectId": projectId,
|
||||
});
|
||||
if (!source) {
|
||||
throw new Error('Data source not found');
|
||||
}
|
||||
// send source without _id
|
||||
const { _id, ...sourceData } = source;
|
||||
return sourceData
|
||||
}
|
||||
|
||||
export async function getUpdatedSourceStatus(projectId: string, sourceId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const source = await dataSourcesCollection.findOne({
|
||||
"_id": new ObjectId(sourceId),
|
||||
"projectId": projectId,
|
||||
}, {
|
||||
projection: {
|
||||
status: 1,
|
||||
}
|
||||
});
|
||||
if (!source) {
|
||||
throw new Error('Data source not found');
|
||||
}
|
||||
return source.status;
|
||||
}
|
||||
|
||||
export async function getInformationTool(
|
||||
projectId: string,
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
returnType: z.infer<typeof WorkflowAgent>['ragReturnType'],
|
||||
k: number,
|
||||
): Promise<z.infer<typeof GetInformationToolResult>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// create embedding for question
|
||||
const embedResult = await embed({
|
||||
model: embeddingModel,
|
||||
value: query,
|
||||
});
|
||||
|
||||
// fetch all data sources for this project
|
||||
const sources = await dataSourcesCollection.find({
|
||||
projectId: projectId,
|
||||
active: true,
|
||||
}).toArray();
|
||||
const validSourceIds = sources
|
||||
.filter(s => sourceIds.includes(s._id.toString())) // id should be in sourceIds
|
||||
.filter(s => s.active) // should be active
|
||||
.map(s => s._id.toString());
|
||||
|
||||
// if no sources found, return empty response
|
||||
if (validSourceIds.length === 0) {
|
||||
return {
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
// perform vector search on mongodb for similar documents
|
||||
// from the sources fetched above
|
||||
const agg = [
|
||||
{
|
||||
'$vectorSearch': {
|
||||
'index': 'vector_index',
|
||||
'path': 'embeddings',
|
||||
'filter': {
|
||||
'sourceId': {
|
||||
'$in': validSourceIds,
|
||||
}
|
||||
},
|
||||
'queryVector': embedResult.embedding,
|
||||
'numCandidates': 5000,
|
||||
'limit': k,
|
||||
}
|
||||
}, {
|
||||
'$project': {
|
||||
'_id': 0,
|
||||
'content': 1,
|
||||
'metadata.sourceURL': 1,
|
||||
'metadata.title': 1,
|
||||
'score': {
|
||||
'$meta': 'vectorSearchScore'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// run pipeline
|
||||
const embeddingMatches = await embeddingsCollection.aggregate<z.infer<typeof EmbeddingDoc>>(agg).toArray();
|
||||
|
||||
// if return type is chunks, return the chunks
|
||||
if (returnType === 'chunks') {
|
||||
return {
|
||||
results: embeddingMatches.map(m => ({
|
||||
title: m.metadata.title,
|
||||
content: m.content,
|
||||
url: m.metadata.sourceURL,
|
||||
score: m.metadata.score,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// else return the content of the webpages
|
||||
const result: z.infer<typeof GetInformationToolResult> = {
|
||||
results: [],
|
||||
};
|
||||
|
||||
// coalesce results by url
|
||||
const seenUrls = new Set<string>();
|
||||
for (const match of embeddingMatches) {
|
||||
if (seenUrls.has(match.metadata.sourceURL)) {
|
||||
continue;
|
||||
}
|
||||
seenUrls.add(match.metadata.sourceURL);
|
||||
result.results.push({
|
||||
title: match.metadata.title,
|
||||
content: match.content,
|
||||
url: match.metadata.sourceURL,
|
||||
score: match.metadata.score,
|
||||
});
|
||||
}
|
||||
|
||||
// now fetch each webpage content and overwrite
|
||||
for (const res of result.results) {
|
||||
try {
|
||||
const page = await webpagesCollection.findOne({
|
||||
"_id": res.url,
|
||||
});
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
res.content = page.contentSimple;
|
||||
} catch (e) {
|
||||
// console.error('error fetching page:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function toggleDataSource(projectId: string, sourceId: string, active: boolean) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await dataSourcesCollection.updateOne({
|
||||
"_id": new ObjectId(sourceId),
|
||||
"projectId": projectId,
|
||||
}, {
|
||||
$set: {
|
||||
"active": active,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScenarios(projectId: string): Promise<WithStringId<z.infer<typeof Scenario>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const scenarios = await scenariosCollection.find({ projectId }).toArray();
|
||||
return scenarios.map(s => ({ ...s, _id: s._id.toString() }));
|
||||
}
|
||||
|
||||
export async function createScenario(projectId: string, name: string, description: string): Promise<string> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = await scenariosCollection.insertOne({
|
||||
projectId,
|
||||
name,
|
||||
description,
|
||||
lastUpdatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
return result.insertedId.toString();
|
||||
}
|
||||
|
||||
export async function updateScenario(projectId: string, scenarioId: string, name: string, description: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await scenariosCollection.updateOne({
|
||||
"_id": new ObjectId(scenarioId),
|
||||
"projectId": projectId,
|
||||
}, {
|
||||
$set: {
|
||||
name,
|
||||
description,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScenario(projectId: string, scenarioId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await scenariosCollection.deleteOne({
|
||||
"_id": new ObjectId(scenarioId),
|
||||
"projectId": projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function simulateUserResponse(
|
||||
projectId: string,
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[],
|
||||
simulationData: z.infer<typeof SimulationData>
|
||||
): Promise<string> {
|
||||
await projectAuthCheck(projectId);
|
||||
const articlePrompt = `
|
||||
# Your Specific Task:
|
||||
|
||||
## Context:
|
||||
|
||||
Here is a help article:
|
||||
|
||||
Content:
|
||||
<START_ARTICLE_CONTENT>
|
||||
Title: {{title}}
|
||||
{{content}}
|
||||
<END_ARTICLE_CONTENT>
|
||||
|
||||
## Task definition:
|
||||
|
||||
Pretend to be a user reaching out to customer support. Chat with the
|
||||
customer support assistant, assuming your issue or query is from this article.
|
||||
Ask follow-up questions and make it real-world like. Don't do dummy
|
||||
conversations. Your conversation should be a maximum of 5 user turns.
|
||||
|
||||
As output, simply provide your (user) turn of conversation.
|
||||
|
||||
After you are done with the chat, keep replying with a single word EXIT
|
||||
in all capitals.
|
||||
`;
|
||||
|
||||
const scenarioPrompt = `
|
||||
# Your Specific Task:
|
||||
|
||||
## Context:
|
||||
|
||||
Here is a scenario:
|
||||
|
||||
Scenario:
|
||||
<START_SCENARIO>
|
||||
{{scenario}}
|
||||
<END_SCENARIO>
|
||||
|
||||
## Task definition:
|
||||
|
||||
Pretend to be a user reaching out to customer support. Chat with the
|
||||
customer support assistant, assuming your issue is based on this scenario.
|
||||
Ask follow-up questions and make it real-world like. Don't do dummy
|
||||
conversations. Your conversation should be a maximum of 5 user turns.
|
||||
|
||||
As output, simply provide your (user) turn of conversation.
|
||||
|
||||
After you are done with the chat, keep replying with a single word EXIT
|
||||
in all capitals.
|
||||
`;
|
||||
|
||||
const previousChatPrompt = `
|
||||
# Your Specific Task:
|
||||
|
||||
## Context:
|
||||
|
||||
Here is a chat between a user and a customer support assistant:
|
||||
|
||||
Chat:
|
||||
<PREVIOUS_CHAT>
|
||||
{{messages}}
|
||||
<END_PREVIOUS_CHAT>
|
||||
|
||||
## Task definition:
|
||||
|
||||
Pretend to be a user reaching out to customer support. Chat with the
|
||||
customer support assistant, assuming your issue based on this previous chat.
|
||||
Ask follow-up questions and make it real-world like. Don't do dummy
|
||||
conversations. Your conversation should be a maximum of 5 user turns.
|
||||
|
||||
As output, simply provide your (user) turn of conversation.
|
||||
|
||||
After you are done with the chat, keep replying with a single word EXIT
|
||||
in all capitals.
|
||||
`;
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// flip message assistant / user message
|
||||
// roles from chat messages
|
||||
// use only text response messages
|
||||
const flippedMessages: { role: 'user' | 'assistant', content: string }[] = messages
|
||||
.filter(m => m.role == 'assistant' || m.role == 'user')
|
||||
.map(m => ({
|
||||
role: m.role == 'assistant' ? 'user' : 'assistant',
|
||||
content: m.content || '',
|
||||
}));
|
||||
|
||||
// simulate user call
|
||||
let prompt;
|
||||
if ('articleUrl' in simulationData) {
|
||||
prompt = articlePrompt
|
||||
.replace('{{title}}', simulationData.articleTitle || '')
|
||||
.replace('{{content}}', simulationData.articleContent || '');
|
||||
}
|
||||
if ('scenario' in simulationData) {
|
||||
prompt = scenarioPrompt
|
||||
.replace('{{scenario}}', simulationData.scenario);
|
||||
}
|
||||
if ('chatMessages' in simulationData) {
|
||||
prompt = previousChatPrompt
|
||||
.replace('{{messages}}', simulationData.chatMessages);
|
||||
}
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o"),
|
||||
system: prompt || '',
|
||||
messages: flippedMessages,
|
||||
});
|
||||
|
||||
return text.replace(/\. EXIT$/, '.');
|
||||
}
|
||||
|
||||
export async function rotateSecret(projectId: string): Promise<string> {
|
||||
await projectAuthCheck(projectId);
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { secret } }
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export async function updateWebhookUrl(projectId: string, url: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { webhookUrl: url } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeClientTool(
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
|
||||
projectId: string,
|
||||
): Promise<unknown> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const project = await projectsCollection.findOne({
|
||||
"_id": projectId,
|
||||
});
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
if (!project.webhookUrl) {
|
||||
throw new Error('Webhook URL not found');
|
||||
}
|
||||
|
||||
// prepare request body
|
||||
const content = JSON.stringify({
|
||||
toolCall,
|
||||
} as z.infer<typeof ClientToolCallRequestBody>);
|
||||
const requestId = crypto.randomUUID();
|
||||
const bodyHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(content, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
// sign request
|
||||
const jwt = await new SignJWT({
|
||||
requestId,
|
||||
projectId,
|
||||
bodyHash,
|
||||
} as z.infer<typeof ClientToolCallJwt>)
|
||||
.setProtectedHeader({
|
||||
alg: 'HS256',
|
||||
typ: 'JWT',
|
||||
})
|
||||
.setIssuer('rowboat')
|
||||
.setAudience(project.webhookUrl)
|
||||
.setSubject(`tool-call-${toolCall.id}`)
|
||||
.setJti(requestId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5 minutes")
|
||||
.sign(new TextEncoder().encode(project.secret));
|
||||
|
||||
// make request
|
||||
const request: z.infer<typeof ClientToolCallRequest> = {
|
||||
requestId,
|
||||
content,
|
||||
};
|
||||
const response = await fetch(project.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-signature-jwt': jwt,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to call webhook: ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const responseBody = await response.json();
|
||||
return responseBody;
|
||||
}
|
||||
BIN
apps/rowboat/app/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/rowboat/app/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
3
apps/rowboat/app/api/auth/[auth0]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleAuth } from '@auth0/nextjs-auth0';
|
||||
|
||||
export const GET = handleAuth();
|
||||
40
apps/rowboat/app/api/v1/chats/[chatId]/close/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { authCheck } from "../../../utils";
|
||||
|
||||
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { chatId: string } }
|
||||
): Promise<Response> {
|
||||
return await authCheck(request, async (session) => {
|
||||
const { chatId } = params;
|
||||
|
||||
const result = await chatsCollection.findOneAndUpdate(
|
||||
{
|
||||
_id: new ObjectId(chatId),
|
||||
projectId: session.projectId,
|
||||
userId: session.userId,
|
||||
closed: { $exists: false },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
closed: true,
|
||||
closedAt: new Date().toISOString(),
|
||||
closeReason: "user-closed-chat",
|
||||
},
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return Response.json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(result);
|
||||
});
|
||||
}
|
||||
94
apps/rowboat/app/api/v1/chats/[chatId]/messages/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { Filter, ObjectId } from "mongodb";
|
||||
import { authCheck } from "../../../utils";
|
||||
|
||||
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
|
||||
|
||||
// list messages
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { chatId: string } }
|
||||
): Promise<Response> {
|
||||
return await authCheck(req, async (session) => {
|
||||
const { chatId } = params;
|
||||
|
||||
// Check if chat exists
|
||||
const chat = await chatsCollection.findOne({
|
||||
_id: new ObjectId(chatId),
|
||||
projectId: session.projectId,
|
||||
userId: session.userId
|
||||
});
|
||||
if (!chat) {
|
||||
return Response.json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const limit = 10; // Hardcoded limit
|
||||
const next = searchParams.get('next');
|
||||
const previous = searchParams.get('previous');
|
||||
|
||||
// Construct the query
|
||||
const query: Filter<z.infer<typeof apiV1.ChatMessage>> = {
|
||||
chatId,
|
||||
$or: [
|
||||
{ role: 'user' },
|
||||
{ role: 'assistant', agenticResponseType: { $eq: 'external' } }
|
||||
],
|
||||
};
|
||||
|
||||
// Add cursor condition to the query
|
||||
if (previous) {
|
||||
query._id = { $lt: new ObjectId(previous) };
|
||||
} else if (next) {
|
||||
query._id = { $gt: new ObjectId(next) };
|
||||
}
|
||||
|
||||
// Fetch messages from the database
|
||||
let messages = await chatMessagesCollection
|
||||
.find(query)
|
||||
.sort({ _id: previous ? -1 : 1 }) // Sort based on direction
|
||||
.limit(limit + 1) // Fetch one extra to determine if there are more results
|
||||
.toArray();
|
||||
|
||||
// Determine if there are more results
|
||||
const hasMore = messages.length > limit;
|
||||
if (hasMore) {
|
||||
messages.pop();
|
||||
}
|
||||
|
||||
// Reverse the array if we're paginating backwards
|
||||
if (previous) {
|
||||
messages.reverse();
|
||||
}
|
||||
|
||||
let nextCursor: string | undefined;
|
||||
let previousCursor: string | undefined;
|
||||
if (messages.length > 0) {
|
||||
if (hasMore || previous) {
|
||||
nextCursor = messages[messages.length - 1]._id.toString();
|
||||
}
|
||||
if (next || (previous && hasMore)) {
|
||||
previousCursor = messages[0]._id.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the response
|
||||
const response: z.infer<typeof apiV1.ApiGetChatMessagesResponse> = {
|
||||
messages: messages.map(message => ({
|
||||
...message,
|
||||
id: message._id.toString(),
|
||||
_id: undefined
|
||||
})),
|
||||
next: nextCursor,
|
||||
previous: previousCursor,
|
||||
};
|
||||
|
||||
// Return response
|
||||
return Response.json(response);
|
||||
});
|
||||
}
|
||||
43
apps/rowboat/app/api/v1/chats/[chatId]/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { authCheck } from "../../utils";
|
||||
|
||||
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
|
||||
// get chat
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
): Promise<Response> {
|
||||
return await authCheck(req, async (session) => {
|
||||
const { chatId } = await params;
|
||||
|
||||
// fetch the chat from the database
|
||||
let chatIdObj: ObjectId;
|
||||
try {
|
||||
chatIdObj = new ObjectId(chatId);
|
||||
} catch (e) {
|
||||
return Response.json({ error: "Invalid chat ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const chat = await chatsCollection.findOne({
|
||||
projectId: session.projectId,
|
||||
userId: session.userId,
|
||||
_id: chatIdObj
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return Response.json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// return the chat
|
||||
return Response.json({
|
||||
...chat,
|
||||
id: chat._id.toString(),
|
||||
_id: undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
152
apps/rowboat/app/api/v1/chats/[chatId]/turn/route.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId, WithId } from "mongodb";
|
||||
import { authCheck } from "../../../utils";
|
||||
import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertToCoreMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types";
|
||||
import { executeClientTool, getAssistantResponse } from "@/app/actions";
|
||||
|
||||
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
|
||||
|
||||
// get next turn / agent response
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
): Promise<Response> {
|
||||
return await authCheck(req, async (session) => {
|
||||
const { chatId } = await params;
|
||||
|
||||
// parse and validate the request body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (e) {
|
||||
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
||||
}
|
||||
const result = apiV1.ApiChatTurnRequest.safeParse(body);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
|
||||
}
|
||||
const userMessage: z.infer<typeof apiV1.ChatMessage> = {
|
||||
version: 'v1',
|
||||
createdAt: new Date().toISOString(),
|
||||
chatId,
|
||||
role: 'user',
|
||||
content: result.data.message,
|
||||
};
|
||||
|
||||
// ensure chat exists
|
||||
const chat = await chatsCollection.findOne({
|
||||
projectId: session.projectId,
|
||||
userId: session.userId,
|
||||
_id: new ObjectId(chatId)
|
||||
});
|
||||
if (!chat) {
|
||||
return Response.json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// prepare system message which will contain user data
|
||||
const systemMessage: z.infer<typeof apiV1.ChatMessage> = {
|
||||
version: 'v1',
|
||||
createdAt: new Date().toISOString(),
|
||||
chatId,
|
||||
role: 'system',
|
||||
content: `The following user data is available to you: ${JSON.stringify(chat.userData)}`,
|
||||
};
|
||||
|
||||
// fetch existing chat messages
|
||||
const messages = await chatMessagesCollection.find({ chatId: chatId }).toArray();
|
||||
|
||||
// fetch project settings
|
||||
const projectSettings = await projectsCollection.findOne({
|
||||
"_id": session.projectId,
|
||||
});
|
||||
if (!projectSettings) {
|
||||
throw new Error("Project settings not found");
|
||||
}
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: session.projectId,
|
||||
_id: new ObjectId(projectSettings.publishedWorkflowId),
|
||||
});
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found");
|
||||
}
|
||||
|
||||
// get assistant response
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
|
||||
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
|
||||
let resolvingToolCalls = true;
|
||||
let state: unknown = chat.agenticState ?? {last_agent_name: startAgent};
|
||||
while (resolvingToolCalls) {
|
||||
const request: z.infer<typeof AgenticAPIChatRequest> = {
|
||||
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
|
||||
state,
|
||||
agents,
|
||||
tools,
|
||||
prompts,
|
||||
startAgent,
|
||||
};
|
||||
console.log("turn: sending agentic request", JSON.stringify(request, null, 2));
|
||||
const response = await getAssistantResponse(session.projectId, request);
|
||||
state = response.state;
|
||||
if (response.messages.length === 0) {
|
||||
throw new Error("No messages returned from assistant");
|
||||
}
|
||||
unsavedMessages.push(...response.messages.map(m => ({
|
||||
...m,
|
||||
version: 'v1' as const,
|
||||
chatId,
|
||||
createdAt: new Date().toISOString(),
|
||||
})));
|
||||
|
||||
// if the last messages is tool call, execute them
|
||||
const lastMessage = response.messages[response.messages.length - 1];
|
||||
if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) {
|
||||
// execute tool calls
|
||||
console.log("Executing tool calls", lastMessage.tool_calls);
|
||||
const toolCallResults = await Promise.all(lastMessage.tool_calls.map(async toolCall => {
|
||||
console.log('executing tool call', toolCall);
|
||||
try {
|
||||
return await executeClientTool(toolCall, session.projectId);
|
||||
} catch (error) {
|
||||
console.error(`Error executing tool call ${toolCall.id}:`, error);
|
||||
return { error: "Tool execution failed" };
|
||||
}
|
||||
}));
|
||||
unsavedMessages.push(...toolCallResults.map((result, index) => ({
|
||||
version: 'v1' as const,
|
||||
chatId,
|
||||
createdAt: new Date().toISOString(),
|
||||
role: 'tool' as const,
|
||||
tool_call_id: lastMessage.tool_calls[index].id,
|
||||
tool_name: lastMessage.tool_calls[index].function.name,
|
||||
content: JSON.stringify(result),
|
||||
})));
|
||||
} else {
|
||||
// ensure that the last message is from an assistant
|
||||
// and is of an external type
|
||||
if (lastMessage.role !== 'assistant' || lastMessage.agenticResponseType !== 'external') {
|
||||
throw new Error("Last message is not from an assistant and is not of an external type");
|
||||
}
|
||||
resolvingToolCalls = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// save unsaved messages and update chat state
|
||||
await chatMessagesCollection.insertMany(unsavedMessages);
|
||||
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
|
||||
|
||||
// send back the last message
|
||||
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
|
||||
return Response.json({
|
||||
...lastMessage,
|
||||
id: lastMessage._id.toString(),
|
||||
_id: undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
116
apps/rowboat/app/api/v1/chats/route.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { authCheck } from "../utils";
|
||||
|
||||
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
|
||||
// create a chat
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
): Promise<Response> {
|
||||
return await authCheck(req, async (session) => {
|
||||
// parse and validate the request body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (e) {
|
||||
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
||||
}
|
||||
const result = apiV1.ApiCreateChatRequest.safeParse(body);
|
||||
if (!result.success) {
|
||||
return new Response(JSON.stringify({ error: `Invalid request body: ${result.error.message}` }), { status: 400 });
|
||||
}
|
||||
|
||||
// insert the chat into the database
|
||||
const id = new ObjectId();
|
||||
const chat: z.infer<typeof apiV1.Chat> = {
|
||||
version: "v1",
|
||||
projectId: session.projectId,
|
||||
userId: session.userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
userData: {
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
},
|
||||
}
|
||||
await chatsCollection.insertOne({
|
||||
...chat,
|
||||
_id: id,
|
||||
});
|
||||
|
||||
// return response
|
||||
const response: z.infer<typeof apiV1.ApiCreateChatResponse> = {
|
||||
...chat,
|
||||
id: id.toString(),
|
||||
};
|
||||
return Response.json(response);
|
||||
});
|
||||
}
|
||||
|
||||
// list chats
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
): Promise<Response> {
|
||||
return await authCheck(req, async (session) => {
|
||||
// Parse query parameters
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const limit = 10; // Hardcoded limit
|
||||
const next = searchParams.get('next');
|
||||
const previous = searchParams.get('previous');
|
||||
|
||||
// Add userId to query to only show chats for current user
|
||||
const query: { projectId: string; userId: string; _id?: { $lt?: ObjectId; $gt?: ObjectId } } = {
|
||||
projectId: session.projectId,
|
||||
userId: session.userId
|
||||
};
|
||||
|
||||
// Add cursor condition to the query
|
||||
if (next) {
|
||||
query._id = { $lt: new ObjectId(next) };
|
||||
} else if (previous) {
|
||||
query._id = { $gt: new ObjectId(previous) };
|
||||
}
|
||||
|
||||
// Fetch chats from the database
|
||||
let chats = await chatsCollection
|
||||
.find(query)
|
||||
.sort({ _id: -1 }) // Sort in descending order
|
||||
.limit(limit + 1) // Fetch one extra to determine if there are more results
|
||||
.toArray();
|
||||
|
||||
// Determine if there are more results
|
||||
const hasMore = chats.length > limit;
|
||||
if (hasMore) {
|
||||
chats.pop();
|
||||
}
|
||||
let nextCursor: string | undefined;
|
||||
let previousCursor: string | undefined;
|
||||
if (chats.length > 0) {
|
||||
if (hasMore || previous) {
|
||||
nextCursor = chats[chats.length - 1]._id.toString();
|
||||
}
|
||||
if (next || (previous && hasMore)) {
|
||||
previousCursor = chats[0]._id.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the response
|
||||
const response: z.infer<typeof apiV1.ApiGetChatsResponse> = {
|
||||
chats: chats
|
||||
.slice(0, limit)
|
||||
.map(chat => ({
|
||||
...chat,
|
||||
id: chat._id.toString(),
|
||||
_id: undefined
|
||||
})),
|
||||
next: nextCursor,
|
||||
previous: previousCursor,
|
||||
};
|
||||
|
||||
// Return response
|
||||
return Response.json(response);
|
||||
});
|
||||
}
|
||||
30
apps/rowboat/app/api/v1/session/guest/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { clientIdCheck } from "../../utils";
|
||||
import { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { Session } from "../../utils";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
return await clientIdCheck(req, async (projectId) => {
|
||||
// create a new guest user
|
||||
const session: z.infer<typeof Session> = {
|
||||
userId: `guest-${crypto.randomUUID()}`,
|
||||
userName: 'Guest User',
|
||||
projectId: projectId
|
||||
};
|
||||
|
||||
// Create and sign JWT
|
||||
const token = await new SignJWT(session)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
|
||||
|
||||
const response: z.infer<typeof apiV1.ApiCreateGuestSessionResponse> = {
|
||||
sessionId: token,
|
||||
};
|
||||
|
||||
return Response.json(response);
|
||||
});
|
||||
}
|
||||
55
apps/rowboat/app/api/v1/session/user/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { clientIdCheck } from "../../utils";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
import { Session } from "../../utils";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { projectsCollection } from "@/app/lib/mongodb";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
return await clientIdCheck(req, async (projectId) => {
|
||||
// decode and validate JWT
|
||||
const json = await req.json();
|
||||
const parsedRequest = apiV1.ApiCreateUserSessionRequest.parse(json);
|
||||
|
||||
// fetch client signing key from db
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId
|
||||
});
|
||||
if (!project) {
|
||||
return Response.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
const clientSigningKey = project.secret;
|
||||
|
||||
// verify client signing key
|
||||
let verified;
|
||||
try {
|
||||
verified = await jwtVerify<{
|
||||
userId: string;
|
||||
userName?: string;
|
||||
}>(parsedRequest.userDataJwt, new TextEncoder().encode(clientSigningKey));
|
||||
} catch (e) {
|
||||
return Response.json({ error: 'Invalid jwt' }, { status: 403 });
|
||||
}
|
||||
|
||||
// create new user session
|
||||
const session: z.infer<typeof Session> = {
|
||||
userId: verified.payload.userId,
|
||||
userName: verified.payload.userName ?? 'Unknown',
|
||||
projectId: projectId
|
||||
};
|
||||
|
||||
// Create and sign JWT
|
||||
const token = await new SignJWT(session)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
|
||||
|
||||
const response: z.infer<typeof apiV1.ApiCreateGuestSessionResponse> = {
|
||||
sessionId: token,
|
||||
};
|
||||
|
||||
return Response.json(response);
|
||||
});
|
||||
}
|
||||
62
apps/rowboat/app/api/v1/utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jwtVerify } from "jose";
|
||||
import { projectsCollection } from "@/app/lib/mongodb";
|
||||
|
||||
export const Session = z.object({
|
||||
userId: z.string(),
|
||||
userName: z.string(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
/*
|
||||
This function wraps an API handler with client ID validation.
|
||||
It checks for a client ID in the request headers and returns a 400
|
||||
Bad Request response if missing. It then looks up the client ID in the
|
||||
database to fetch the corresponding project ID. If no record is found,
|
||||
it returns a 403 Forbidden response. Otherwise, it sets the project ID
|
||||
in the request headers and calls the provided handler function.
|
||||
*/
|
||||
export async function clientIdCheck(req: NextRequest, handler: (projectId: string) => Promise<Response>): Promise<Response> {
|
||||
const clientId = req.headers.get('x-client-id')?.trim();
|
||||
if (!clientId) {
|
||||
return Response.json({ error: "Missing client ID in request" }, { status: 400 });
|
||||
}
|
||||
const project = await projectsCollection.findOne({
|
||||
chatClientId: clientId
|
||||
});
|
||||
if (!project) {
|
||||
return Response.json({ error: "Invalid client ID" }, { status: 403 });
|
||||
}
|
||||
// set the project id in the request headers
|
||||
req.headers.set('x-project-id', project._id);
|
||||
return await handler(project._id);
|
||||
}
|
||||
|
||||
/*
|
||||
This function wraps an API handler with session validation.
|
||||
It checks for a session in the request headers and returns a 400
|
||||
Bad Request response if missing. It then verifies the session JWT.
|
||||
If no record is found, it returns a 403 Forbidden response. Otherwise,
|
||||
it sets the project ID and user ID in the request headers and calls the
|
||||
provided handler function.
|
||||
*/
|
||||
export async function authCheck(req: NextRequest, handler: (session: z.infer<typeof Session>) => Promise<Response>): Promise<Response> {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
return Response.json({ error: "Missing session token in request" }, { status: 400 });
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await jwtVerify(token, new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
|
||||
} catch (error) {
|
||||
return Response.json({ error: "Invalid session token" }, { status: 403 });
|
||||
}
|
||||
|
||||
return await handler(session.payload as z.infer<typeof Session>);
|
||||
}
|
||||
64
apps/rowboat/app/app.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
'use client';
|
||||
import { TypewriterEffect } from "./lib/components/typewriter";
|
||||
import Image from 'next/image';
|
||||
import logo from "@/public/rowboat-logo.png";
|
||||
import { useUser } from "@auth0/nextjs-auth0/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
|
||||
export function App() {
|
||||
const router = useRouter();
|
||||
const { user, error, isLoading } = useUser();
|
||||
|
||||
if (user) {
|
||||
router.push("/projects");
|
||||
}
|
||||
|
||||
return <div className="flex h-full justify-center lg:justify-between">
|
||||
<div className="hidden h-full grow md:justify-start bg-gray-50 bg-[url('/landing-bg.jpg')] bg-cover bg-center p-10 md:flex md:flex-col md:gap-20">
|
||||
<div className="flex flex-col items-start gap-48">
|
||||
<Image
|
||||
src={logo}
|
||||
alt="RowBoat Logo"
|
||||
height={30}
|
||||
/>
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold inline-block bg-white bg-opacity-75 rounded-lg px-4 py-4">
|
||||
AI agents for human-like customer assistance
|
||||
</h1>
|
||||
<h2 className="text-md md:text-lg lg:text-xl text-gray-600 inline-block bg-white bg-opacity-75 rounded-lg px-4 py-3">
|
||||
Set up a personalized agent for your app or website in minutes.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<TypewriterEffect />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-20 px-28 py-2 justify-center">
|
||||
<Image
|
||||
className="md:hidden"
|
||||
src={logo}
|
||||
alt="RowBoat Logo"
|
||||
height={30}
|
||||
/>
|
||||
{isLoading && <Spinner size="sm" />}
|
||||
{error && <div className="text-red-500">{error.message}</div>}
|
||||
{!isLoading && !error && !user && (
|
||||
<a
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-md"
|
||||
href="/api/auth/login"
|
||||
>
|
||||
Sign in to get started
|
||||
</a>
|
||||
)}
|
||||
{user && <div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div className="text-sm text-gray-400">Welcome, {user.name}</div>
|
||||
</div>}
|
||||
<div className="flex flex-col justify-center items-center px-4 py-2 gap-2">
|
||||
<div className="text-sm text-gray-400">© 2024 RowBoat Labs</div>
|
||||
<a className="text-sm text-gray-400 hover:underline" href="https://www.rowboatlabs.com/terms-and-conditions" target="_blank" rel="noopener noreferrer">Terms and Conditions</a>
|
||||
<a className="text-sm text-gray-400 hover:underline" href="https://www.rowboatlabs.com/privacy-policy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
BIN
apps/rowboat/app/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
9
apps/rowboat/app/browserconfig.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
apps/rowboat/app/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 630 B |
BIN
apps/rowboat/app/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 851 B |
BIN
apps/rowboat/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
78
apps/rowboat/app/globals.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
30
apps/rowboat/app/layout.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import "./globals.css";
|
||||
import { UserProvider } from '@auth0/nextjs-auth0/client';
|
||||
import { Inter } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
import { Metadata } from "next";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "RowBoat labs",
|
||||
template: "%s | RowBoat Labs",
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <html lang="en" className="h-dvh">
|
||||
<UserProvider>
|
||||
<body className={`${inter.className} h-full text-base [scrollbar-width:thin]`}>
|
||||
<Providers className='h-full flex flex-col'>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</UserProvider>
|
||||
</html>;
|
||||
}
|
||||
14
apps/rowboat/app/lib/components/FormStatusButton.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button, ButtonProps } from "@nextui-org/react";
|
||||
|
||||
export function FormStatusButton({
|
||||
props
|
||||
}: {
|
||||
props: ButtonProps;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return <Button {...props} isLoading={pending} />;
|
||||
}
|
||||
18
apps/rowboat/app/lib/components/PageSection.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function PageSection({
|
||||
title,
|
||||
children,
|
||||
danger = false,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return <div className="pb-2">
|
||||
<div className={`text-lg pb-2 border-b border-b-gray-100` + (danger ? ' text-red-600 border-b-red-600' : '')}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="px-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
16
apps/rowboat/app/lib/components/datasource-icon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function DataSourceIcon({
|
||||
type = undefined,
|
||||
size = "sm",
|
||||
}: {
|
||||
type?: "crawl" | "urls" | undefined;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const sizeClass = size === "sm" ? "w-4 h-4" : "w-6 h-6";
|
||||
return <>
|
||||
{type === undefined && <svg className={sizeClass} aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 6c0 1.657-3.134 3-7 3S5 7.657 5 6m14 0c0-1.657-3.134-3-7-3S5 4.343 5 6m14 0v6M5 6v6m0 0c0 1.657 3.134 3 7 3s7-1.343 7-3M5 12v6c0 1.657 3.134 3 7 3s7-1.343 7-3v-6" />
|
||||
</svg>}
|
||||
{type == "crawl" && <svg className={`${sizeClass} lucide lucide-globe`} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></svg>}
|
||||
{type == "urls" && <svg className={`${sizeClass} lucide lucide-globe`} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></svg>}
|
||||
</>;
|
||||
}
|
||||
155
apps/rowboat/app/lib/components/editable-field.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Button, Input, InputProps, Kbd, Textarea } from "@nextui-org/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useClickAway } from "@/hooks/use-click-away";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface EditableFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
markdown?: boolean;
|
||||
multiline?: boolean;
|
||||
locked?: boolean;
|
||||
className?: string;
|
||||
validate?: (value: string) => { valid: boolean; errorMessage?: string };
|
||||
light?: boolean;
|
||||
}
|
||||
|
||||
export function EditableField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Click to edit...",
|
||||
markdown = false,
|
||||
multiline = false,
|
||||
locked = false,
|
||||
className = "flex flex-col gap-1",
|
||||
validate,
|
||||
light = false,
|
||||
}: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const validationResult = validate?.(localValue);
|
||||
const isValid = !validate || validationResult?.valid;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (isEditing) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
|
||||
const commonProps = {
|
||||
autoFocus: true,
|
||||
value: localValue,
|
||||
onValueChange: setLocalValue,
|
||||
variant: "bordered" as const,
|
||||
labelPlacement: "outside" as const,
|
||||
placeholder: markdown ? '' : placeholder,
|
||||
radius: "sm" as const,
|
||||
isInvalid: !isValid,
|
||||
errorMessage: validationResult?.errorMessage,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (!multiline && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (multiline && e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
{label && <div className="block text-sm font-medium text-foreground-500 pb-1.5">{label}</div>}
|
||||
{isEditing && multiline && <div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
multiline ? (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
/>
|
||||
) : (
|
||||
<Input {...commonProps} />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !locked && setIsEditing(true)}
|
||||
className={clsx("text-sm px-2 py-1 rounded-md", {
|
||||
"bg-blue-50": markdown && !locked,
|
||||
"bg-gray-50": light,
|
||||
"hover:bg-blue-50 cursor-pointer": light && !locked,
|
||||
"hover:bg-gray-50 cursor-pointer": !light && !locked,
|
||||
"cursor-default": locked,
|
||||
})}
|
||||
>
|
||||
{value ? (<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto">
|
||||
<MarkdownContent content={value} />
|
||||
</div>}
|
||||
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
|
||||
{value}
|
||||
</div>}
|
||||
</>) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400 italic">
|
||||
<MarkdownContent content={placeholder} />
|
||||
</div>}
|
||||
{!markdown && <span className="text-gray-400 italic">{placeholder}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/rowboat/app/lib/components/icons.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export function WorkflowIcon({
|
||||
size = 24,
|
||||
strokeWidth = 1,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-workflow">
|
||||
<rect width="8" height="8" x="3" y="3" rx="2" />
|
||||
<path d="M7 11v4a2 2 0 0 0 2 2h4" />
|
||||
<rect width="8" height="8" x="13" y="13" rx="2" />
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export function HamburgerIcon({
|
||||
size = 24,
|
||||
strokeWidth = 1,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-hamburger">
|
||||
<path d="M3 7h18" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M3 17h18" />
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export function BackIcon({
|
||||
size = 24,
|
||||
strokeWidth = 1,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
return <svg width={size} height={size} aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M5 12h14M5 12l4-4m-4 4 4 4"/>
|
||||
</svg>;
|
||||
}
|
||||
66
apps/rowboat/app/lib/components/markdown-content.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
export default function MarkdownContent({ content }: { content: string }) {
|
||||
return <Markdown
|
||||
className="overflow-auto break-words"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1({ children }) {
|
||||
return <h1 className="text-2xl font-bold py-2">{children}</h1>
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-xl font-bold py-2">{children}</h2>
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-lg font-semibold py-2">{children}</h3>
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h4 className="text-base font-semibold py-2">{children}</h4>
|
||||
},
|
||||
h5({ children }) {
|
||||
return <h5 className="text-sm font-semibold py-2">{children}</h5>
|
||||
},
|
||||
h6({ children }) {
|
||||
return <h6 className="text-xs font-semibold py-2">{children}</h6>
|
||||
},
|
||||
strong({ children }) {
|
||||
return <span className="font-semibold">{children}</span>
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="py-2">{children}</p>
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="py-2 pl-5 list-disc">{children}</ul>
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ul className="py-2 pl-5 list-decimal">{children}</ul>
|
||||
},
|
||||
table({ children }) {
|
||||
return <table className="py-2 border-collapse border border-gray-400 rounded">{children}</table>
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</th>
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</td>
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return <blockquote className='py-2 bg-gray-200 px-1'>{children}</blockquote>;
|
||||
},
|
||||
a(props) {
|
||||
const { children, className, node, ...rest } = props
|
||||
return <a className="inline-flex items-center gap-1" target="_blank" {...rest} >
|
||||
<span className='underline'>
|
||||
{children}
|
||||
</span>
|
||||
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
|
||||
</svg>
|
||||
</a>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>;
|
||||
}
|
||||
24
apps/rowboat/app/lib/components/pagination.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { Pagination as NextUiPagination } from "@nextui-org/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export function Pagination({
|
||||
total,
|
||||
page,
|
||||
}: {
|
||||
total: number;
|
||||
page: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
return <NextUiPagination
|
||||
showControls
|
||||
total={total}
|
||||
initialPage={page}
|
||||
onChange={(page) => {
|
||||
router.push(`${pathname}?page=${page}`);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
52
apps/rowboat/app/lib/components/typewriter.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const phrases = [
|
||||
"Can you help me choose the right product?",
|
||||
"Which plan is right for me?",
|
||||
"Do you have a discount code available?",
|
||||
"How do I get early access?",
|
||||
"Can you explain the charges?",
|
||||
];
|
||||
|
||||
export function TypewriterEffect() {
|
||||
const [displayText, setDisplayText] = useState("");
|
||||
const [index, setIndex] = useState(0);
|
||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||
const [isTyping, setIsTyping] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
const currentPhrase = phrases[phraseIndex];
|
||||
|
||||
if (isTyping) {
|
||||
if (index < currentPhrase.length) {
|
||||
timer = setTimeout(() => {
|
||||
setDisplayText((prev) => prev + currentPhrase[index]);
|
||||
setIndex((prev) => prev + 1);
|
||||
}, 20);
|
||||
} else {
|
||||
// Pause at the end
|
||||
timer = setTimeout(() => setIsTyping(false), 2000);
|
||||
}
|
||||
} else {
|
||||
if (index > 0) {
|
||||
timer = setTimeout(() => {
|
||||
setDisplayText((prev) => prev.slice(0, -1));
|
||||
setIndex((prev) => prev - 1);
|
||||
}, 10);
|
||||
} else {
|
||||
// Move to next phrase
|
||||
setPhraseIndex((prev) => (prev + 1) % phrases.length);
|
||||
setIsTyping(true);
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [index, isTyping, phraseIndex]);
|
||||
|
||||
return <div className="mb-8 font-semibold text-md md:text-xl lg:text-2xl leading-tight tracking-tight px-4 py-2">
|
||||
{displayText}
|
||||
</div>;
|
||||
};
|
||||
37
apps/rowboat/app/lib/components/user_button.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client';
|
||||
import { useUser } from '@auth0/nextjs-auth0/client';
|
||||
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from '@nextui-org/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function UserButton() {
|
||||
const router = useRouter();
|
||||
const { user } = useUser();
|
||||
if (!user) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const name = user.name ?? user.email ?? 'Unknown user';
|
||||
|
||||
return <Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Avatar
|
||||
name={name}
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
onAction={(key) => {
|
||||
if (key === 'logout') {
|
||||
router.push('/api/auth/logout');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownSection title={name}>
|
||||
<DropdownItem key="logout">
|
||||
Logout
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
3
apps/rowboat/app/lib/embedding.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const embeddingModel = openai.embedding('text-embedding-3-small');
|
||||
2
apps/rowboat/app/lib/loadenv.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import dotenv from 'dotenv'
|
||||
dotenv.config({path: [".env.local", ".env"]});
|
||||
14
apps/rowboat/app/lib/mongodb.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { MongoClient } from "mongodb";
|
||||
import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember } from "./types";
|
||||
import { z } from 'zod';
|
||||
|
||||
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "");
|
||||
|
||||
export const db = client.db("rowboat");
|
||||
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
|
||||
export const embeddingsCollection = db.collection<z.infer<typeof EmbeddingDoc>>("embeddings");
|
||||
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
|
||||
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
|
||||
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
|
||||
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
|
||||
export const scenariosCollection = db.collection<z.infer<typeof Scenario>>("scenarios");
|
||||
625
apps/rowboat/app/lib/types.ts
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
import { CoreMessage, ToolCallPart } from "ai";
|
||||
import { z } from "zod";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
||||
export const SimulationArticleData = z.object({
|
||||
articleUrl: z.string(),
|
||||
articleTitle: z.string().default('').optional(),
|
||||
articleContent: z.string().default('').optional(),
|
||||
});
|
||||
|
||||
export const Scenario = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string().min(1, "Name cannot be empty"),
|
||||
description: z.string().min(1, "Description cannot be empty"),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const SimulationScenarioData = z.object({
|
||||
scenario: z.string(),
|
||||
});
|
||||
|
||||
export const SimulationChatMessagesData = z.object({
|
||||
chatMessages: z.string(),
|
||||
});
|
||||
|
||||
export const SimulationData = z.union([SimulationArticleData, SimulationScenarioData, SimulationChatMessagesData]);
|
||||
|
||||
export const PlaygroundChat = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
projectId: z.string(),
|
||||
title: z.string().optional(),
|
||||
messages: z.array(apiV1.ChatMessage),
|
||||
simulated: z.boolean().default(false).optional(),
|
||||
simulationData: SimulationData.optional(),
|
||||
simulationComplete: z.boolean().default(false).optional(),
|
||||
agenticState: z.unknown().optional(),
|
||||
systemMessage: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Webpage = z.object({
|
||||
_id: z.string(),
|
||||
title: z.string(),
|
||||
contentSimple: z.string(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const ChatClientId = z.object({
|
||||
_id: z.string(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
export const DataSource = z.object({
|
||||
name: z.string(),
|
||||
projectId: z.string(),
|
||||
active: z.boolean().default(true),
|
||||
status: z.union([z.literal('new'), z.literal('processing'), z.literal('completed'), z.literal('error')]),
|
||||
detailedStatus: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
attempts: z.number().default(0).optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastAttemptAt: z.string().datetime().optional(),
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('crawl'),
|
||||
startUrl: z.string(),
|
||||
limit: z.number(),
|
||||
firecrawlId: z.string().optional(),
|
||||
oxylabsId: z.string().optional(),
|
||||
crawledUrls: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('urls'),
|
||||
urls: z.array(z.string()),
|
||||
scrapedUrls: z.string().optional(),
|
||||
missingUrls: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const EmbeddingDoc = z.object({
|
||||
content: z.string(),
|
||||
sourceId: z.string(),
|
||||
embeddings: z.array(z.number()),
|
||||
metadata: z.object({
|
||||
sourceURL: z.string(),
|
||||
title: z.string(),
|
||||
score: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const Project = z.object({
|
||||
_id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
createdByUserId: z.string(),
|
||||
secret: z.string(),
|
||||
chatClientId: z.string(),
|
||||
webhookUrl: z.string().optional(),
|
||||
publishedWorkflowId: z.string().optional(),
|
||||
nextWorkflowNumber: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ProjectMember = z.object({
|
||||
userId: z.string(),
|
||||
projectId: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const GetInformationToolResultItem = z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
url: z.string(),
|
||||
score: z.number().optional(),
|
||||
});
|
||||
|
||||
export const GetInformationToolResult = z.object({
|
||||
results: z.array(GetInformationToolResultItem)
|
||||
});
|
||||
|
||||
export const WebpageCrawlResponse = z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const AgenticAPIChatMessage = z.object({
|
||||
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
|
||||
content: z.string().nullable(),
|
||||
tool_calls: z.array(z.object({
|
||||
id: z.string(),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
}),
|
||||
type: z.literal('function'),
|
||||
})).nullable(),
|
||||
tool_call_id: z.string().nullable(),
|
||||
tool_name: z.string().nullable(),
|
||||
sender: z.string().nullable(),
|
||||
response_type: z.union([
|
||||
z.literal('internal'),
|
||||
z.literal('external'),
|
||||
]).optional(),
|
||||
});
|
||||
|
||||
export const WorkflowAgent = z.object({
|
||||
name: z.string(),
|
||||
type: z.union([
|
||||
z.literal('conversation'),
|
||||
z.literal('post_process'),
|
||||
z.literal('guardrails'),
|
||||
z.literal('escalation'),
|
||||
]),
|
||||
description: z.string(),
|
||||
disabled: z.boolean().default(false).optional(),
|
||||
instructions: z.string(),
|
||||
examples: z.string().optional(),
|
||||
prompts: z.array(z.string()),
|
||||
tools: z.array(z.string()),
|
||||
model: z.string(),
|
||||
locked: z.boolean().default(false).describe('Whether this agent is locked and cannot be deleted').optional(),
|
||||
toggleAble: z.boolean().default(true).describe('Whether this agent can be enabled or disabled').optional(),
|
||||
global: z.boolean().default(false).describe('Whether this agent is a global agent, in which case it cannot be connected to other agents').optional(),
|
||||
ragDataSources: z.array(z.string()).optional(),
|
||||
ragReturnType: z.union([z.literal('chunks'), z.literal('content')]).default('chunks'),
|
||||
ragK: z.number().default(3),
|
||||
connectedAgents: z.array(z.string()),
|
||||
controlType: z.union([z.literal('retain'), z.literal('relinquish_to_parent'), z.literal('relinquish_to_start')]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
|
||||
});
|
||||
|
||||
export const WorkflowPrompt = z.object({
|
||||
name: z.string(),
|
||||
type: z.union([
|
||||
z.literal('base_prompt'),
|
||||
z.literal('style_prompt'),
|
||||
]),
|
||||
prompt: z.string(),
|
||||
});
|
||||
|
||||
export const WorkflowTool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
mockInPlayground: z.boolean().default(false).optional(),
|
||||
parameters: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
})),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const AgenticAPIAgent = WorkflowAgent
|
||||
.omit({
|
||||
disabled: true,
|
||||
examples: true,
|
||||
prompts: true,
|
||||
locked: true,
|
||||
toggleAble: true,
|
||||
global: true,
|
||||
ragDataSources: true,
|
||||
ragReturnType: true,
|
||||
ragK: true,
|
||||
})
|
||||
.extend({
|
||||
hasRagSources: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export const AgenticAPIPrompt = WorkflowPrompt;
|
||||
|
||||
export const AgenticAPITool = WorkflowTool.omit({
|
||||
mockInPlayground: true,
|
||||
});
|
||||
|
||||
export const Workflow = z.object({
|
||||
name: z.string().optional(),
|
||||
agents: z.array(WorkflowAgent),
|
||||
prompts: z.array(WorkflowPrompt),
|
||||
tools: z.array(WorkflowTool),
|
||||
startAgent: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
export type WithStringId<T> = T & { _id: string };
|
||||
|
||||
export const CopilotWorkflow = Workflow.omit({
|
||||
lastUpdatedAt: true,
|
||||
projectId: true,
|
||||
});
|
||||
|
||||
export const AgenticAPIChatRequest = z.object({
|
||||
messages: z.array(AgenticAPIChatMessage),
|
||||
state: z.unknown(),
|
||||
agents: z.array(AgenticAPIAgent),
|
||||
tools: z.array(AgenticAPITool),
|
||||
prompts: z.array(WorkflowPrompt),
|
||||
startAgent: z.string(),
|
||||
});
|
||||
|
||||
export const AgenticAPIChatResponse = z.object({
|
||||
messages: z.array(AgenticAPIChatMessage),
|
||||
state: z.unknown(),
|
||||
});
|
||||
|
||||
export const CopilotUserMessage = z.object({
|
||||
role: z.literal('user'),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const CopilotAssistantMessageTextPart = z.object({
|
||||
type: z.literal("text"),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const CopilotAssistantMessageActionPart = z.object({
|
||||
type: z.literal("action"),
|
||||
content: z.object({
|
||||
config_type: z.union([z.literal('tool'), z.literal('agent'), z.literal('prompt')]),
|
||||
action: z.union([z.literal('create_new'), z.literal('edit')]),
|
||||
name: z.string(),
|
||||
change_description: z.string(),
|
||||
config_changes: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
});
|
||||
|
||||
export const CopilotAssistantMessage = z.object({
|
||||
role: z.literal('assistant'),
|
||||
content: z.object({
|
||||
thoughts: z.string().optional(),
|
||||
response: z.array(z.union([CopilotAssistantMessageTextPart, CopilotAssistantMessageActionPart])),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CopilotMessage = z.union([CopilotUserMessage, CopilotAssistantMessage]);
|
||||
|
||||
export const CopilotApiMessage = z.object({
|
||||
role: z.union([z.literal('assistant'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const CopilotChatContext = z.union([
|
||||
z.object({
|
||||
type: z.literal('chat'),
|
||||
messages: z.array(apiV1.ChatMessage),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('agent'),
|
||||
name: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('tool'),
|
||||
name: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('prompt'),
|
||||
name: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CopilotApiChatContext = z.union([
|
||||
z.object({
|
||||
type: z.literal('chat'),
|
||||
messages: z.array(AgenticAPIChatMessage),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('agent'),
|
||||
agentName: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('tool'),
|
||||
toolName: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('prompt'),
|
||||
promptName: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CopilotAPIRequest = z.object({
|
||||
messages: z.array(CopilotApiMessage),
|
||||
workflow_schema: z.string(),
|
||||
current_workflow_config: z.string(),
|
||||
context: CopilotApiChatContext.nullable(),
|
||||
});
|
||||
|
||||
export const CopilotAPIResponse = z.union([
|
||||
z.object({
|
||||
response: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ClientToolCallRequestBody = z.object({
|
||||
toolCall: apiV1.AssistantMessageWithToolCalls.shape.tool_calls.element,
|
||||
});
|
||||
|
||||
export const ClientToolCallJwt = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
bodyHash: z.string(),
|
||||
iat: z.number(),
|
||||
exp: z.number(),
|
||||
});
|
||||
|
||||
export const ClientToolCallRequest = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
content: z.string(), // json stringified ClientToolCallRequestBody
|
||||
});
|
||||
|
||||
export const ClientToolCallResponse = z.unknown();
|
||||
|
||||
export function convertToCopilotApiChatContext(context: z.infer<typeof CopilotChatContext>): z.infer<typeof CopilotApiChatContext> {
|
||||
switch (context.type) {
|
||||
case 'chat':
|
||||
return {
|
||||
type: 'chat',
|
||||
messages: convertToAgenticAPIChatMessages(context.messages),
|
||||
};
|
||||
case 'agent':
|
||||
return {
|
||||
type: 'agent',
|
||||
agentName: context.name,
|
||||
};
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: context.name,
|
||||
};
|
||||
case 'prompt':
|
||||
return {
|
||||
type: 'prompt',
|
||||
promptName: context.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToCopilotApiMessage(message: z.infer<typeof CopilotMessage>): z.infer<typeof CopilotApiMessage> {
|
||||
return {
|
||||
role: message.role,
|
||||
content: JSON.stringify(message.content),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToCopilotMessage(message: z.infer<typeof CopilotApiMessage>): z.infer<typeof CopilotMessage> {
|
||||
switch (message.role) {
|
||||
case 'assistant':
|
||||
return CopilotAssistantMessage.parse({
|
||||
role: 'assistant',
|
||||
content: JSON.parse(message.content),
|
||||
});
|
||||
case 'user':
|
||||
return {
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown role: ${message.role}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>): {
|
||||
agents: z.infer<typeof AgenticAPIAgent>[],
|
||||
tools: z.infer<typeof AgenticAPITool>[],
|
||||
prompts: z.infer<typeof AgenticAPIPrompt>[],
|
||||
startAgent: string,
|
||||
} {
|
||||
return {
|
||||
agents: workflow.agents
|
||||
.filter(agent => !agent.disabled)
|
||||
.map(agent => ({
|
||||
name: agent.name,
|
||||
type: agent.type,
|
||||
description: agent.description,
|
||||
instructions: agent.instructions +
|
||||
'\n\n' + agent.prompts.map(prompt =>
|
||||
workflow.prompts.find(p => p.name === prompt)?.prompt
|
||||
).join('\n\n') +
|
||||
(agent.examples ? '\n\n# Examples\n' + agent.examples : ''),
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
hasRagSources: agent.ragDataSources ? agent.ragDataSources.length > 0 : false,
|
||||
connectedAgents: agent.connectedAgents,
|
||||
controlType: agent.controlType,
|
||||
})),
|
||||
tools: workflow.tools.map(tool => {
|
||||
const { mockInPlayground, ...rest } = tool;
|
||||
return {
|
||||
...rest,
|
||||
};
|
||||
}),
|
||||
prompts: workflow.prompts,
|
||||
startAgent: workflow.startAgent,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToCoreMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): CoreMessage[] {
|
||||
// convert to core messages
|
||||
const coreMessages: CoreMessage[] = [];
|
||||
for (const m of messages) {
|
||||
switch (m.role) {
|
||||
case 'system':
|
||||
coreMessages.push({
|
||||
role: 'system',
|
||||
content: m.content,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
coreMessages.push({
|
||||
role: 'user',
|
||||
content: m.content,
|
||||
});
|
||||
break;
|
||||
case 'assistant':
|
||||
if ('tool_calls' in m) {
|
||||
const toolCallParts: ToolCallPart[] = m.tool_calls.map((toolCall) => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.function.name,
|
||||
args: JSON.parse(toolCall.function.arguments),
|
||||
}));
|
||||
if (m.content) {
|
||||
coreMessages.push({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: m.content,
|
||||
},
|
||||
...toolCallParts,
|
||||
]
|
||||
});
|
||||
} else {
|
||||
coreMessages.push({
|
||||
role: 'assistant',
|
||||
content: toolCallParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
coreMessages.push({
|
||||
role: 'assistant',
|
||||
content: m.content,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
coreMessages.push({
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: m.tool_call_id,
|
||||
toolName: m.tool_name,
|
||||
result: JSON.parse(m.content),
|
||||
}
|
||||
]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return coreMessages;
|
||||
}
|
||||
|
||||
export function convertToAgenticAPIChatMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
|
||||
const converted: z.infer<typeof AgenticAPIChatMessage>[] = [];
|
||||
|
||||
for (const m of messages) {
|
||||
const baseMessage: z.infer<typeof AgenticAPIChatMessage> = {
|
||||
content: null,
|
||||
role: m.role,
|
||||
sender: null,
|
||||
tool_calls: null,
|
||||
tool_call_id: null,
|
||||
tool_name: null,
|
||||
};
|
||||
|
||||
switch (m.role) {
|
||||
case 'system':
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
content: m.content,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
content: m.content,
|
||||
});
|
||||
break;
|
||||
case 'assistant':
|
||||
if ('tool_calls' in m) {
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
tool_calls: m.tool_calls,
|
||||
sender: m.agenticSender ?? null,
|
||||
response_type: m.agenticResponseType,
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
content: m.content,
|
||||
sender: m.agenticSender ?? null,
|
||||
response_type: m.agenticResponseType,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
content: m.content,
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_name: m.tool_name,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
export function convertFromAgenticAPIChatMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof apiV1.ChatMessage>[] {
|
||||
const converted: z.infer<typeof apiV1.ChatMessage>[] = [];
|
||||
|
||||
for (const m of messages) {
|
||||
const baseMessage = {
|
||||
version: 'v1' as const,
|
||||
chatId: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
switch (m.role) {
|
||||
case 'user':
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
role: 'user',
|
||||
content: m.content ?? '',
|
||||
});
|
||||
break;
|
||||
case 'assistant':
|
||||
if (m.tool_calls) {
|
||||
// TODO: handle tool calls
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
role: 'assistant',
|
||||
tool_calls: m.tool_calls,
|
||||
agenticSender: m.sender ?? undefined,
|
||||
agenticResponseType: m.response_type ?? 'internal',
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
role: 'assistant',
|
||||
content: m.content ?? '',
|
||||
agenticSender: m.sender ?? undefined,
|
||||
agenticResponseType: m.response_type ?? 'internal',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
converted.push({
|
||||
...baseMessage,
|
||||
role: 'tool',
|
||||
content: m.content ?? '',
|
||||
tool_call_id: m.tool_call_id ?? '',
|
||||
tool_name: m.tool_name ?? '',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
export function convertToCopilotWorkflow(workflow: z.infer<typeof Workflow>): z.infer<typeof CopilotWorkflow> {
|
||||
const { lastUpdatedAt, projectId, ...rest } = workflow;
|
||||
return {
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
114
apps/rowboat/app/lib/utils.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Workflow } from "@/app/lib/types";
|
||||
import { projectsCollection } from "./mongodb";
|
||||
import crypto from 'crypto';
|
||||
import { z } from "zod";
|
||||
|
||||
export async function generateWebhookJwtSecret(projectId: string): Promise<string> {
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { webhookJwtSecret: secret, webhookJwtSecretUpdatedAt: new Date().toISOString() } }
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export const baseWorkflow: z.infer<typeof Workflow> = {
|
||||
projectId: "",
|
||||
createdAt: "",
|
||||
lastUpdatedAt: "",
|
||||
startAgent: "Example Agent",
|
||||
agents: [
|
||||
{
|
||||
name: "Example Agent",
|
||||
type: "conversation",
|
||||
description: "",
|
||||
instructions: `## 🧑 Role:
|
||||
You are an helpful customer support assistant
|
||||
|
||||
---
|
||||
## ⚙️ Steps to Follow:
|
||||
1. Ask the user what they would like help with
|
||||
2. Ask the user for their email address and let them know someone will contact them soon.
|
||||
|
||||
---
|
||||
## 🎯 Scope:
|
||||
✅ In Scope:
|
||||
- Asking the user their issue
|
||||
- Getting their email
|
||||
|
||||
❌ Out of Scope:
|
||||
- Questions unrelated to customer support
|
||||
- If a question is out of scope, politely inform the user and avoid providing an answer.
|
||||
|
||||
---
|
||||
## 📋 Guidelines:
|
||||
✔️ Dos:
|
||||
- ask user their issue
|
||||
|
||||
❌ Don'ts:
|
||||
- don't ask user any other detail than email`,
|
||||
prompts: [],
|
||||
tools: [],
|
||||
model: "gpt-4o-mini",
|
||||
toggleAble: true,
|
||||
ragReturnType: "chunks",
|
||||
ragK: 3,
|
||||
connectedAgents: [],
|
||||
controlType: "retain",
|
||||
},
|
||||
{
|
||||
name: "Guardrails",
|
||||
type: "guardrails",
|
||||
description: "",
|
||||
instructions: "Stick to the facts and do not make any assumptions.",
|
||||
prompts: [],
|
||||
tools: [],
|
||||
model: "gpt-4o-mini",
|
||||
locked: true,
|
||||
toggleAble: true,
|
||||
global: true,
|
||||
ragReturnType: "chunks",
|
||||
ragK: 3,
|
||||
connectedAgents: [],
|
||||
controlType: "retain",
|
||||
},
|
||||
{
|
||||
name: "Post process",
|
||||
type: "post_process",
|
||||
description: "",
|
||||
instructions: "Ensure that the agent response is terse and to the point.",
|
||||
prompts: [],
|
||||
tools: [],
|
||||
model: "gpt-4o-mini",
|
||||
locked: true,
|
||||
global: true,
|
||||
ragReturnType: "chunks",
|
||||
ragK: 3,
|
||||
connectedAgents: [],
|
||||
controlType: "retain",
|
||||
},
|
||||
{
|
||||
name: "Escalation",
|
||||
type: "escalation",
|
||||
description: "",
|
||||
instructions: "Get the user's contact information and let them know that their request has been escalated.",
|
||||
prompts: [],
|
||||
tools: [],
|
||||
model: "gpt-4o-mini",
|
||||
locked: true,
|
||||
toggleAble: true,
|
||||
ragReturnType: "chunks",
|
||||
ragK: 3,
|
||||
connectedAgents: [],
|
||||
controlType: "retain",
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
name: "Style prompt",
|
||||
type: "style_prompt",
|
||||
prompt: "You should be empathetic and helpful.",
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
};
|
||||
7
apps/rowboat/app/loading.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Spinner } from "@nextui-org/react";
|
||||
|
||||
export default function Loading() {
|
||||
// Stack uses React Suspense, which will render this page while user data is being fetched.
|
||||
// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading
|
||||
return <Spinner size="sm" />;
|
||||
}
|
||||
BIN
apps/rowboat/app/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
apps/rowboat/app/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/rowboat/app/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/rowboat/app/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/rowboat/app/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
10
apps/rowboat/app/new-chat-link.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export function NewChatLink({demo}: {demo: string}) {
|
||||
return <Link
|
||||
className="mt-2 text-black flex rounded-lg border border-gray-400 px-4 py-2 disabled:text-gray-400"
|
||||
href={`/new/${demo}`}
|
||||
>
|
||||
Start new chat →
|
||||
</Link>
|
||||
}
|
||||
5
apps/rowboat/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { App } from "./app";
|
||||
|
||||
export default function Home() {
|
||||
return <App />
|
||||
}
|
||||
124
apps/rowboat/app/projects/[projectId]/config/app.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use client';
|
||||
|
||||
import { Metadata } from "next";
|
||||
import { Secret } from "./secret";
|
||||
import { Divider, Spinner } from "@nextui-org/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Project } from "@/app/lib/types";
|
||||
import { getProjectConfig } from "@/app/actions";
|
||||
import { EmbedCode } from "./embed";
|
||||
import { WebhookUrl } from "./webhook-url";
|
||||
import { z } from 'zod';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Project config",
|
||||
};
|
||||
|
||||
export default function App({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [project, setProject] = useState<z.infer<typeof Project> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchProjectConfig() {
|
||||
setIsLoading(true);
|
||||
const project = await getProjectConfig(projectId);
|
||||
if (!ignore) {
|
||||
setProject(project);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchProjectConfig();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const standardEmbedCode = `<!-- RowBoat Chat Widget -->
|
||||
<script>
|
||||
window.ROWBOAT_CONFIG = {
|
||||
clientId: '${project?.chatClientId}'
|
||||
};
|
||||
(function(d) {
|
||||
var s = d.createElement('script');
|
||||
s.src = 'https://chat.rowboatlabs.com/bootstrap.js';
|
||||
s.async = true;
|
||||
d.getElementsByTagName('head')[0].appendChild(s);
|
||||
})(document);
|
||||
</script>`;
|
||||
|
||||
const nextJsEmbedCode = `// Add this to your Next.js page or layout
|
||||
import Script from 'next/script'
|
||||
|
||||
export default function YourComponent() {
|
||||
return (
|
||||
<>
|
||||
<Script id="rowboat-config">
|
||||
{\`window.ROWBOAT_CONFIG = {
|
||||
clientId: '${project?.chatClientId}'
|
||||
};\`}
|
||||
</Script>
|
||||
<Script
|
||||
src="https://chat.rowboatlabs.com/bootstrap.js"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}`
|
||||
|
||||
return <div className="flex flex-col h-full">
|
||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg">Project config</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto py-4">
|
||||
<div className="max-w-[768px] mx-auto">
|
||||
{isLoading && <div className="flex items-center gap-1">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading project config...</div>
|
||||
</div>}
|
||||
{!isLoading && project && <div className="flex flex-col gap-4">
|
||||
<h2 className="font-semibold">Credentials</h2>
|
||||
<Secret
|
||||
initialSecret={project.secret}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Add the chat widget to your website</h2>
|
||||
<p className="text-gray-600">Copy and paste this code snippet just before the closing </body> tag of your website:</p>
|
||||
<EmbedCode key="standard-embed-code" embedCode={standardEmbedCode} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Using Next.js?</h2>
|
||||
<p className="text-gray-600">If you're using Next.js, use this code instead:</p>
|
||||
<EmbedCode key="nextjs-embed-code" embedCode={nextJsEmbedCode} />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Webhook settings</h2>
|
||||
<p className="mb-4">
|
||||
You can configure a webhook that will respond to tool calls.
|
||||
</p>
|
||||
<WebhookUrl
|
||||
initialUrl={project?.webhookUrl}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
42
apps/rowboat/app/projects/[projectId]/config/embed.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Textarea, Button } from "@nextui-org/react";
|
||||
import { CheckIcon, ClipboardIcon } from 'lucide-react';
|
||||
|
||||
interface EmbedCodeProps {
|
||||
embedCode: string;
|
||||
}
|
||||
|
||||
export function EmbedCode({ embedCode }: EmbedCodeProps) {
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(embedCode);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
labelPlacement="outside"
|
||||
variant="bordered"
|
||||
defaultValue={embedCode}
|
||||
className="max-w-full cursor-pointer"
|
||||
readOnly
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Button
|
||||
variant="flat"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
isIconOnly
|
||||
>
|
||||
{isCopied ? <CheckIcon size={16} /> : <ClipboardIcon size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/rowboat/app/projects/[projectId]/config/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Metadata } from "next";
|
||||
import App from "./app";
|
||||
export const metadata: Metadata = {
|
||||
title: "Project config",
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
projectId: string;
|
||||
};
|
||||
}) {
|
||||
return <App projectId={params.projectId} />;
|
||||
}
|
||||
94
apps/rowboat/app/projects/[projectId]/config/secret.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Input } from "@nextui-org/react";
|
||||
import { useState } from "react";
|
||||
import { rotateSecret } from "@/app/actions";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
|
||||
export function Secret({
|
||||
initialSecret,
|
||||
projectId
|
||||
}: {
|
||||
initialSecret: string,
|
||||
projectId: string
|
||||
}) {
|
||||
const getMaskedSecret = (secret: string) => {
|
||||
if (!secret) return '';
|
||||
if (secret.length <= 8) return secret;
|
||||
return `${secret.slice(0, 4)}${'•'.repeat(16)}${secret.slice(-4)}`;
|
||||
};
|
||||
|
||||
const [maskedSecret, setMaskedSecret] = useState(getMaskedSecret(initialSecret));
|
||||
const [showNewSecret, setShowNewSecret] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!window.confirm('Are you sure you want to regenerate the webhook secret? This will invalidate the current secret key immediately.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const newSecret = await rotateSecret(projectId);
|
||||
setShowNewSecret(newSecret);
|
||||
setMaskedSecret(getMaskedSecret(newSecret));
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate webhook secret:', error);
|
||||
// You might want to add a toast or error message here
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (showNewSecret) {
|
||||
await navigator.clipboard.writeText(showNewSecret);
|
||||
setShowCopySuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowCopySuccess(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-gray-600 mb-2">Project Secret</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={showNewSecret || maskedSecret}
|
||||
readOnly
|
||||
variant="bordered"
|
||||
className="font-mono"
|
||||
endContent={
|
||||
showNewSecret ? (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<CheckIcon size={16} />
|
||||
) : (
|
||||
<ClipboardIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onClick={handleRegenerate}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
{showNewSecret && (
|
||||
<div className="text-sm text-red-600 mt-2">
|
||||
Make sure to copy your new secret key. It won't be shown again!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/rowboat/app/projects/[projectId]/config/webhook-url.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Input } from "@nextui-org/react";
|
||||
import { useState } from "react";
|
||||
import { updateWebhookUrl } from "@/app/actions";
|
||||
|
||||
export function WebhookUrl({
|
||||
initialUrl,
|
||||
projectId
|
||||
}: {
|
||||
initialUrl?: string,
|
||||
projectId: string
|
||||
}) {
|
||||
const [url, setUrl] = useState(initialUrl || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setShowSuccess(false);
|
||||
|
||||
// URL validation
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
setError('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure HTTPS scheme
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
setError('URL must use HTTPS');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateWebhookUrl(projectId, url);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to update webhook URL:', error);
|
||||
setError('Failed to update webhook URL');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-end">
|
||||
<Input
|
||||
label="Webhook URL"
|
||||
labelPlacement="outside"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError(null);
|
||||
setShowSuccess(false);
|
||||
}}
|
||||
className="flex-grow"
|
||||
isInvalid={!!error}
|
||||
errorMessage={error}
|
||||
description={showSuccess ? "Webhook URL updated successfully" : undefined}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleUpdate}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/rowboat/app/projects/[projectId]/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Nav } from "./nav";
|
||||
|
||||
export default async function Layout({
|
||||
params,
|
||||
children
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="flex h-full">
|
||||
<Nav projectId={params.projectId} />
|
||||
<div className="grow p-4 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div >;
|
||||
}
|
||||
85
apps/rowboat/app/projects/[projectId]/menu.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'use client';
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { WorkflowIcon } from "@/app/lib/components/icons";
|
||||
|
||||
function NavLink({ href, label, icon, collapsed, selected = false }: { href: string, label: string, icon: React.ReactNode, collapsed: boolean, selected?: boolean }) {
|
||||
return <Link
|
||||
href={href}
|
||||
className={clsx("flex px-2 py-3 gap-3 items-center rounded-lg hover:bg-gray-200", {
|
||||
"bg-gray-200": selected,
|
||||
"justify-center": collapsed,
|
||||
})}
|
||||
>
|
||||
{collapsed && Tooltip && <Tooltip content={label} showArrow placement="right">
|
||||
<div className="shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
</Tooltip>}
|
||||
{!collapsed && <div className="shrink-0">
|
||||
{icon}
|
||||
</div>}
|
||||
{!collapsed && <div className="truncate">
|
||||
{label}
|
||||
</div>}
|
||||
</Link>;
|
||||
}
|
||||
|
||||
export default function Menu({
|
||||
projectId,
|
||||
collapsed,
|
||||
}: {
|
||||
projectId: string;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return <div className="flex flex-col">
|
||||
{/* <NavLink
|
||||
href={`/projects/${projectId}/playground`}
|
||||
label="Playground"
|
||||
collapsed={collapsed}
|
||||
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
|
||||
</svg>
|
||||
selected={pathname.startsWith(`/projects/${projectId}/playground`)}
|
||||
/> */}
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/workflow`}
|
||||
label="Workflow"
|
||||
collapsed={collapsed}
|
||||
icon={<WorkflowIcon />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
|
||||
/>
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/sources`}
|
||||
label="Data sources"
|
||||
collapsed={collapsed}
|
||||
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 6c0 1.657-3.134 3-7 3S5 7.657 5 6m14 0c0-1.657-3.134-3-7-3S5 4.343 5 6m14 0v6M5 6v6m0 0c0 1.657 3.134 3 7 3s7-1.343 7-3M5 12v6c0 1.657 3.134 3 7 3s7-1.343 7-3v-6" />
|
||||
</svg>
|
||||
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
|
||||
/>
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/config`}
|
||||
label="Config"
|
||||
collapsed={collapsed}
|
||||
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 4.757V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757l-1.707.707-.536-.535a1 1 0 0 0-1.414 0L4.929 6.343a1 1 0 0 0 0 1.414l.536.536L4.757 10H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535 1.707.707V20a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.708.536.536a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z" />
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
</svg>
|
||||
selected={pathname.startsWith(`/projects/${projectId}/config`)}
|
||||
/>
|
||||
{/*<NavLink
|
||||
href={`/projects/${projectId}/integrate`}
|
||||
label="Integrate"
|
||||
collapsed={collapsed}
|
||||
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14" />
|
||||
</svg>
|
||||
selected={pathname.startsWith(`/projects/${projectId}/integrate`)}
|
||||
/>*/}
|
||||
</div>;
|
||||
}
|
||||
68
apps/rowboat/app/projects/[projectId]/nav.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use client';
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import Menu from "./menu";
|
||||
import { Project } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { getProjectConfig } from "@/app/actions";
|
||||
|
||||
export function Nav({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [project, setProject] = useState<z.infer<typeof Project> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function getProject() {
|
||||
const project = await getProjectConfig(projectId);
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setProject(project);
|
||||
}
|
||||
getProject();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
function toggleCollapse() {
|
||||
setCollapsed(!collapsed);
|
||||
}
|
||||
|
||||
return <div className={clsx("bg-gray-50 shrink-0 flex flex-col gap-6 border-r-1 border-gray-100 relative p-4", {
|
||||
"w-64": !collapsed,
|
||||
"w-16": collapsed
|
||||
})}>
|
||||
<Tooltip content={collapsed ? "Expand" : "Collapse"} showArrow placement="right">
|
||||
<button onClick={toggleCollapse} className="absolute bottom-[100px] right-[-16px] rounded-full border bg-white text-gray-400 border-gray-400 hover:border-black hover:text-black w-[28px] h-[28px] shadow-sm">
|
||||
{!collapsed && <svg className="m-auto w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m17 16-4-4 4-4m-6 8-4-4 4-4" />
|
||||
</svg>}
|
||||
{collapsed && <svg className="m-auto w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m7 16 4-4-4-4m6 8 4-4-4-4" />
|
||||
</svg>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!collapsed && project && <div className="flex flex-col gap-1">
|
||||
<Tooltip content="Change project" showArrow placement="bottom-end">
|
||||
<Link className="relative group flex flex-col px-3 py-3 border border-gray-200 rounded-md hover:border-gray-500" href="/projects">
|
||||
<div className="absolute top-[-7px] left-1 px-2 bg-gray-50 text-xs text-gray-400 group-hover:text-gray-600">
|
||||
Project
|
||||
</div>
|
||||
<div className="truncate">
|
||||
{project.name}
|
||||
</div>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
<Menu projectId={projectId} collapsed={collapsed} />
|
||||
</div>;
|
||||
}
|
||||
9
apps/rowboat/app/projects/[projectId]/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page({
|
||||
params
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
redirect(`/projects/${params.projectId}/workflow`);
|
||||
}
|
||||
110
apps/rowboat/app/projects/[projectId]/playground/app.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use client';
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import { PlaygroundChat, SimulationData, Workflow } from "@/app/lib/types";
|
||||
import { SimulateScenarioOption, SimulateURLOption } from "./simulation-options";
|
||||
import { Chat } from "./chat";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ActionButton, Pane } from "../workflow/pane";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
||||
function SimulateLabel() {
|
||||
return <span>Simulate<sup className="pl-1">beta</sup></span>;
|
||||
}
|
||||
|
||||
const defaultSystemMessage = '';
|
||||
|
||||
export function App({
|
||||
hidden = false,
|
||||
projectId,
|
||||
workflow,
|
||||
messageSubscriber,
|
||||
}: {
|
||||
hidden?: boolean;
|
||||
projectId: string;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
|
||||
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
|
||||
const [loadingChat, setLoadingChat] = useState<boolean>(false);
|
||||
const [viewSimulationMenu, setViewSimulationMenu] = useState<boolean>(false);
|
||||
const [counter, setCounter] = useState<number>(0);
|
||||
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
messages: [],
|
||||
simulated: false,
|
||||
systemMessage: defaultSystemMessage,
|
||||
});
|
||||
|
||||
function handleSimulateButtonClick() {
|
||||
setViewSimulationMenu(true);
|
||||
}
|
||||
function handleNewChatButtonClick() {
|
||||
setExistingChatId(null);
|
||||
setViewSimulationMenu(false);
|
||||
setCounter(counter + 1);
|
||||
setChat({
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
messages: [],
|
||||
simulated: false,
|
||||
systemMessage: defaultSystemMessage,
|
||||
});
|
||||
}
|
||||
function beginSimulation(data: z.infer<typeof SimulationData>) {
|
||||
setExistingChatId(null);
|
||||
setViewSimulationMenu(false);
|
||||
setCounter(counter + 1);
|
||||
setChat({
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
messages: [],
|
||||
simulated: true,
|
||||
simulationData: data,
|
||||
});
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <Pane title={viewSimulationMenu ? <SimulateLabel /> : "Playground"} actions={[
|
||||
<ActionButton
|
||||
key="new-chat"
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 10.5h.01m-4.01 0h.01M8 10.5h.01M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6.6a1 1 0 0 0-.69.275l-2.866 2.723A.5.5 0 0 1 8 18.635V17a1 1 0 0 0-1-1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||
</svg>}
|
||||
onClick={handleNewChatButtonClick}
|
||||
>
|
||||
New chat
|
||||
</ActionButton>,
|
||||
!viewSimulationMenu && <ActionButton
|
||||
key="simulate"
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 18V6l8 6-8 6Z" />
|
||||
</svg>}
|
||||
onClick={handleSimulateButtonClick}
|
||||
>
|
||||
Simulate
|
||||
</ActionButton>,
|
||||
]}>
|
||||
<div className="h-full overflow-auto">
|
||||
{!viewSimulationMenu && loadingChat && <div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>}
|
||||
{!viewSimulationMenu && !loadingChat && <Chat
|
||||
key={existingChatId || 'chat-' + counter}
|
||||
chat={chat}
|
||||
initialChatId={existingChatId || null}
|
||||
projectId={projectId}
|
||||
workflow={workflow}
|
||||
messageSubscriber={messageSubscriber}
|
||||
/>}
|
||||
{viewSimulationMenu && <SimulateScenarioOption beginSimulation={beginSimulation} projectId={projectId} />}
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
319
apps/rowboat/app/projects/[projectId]/playground/chat.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
'use client';
|
||||
import { getAssistantResponse, simulateUserResponse } from "@/app/actions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Messages } from "./messages";
|
||||
import z from "zod";
|
||||
import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI, PlaygroundChat, Workflow } from "@/app/lib/types";
|
||||
import { ComposeBox } from "./compose-box";
|
||||
import { Button } from "@nextui-org/react";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
|
||||
export function Chat({
|
||||
chat,
|
||||
initialChatId = null,
|
||||
projectId,
|
||||
workflow,
|
||||
messageSubscriber,
|
||||
}: {
|
||||
chat: z.infer<typeof PlaygroundChat>;
|
||||
initialChatId?: string | null;
|
||||
projectId: string;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
|
||||
}) {
|
||||
const [chatId, setChatId] = useState<string | null>(initialChatId);
|
||||
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
|
||||
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
|
||||
const [loadingUserResponse, setLoadingUserResponse] = useState<boolean>(false);
|
||||
const [simulationComplete, setSimulationComplete] = useState<boolean>(chat.simulationComplete || false);
|
||||
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
|
||||
last_agent_name: workflow.startAgent,
|
||||
});
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
const [assistantResponseError, setAssistantResponseError] = useState<string | null>(null);
|
||||
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
|
||||
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
|
||||
const [systemMessage, setSystemMessage] = useState<string | undefined>(chat.systemMessage);
|
||||
|
||||
// collect published tool call results
|
||||
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
|
||||
messages
|
||||
.filter((message) => message.role == 'tool')
|
||||
.forEach((message) => {
|
||||
toolCallResults[message.tool_call_id] = message;
|
||||
});
|
||||
|
||||
function handleUserMessage(prompt: string) {
|
||||
const updatedMessages: z.infer<typeof apiV1.ChatMessage>[] = [...messages, {
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
version: 'v1',
|
||||
chatId: chatId ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}];
|
||||
setMessages(updatedMessages);
|
||||
}
|
||||
|
||||
function handleToolCallResults(results: z.infer<typeof apiV1.ToolMessage>[]) {
|
||||
setMessages([...messages, ...results.map((result) => ({
|
||||
...result,
|
||||
version: 'v1' as const,
|
||||
chatId: chatId ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}))]);
|
||||
}
|
||||
|
||||
// reset state when workflow changes
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setAgenticState({
|
||||
last_agent_name: workflow.startAgent,
|
||||
});
|
||||
}, [workflow]);
|
||||
|
||||
// publish messages to subscriber
|
||||
useEffect(() => {
|
||||
if (messageSubscriber) {
|
||||
messageSubscriber(messages);
|
||||
}
|
||||
}, [messages, messageSubscriber]);
|
||||
|
||||
// get agent response
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function process() {
|
||||
setLoadingAssistantResponse(true);
|
||||
setAssistantResponseError(null);
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
|
||||
const request: z.infer<typeof AgenticAPIChatRequest> = {
|
||||
messages: convertToAgenticAPIChatMessages([{
|
||||
role: 'system',
|
||||
content: systemMessage || '',
|
||||
version: 'v1' as const,
|
||||
chatId: chatId ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}, ...messages]),
|
||||
state: agenticState,
|
||||
agents,
|
||||
tools,
|
||||
prompts,
|
||||
startAgent,
|
||||
};
|
||||
setLastAgenticRequest(request);
|
||||
setLastAgenticResponse(null);
|
||||
|
||||
try {
|
||||
const response = await getAssistantResponse(projectId, request);
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setLastAgenticResponse(response.rawAPIResponse);
|
||||
setMessages([...messages, ...response.messages.map((message) => ({
|
||||
...message,
|
||||
version: 'v1' as const,
|
||||
chatId: chatId ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}))]);
|
||||
setAgenticState(response.state);
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setAssistantResponseError(`Failed to get assistant response: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
} finally {
|
||||
setLoadingAssistantResponse(false);
|
||||
}
|
||||
}
|
||||
|
||||
// if no messages, return
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if last message is not from role user
|
||||
// or tool, return
|
||||
const last = messages[messages.length - 1];
|
||||
if (assistantResponseError) {
|
||||
return;
|
||||
}
|
||||
if (last.role !== 'user' && last.role !== 'tool') {
|
||||
return;
|
||||
}
|
||||
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [chatId, chat.simulated, messages, projectId, agenticState, workflow, assistantResponseError, systemMessage]);
|
||||
|
||||
// simulate user turn
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
function process() {
|
||||
if (chat.simulationData === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// fetch next user prompt
|
||||
setLoadingUserResponse(true);
|
||||
simulateUserResponse(projectId, messages, chat.simulationData)
|
||||
.then(response => {
|
||||
//console.log('User response:', response);
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
if (response.trim() === 'EXIT') {
|
||||
setSimulationComplete(true);
|
||||
return;
|
||||
}
|
||||
setMessages([...messages, {
|
||||
role: 'user',
|
||||
content: response,
|
||||
version: 'v1' as const,
|
||||
chatId: chatId ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingUserResponse(false);
|
||||
});
|
||||
}
|
||||
|
||||
// proceed only if chat is simulated
|
||||
if (!chat.simulated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// dont proceed if simulation is complete
|
||||
if (chat.simulated && simulationComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if there are no messages yet OR
|
||||
// check if the last message is an assistant
|
||||
// message containing a text response. If so,
|
||||
// call the simulate user turn api to fetch
|
||||
// user response
|
||||
let last = messages[messages.length - 1];
|
||||
if (last && last.role !== 'assistant') {
|
||||
return;
|
||||
}
|
||||
if (last && 'tool_calls' in last) {
|
||||
return;
|
||||
}
|
||||
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [chatId, chat.simulated, messages, projectId, simulationComplete, chat.simulationData]);
|
||||
|
||||
// save chat on every assistant message
|
||||
// useEffect(() => {
|
||||
// let ignore = false;
|
||||
|
||||
// function process() {
|
||||
// savePlaygroundChat(projectId, {
|
||||
// ...chat,
|
||||
// messages,
|
||||
// simulationComplete,
|
||||
// agenticState,
|
||||
// }, chatId)
|
||||
// .then((insertedChatId) => {
|
||||
// if (!chatId) {
|
||||
// setChatId(insertedChatId);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// if (messages.length === 0) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const lastMessage = messages[messages.length - 1];
|
||||
// if (lastMessage && lastMessage.role !== 'assistant') {
|
||||
// return;
|
||||
// }
|
||||
// process();
|
||||
// }, [chatId, chat, messages, projectId, simulationComplete, agenticState]);
|
||||
|
||||
const handleCopyChat = () => {
|
||||
const jsonString = JSON.stringify({
|
||||
messages: [{
|
||||
role: 'system',
|
||||
content: systemMessage,
|
||||
}, ...messages],
|
||||
lastRequest: lastAgenticRequest,
|
||||
lastResponse: lastAgenticResponse,
|
||||
}, null, 2);
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
.then(() => {
|
||||
setShowCopySuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowCopySuccess(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy chat to clipboard:', err);
|
||||
});
|
||||
};
|
||||
|
||||
function handleSystemMessageChange(message: string) {
|
||||
setSystemMessage(message);
|
||||
}
|
||||
|
||||
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
isIconOnly
|
||||
onClick={handleCopyChat}
|
||||
className="absolute top-2 right-0"
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<CheckIcon size={16} />
|
||||
) : (
|
||||
<ClipboardIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
<Messages
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
systemMessage={systemMessage}
|
||||
toolCallResults={toolCallResults}
|
||||
handleToolCallResults={handleToolCallResults}
|
||||
loadingAssistantResponse={loadingAssistantResponse}
|
||||
loadingUserResponse={loadingUserResponse}
|
||||
workflow={workflow}
|
||||
onSystemMessageChange={handleSystemMessageChange}
|
||||
/>
|
||||
<div className="shrink-0">
|
||||
{assistantResponseError && (
|
||||
<div className="max-w-[768px] mx-auto mb-4 p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center">
|
||||
<p className="text-red-600">{assistantResponseError}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
setAssistantResponseError(null);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!chat.simulated && <div className="max-w-[768px] mx-auto">
|
||||
<ComposeBox
|
||||
handleUserMessage={handleUserMessage}
|
||||
messages={messages}
|
||||
/>
|
||||
</div>}
|
||||
{chat.simulated && simulationComplete && <p className="text-center">Simulation complete.</p>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Spinner, Textarea } from "@nextui-org/react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { z } from "zod";
|
||||
|
||||
export function ComposeBox({
|
||||
minRows=3,
|
||||
disabled=false,
|
||||
loading=false,
|
||||
handleUserMessage,
|
||||
messages,
|
||||
}: {
|
||||
minRows?: number;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
handleUserMessage: (prompt: string) => void;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function handleInput() {
|
||||
const prompt = input.trim();
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
setInput('');
|
||||
|
||||
handleUserMessage(prompt);
|
||||
}
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleInput();
|
||||
}
|
||||
}
|
||||
// focus on the input field
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [messages]);
|
||||
|
||||
return <Textarea
|
||||
required
|
||||
ref={inputRef}
|
||||
variant="bordered"
|
||||
placeholder="Enter message..."
|
||||
minRows={minRows}
|
||||
maxRows={5}
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
endContent={<Button
|
||||
isIconOnly
|
||||
disabled={disabled}
|
||||
onClick={handleInput}
|
||||
className="bg-gray-100"
|
||||
>
|
||||
{!loading && <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 6v13m0-13 4 4m-4-4-4 4" />
|
||||
</svg>}
|
||||
{loading && <Spinner size="sm" />}
|
||||
</Button>}
|
||||
/>;
|
||||
}
|
||||
763
apps/rowboat/app/projects/[projectId]/playground/messages.tsx
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
'use client';
|
||||
import { Button, Spinner, Textarea } from "@nextui-org/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import { GetInformationToolResult, WebpageCrawlResponse, Workflow, WorkflowTool } from "@/app/lib/types";
|
||||
import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "@/app/actions";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
import Link from "next/link";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
|
||||
function UserMessage({ content }: { content: string }) {
|
||||
return <div className="self-end ml-[30%] flex flex-col">
|
||||
<div className="text-right text-gray-500 text-sm mr-3">
|
||||
User
|
||||
</div>
|
||||
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-br-none">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | undefined, latency: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// show a message icon with a + symbol to expand and show the content
|
||||
return <div className="self-start mr-[30%]">
|
||||
{!expanded && <button className="flex items-center text-gray-400 hover:text-gray-600 gap-1 group" onClick={() => setExpanded(true)}>
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M16 10.5h.01m-4.01 0h.01M8 10.5h.01M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6.6a1 1 0 0 0-.69.275l-2.866 2.723A.5.5 0 0 1 8 18.635V17a1 1 0 0 0-1-1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||
</svg>
|
||||
<svg className="group-hover:hidden w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M6 12h.01m6 0h.01m5.99 0h.01" />
|
||||
</svg>
|
||||
<span className="hidden group-hover:block text-xs">Show debug message</span>
|
||||
</button>}
|
||||
{expanded && <div className="flex flex-col">
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="text-gray-500 text-sm pl-3">
|
||||
{sender ?? 'Assistant'}
|
||||
</div>
|
||||
<button className="flex items-center gap-1 text-gray-400 hover:text-gray-600" onClick={() => setExpanded(false)}>
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-300 border-dashed px-3 py-1 rounded-lg rounded-bl-none">
|
||||
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | undefined, latency: number }) {
|
||||
return <div className="self-start mr-[30%] flex flex-col">
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="text-gray-500 text-sm pl-3">
|
||||
{sender ?? 'Assistant'}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs pr-3">
|
||||
{Math.round(latency / 1000)}s
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-bl-none">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AssistantMessageLoading() {
|
||||
return <div className="self-start mr-[30%] flex flex-col">
|
||||
<div className="text-gray-500 text-sm ml-3">
|
||||
Assistant
|
||||
</div>
|
||||
<div className="bg-gray-100 p-3 rounded-lg rounded-bl-none animate-pulse w-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UserMessageLoading() {
|
||||
return <div className="self-end ml-[30%] flex flex-col">
|
||||
<div className="text-right text-gray-500 text-sm mr-3">
|
||||
User
|
||||
</div>
|
||||
<div className="bg-gray-100 p-3 rounded-lg rounded-br-none animate-pulse w-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ToolCalls({
|
||||
toolCalls,
|
||||
results,
|
||||
handleResults,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
workflow,
|
||||
}: {
|
||||
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
|
||||
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
|
||||
handleResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
}) {
|
||||
const resultsMap: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
|
||||
|
||||
function handleToolCallResult(result: z.infer<typeof apiV1.ToolMessage>) {
|
||||
resultsMap[result.tool_call_id] = result;
|
||||
if (Object.keys(resultsMap).length === toolCalls.length) {
|
||||
const results = Object.values(resultsMap);
|
||||
handleResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
{toolCalls.map(toolCall => {
|
||||
return <ToolCall
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
result={results[toolCall.id]}
|
||||
handleResult={handleToolCallResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
workflow={workflow}
|
||||
/>
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
toolCall,
|
||||
result,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
workflow,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
}) {
|
||||
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
|
||||
for (const tool of workflow.tools) {
|
||||
if (tool.name === toolCall.function.name) {
|
||||
matchingWorkflowTool = tool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (toolCall.function.name) {
|
||||
case 'retrieve_url_info':
|
||||
return <RetrieveUrlInfoToolCall
|
||||
toolCall={toolCall}
|
||||
result={result}
|
||||
handleResult={handleResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
/>;
|
||||
case 'getArticleInfo':
|
||||
return <GetInformationToolCall
|
||||
toolCall={toolCall}
|
||||
result={result}
|
||||
handleResult={handleResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
workflow={workflow}
|
||||
/>;
|
||||
default:
|
||||
if (toolCall.function.name.startsWith('transfer_to_')) {
|
||||
return <TransferToAgentToolCall
|
||||
toolCall={toolCall}
|
||||
result={result}
|
||||
handleResult={handleResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
/>;
|
||||
}
|
||||
if (matchingWorkflowTool && !matchingWorkflowTool.mockInPlayground) {
|
||||
return <ClientToolCall
|
||||
toolCall={toolCall}
|
||||
result={result}
|
||||
handleResult={handleResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
/>;
|
||||
}
|
||||
return <MockToolCall
|
||||
toolCall={toolCall}
|
||||
result={result}
|
||||
handleResult={handleResult}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={sender}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
function GetInformationToolCall({
|
||||
toolCall,
|
||||
result: availableResult,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
workflow,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
}) {
|
||||
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
|
||||
const args = JSON.parse(toolCall.function.arguments) as { question: string };
|
||||
let typedResult: z.infer<typeof GetInformationToolResult> | undefined;
|
||||
if (result) {
|
||||
typedResult = JSON.parse(result.content) as z.infer<typeof GetInformationToolResult>;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
let ignore = false;
|
||||
|
||||
async function process() {
|
||||
const result: z.infer<typeof apiV1.ToolMessage> = {
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: toolCall.function.name,
|
||||
content: '',
|
||||
};
|
||||
// find target agent
|
||||
const agent = workflow.agents.find(agent => agent.name == sender);
|
||||
if (!agent || !agent.ragDataSources) {
|
||||
result.content = JSON.stringify({
|
||||
results: [],
|
||||
});
|
||||
} else {
|
||||
const matches = await getInformationTool(projectId, args.question, agent.ragDataSources, agent.ragReturnType, agent.ragK);
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
result.content = JSON.stringify(matches);
|
||||
}
|
||||
setResult(result);
|
||||
handleResult(result);
|
||||
}
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [result, toolCall.id, toolCall.function.name, projectId, args.question, workflow.agents, sender, handleResult]);
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
|
||||
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
{!result && <Spinner />}
|
||||
|
||||
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
|
||||
</svg>}
|
||||
|
||||
<div className='font-semibold'>
|
||||
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-1'>
|
||||
{result ? 'Fetched' : 'Fetch'} information for question: <span className='font-mono font-semibold'>{args['question']}</span>
|
||||
{result && <div className='flex flex-col gap-2 mt-2 pt-2 border-t border-t-gray-200'>
|
||||
{typedResult && typedResult.results.length === 0 && <div>No matches found.</div>}
|
||||
{typedResult && typedResult.results.length > 0 && <ul className="list-disc ml-6">
|
||||
{typedResult.results.map((result, index) => {
|
||||
return <li key={'' + index}>
|
||||
<Link target="_blank" className="underline" href={result.url}>
|
||||
{result.url}
|
||||
</Link>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RetrieveUrlInfoToolCall({
|
||||
toolCall,
|
||||
result: availableResult,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
}) {
|
||||
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
|
||||
const args = JSON.parse(toolCall.function.arguments) as { url: string };
|
||||
let typedResult: z.infer<typeof WebpageCrawlResponse> | undefined;
|
||||
if (result) {
|
||||
typedResult = JSON.parse(result.content) as z.infer<typeof WebpageCrawlResponse>;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
let ignore = false;
|
||||
|
||||
function process() {
|
||||
// parse args
|
||||
|
||||
scrapeWebpage(args.url)
|
||||
.then(page => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
const result: z.infer<typeof apiV1.ToolMessage> = {
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: toolCall.function.name,
|
||||
content: JSON.stringify(page),
|
||||
};
|
||||
setResult(result);
|
||||
handleResult(result);
|
||||
});
|
||||
}
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [result, toolCall.id, toolCall.function.name, projectId, args.url, handleResult]);
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
|
||||
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
{!result && <Spinner />}
|
||||
|
||||
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
|
||||
</svg>}
|
||||
|
||||
<div className='font-semibold'>
|
||||
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-1 flex flex-col gap-2'>
|
||||
<div className="flex gap-1">
|
||||
URL: <a className="inline-flex items-center gap-1" target="_blank" href={args.url}>
|
||||
<span className='underline'>
|
||||
{args.url}
|
||||
</span>
|
||||
<svg className="w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{result && (
|
||||
<ExpandableContent
|
||||
label='Content'
|
||||
content={JSON.stringify(typedResult, null, 2)}
|
||||
expanded={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TransferToAgentToolCall({
|
||||
toolCall,
|
||||
result: availableResult,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
}) {
|
||||
const typedResult = availableResult ? JSON.parse(availableResult.content) as { assistant: string } : undefined;
|
||||
if (!typedResult) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <div className="flex gap-1 items-center text-gray-500 text-sm justify-center">
|
||||
<div>{sender}</div>
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
|
||||
</svg>
|
||||
<div>{typedResult.assistant}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ClientToolCall({
|
||||
toolCall,
|
||||
result: availableResult,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
}) {
|
||||
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
let ignore = false;
|
||||
|
||||
async function process() {
|
||||
let response;
|
||||
try {
|
||||
response = await executeClientTool(
|
||||
toolCall,
|
||||
projectId,
|
||||
);
|
||||
} catch (e) {
|
||||
response = {
|
||||
error: (e as Error).message,
|
||||
};
|
||||
}
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: z.infer<typeof apiV1.ToolMessage> = {
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: toolCall.function.name,
|
||||
content: JSON.stringify(response),
|
||||
};
|
||||
setResult(result);
|
||||
handleResult(result);
|
||||
}
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [result, toolCall, projectId, messages, handleResult]);
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
|
||||
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
|
||||
<div className='shrink-0 flex gap-2 items-center'>
|
||||
{!result && <Spinner />}
|
||||
|
||||
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
|
||||
</svg>}
|
||||
|
||||
<div className='font-semibold'>
|
||||
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ExpandableContent label='Arguments' content={JSON.stringify(toolCall.function.arguments, null, 2)} expanded={Boolean(!result)} />
|
||||
{result && <ExpandableContent label='Result' content={JSON.stringify(result.content, null, 2)} expanded={true} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MockToolCall({
|
||||
toolCall,
|
||||
result: availableResult,
|
||||
handleResult,
|
||||
projectId,
|
||||
messages,
|
||||
sender,
|
||||
}: {
|
||||
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
|
||||
result: z.infer<typeof apiV1.ToolMessage> | undefined;
|
||||
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
|
||||
projectId: string;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
sender: string | undefined;
|
||||
}) {
|
||||
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
|
||||
const [response, setResponse] = useState('');
|
||||
const [generatingResponse, setGeneratingResponse] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
if (response) {
|
||||
return;
|
||||
}
|
||||
let ignore = false;
|
||||
|
||||
function process() {
|
||||
setGeneratingResponse(true);
|
||||
|
||||
suggestToolResponse(toolCall.id, projectId, messages)
|
||||
.then((object) => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setResponse(JSON.stringify(object));
|
||||
})
|
||||
.finally(() => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setGeneratingResponse(false);
|
||||
})
|
||||
}
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [result, response, toolCall.id, projectId, messages]);
|
||||
|
||||
function handleSubmit() {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(response);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON');
|
||||
return;
|
||||
}
|
||||
const result: z.infer<typeof apiV1.ToolMessage> = {
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: toolCall.function.name,
|
||||
content: JSON.stringify(parsed),
|
||||
};
|
||||
setResult(result);
|
||||
handleResult(result);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
|
||||
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
|
||||
<div className='shrink-0 flex gap-2 items-center'>
|
||||
{!result && <Spinner />}
|
||||
|
||||
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
|
||||
</svg>}
|
||||
|
||||
<div className='font-semibold'>
|
||||
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ExpandableContent label='Arguments' content={JSON.stringify(toolCall.function.arguments, null, 2)} expanded={Boolean(!result)} />
|
||||
{result && <ExpandableContent label='Result' content={JSON.stringify(result.content, null, 2)} expanded={true} />}
|
||||
</div>
|
||||
{!result && <div className='flex flex-col gap-2 mt-2'>
|
||||
<div>Response:</div>
|
||||
<Textarea
|
||||
maxRows={10}
|
||||
placeholder='{}'
|
||||
variant="bordered"
|
||||
value={response}
|
||||
disabled={generatingResponse}
|
||||
onValueChange={(value) => setResponse(value)}
|
||||
className='font-mono'
|
||||
>
|
||||
</Textarea>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={generatingResponse}
|
||||
isLoading={generatingResponse}
|
||||
>
|
||||
Submit result
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ExpandableContent({
|
||||
label,
|
||||
content,
|
||||
expanded = false
|
||||
}: {
|
||||
label: string,
|
||||
content: string
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(expanded);
|
||||
|
||||
function toggleExpanded() {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
<div className='flex gap-2 items-start cursor-pointer' onClick={toggleExpanded}>
|
||||
{!isExpanded && <svg className="mt-1 w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 7.757v8.486M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>}
|
||||
{isExpanded && <svg className="mt-1 w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>}
|
||||
<div className='text-left break-all'>{label}</div>
|
||||
</div>
|
||||
{isExpanded && <div className='text-sm font-mono bg-gray-100 p-2 rounded break-all'>
|
||||
{content}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SystemMessage({
|
||||
content,
|
||||
onChange,
|
||||
locked
|
||||
}: {
|
||||
content: string,
|
||||
onChange: (content: string) => void,
|
||||
locked: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-300 p-2 rounded-lg flex flex-col gap-2">
|
||||
<EditableField
|
||||
light
|
||||
label="System message"
|
||||
value={content}
|
||||
onChange={onChange}
|
||||
multiline
|
||||
markdown
|
||||
locked={locked}
|
||||
placeholder={`Use this space to simulate user information provided to the assistant at start of chat. Example:
|
||||
- userName: John Doe
|
||||
- email: john@gmail.com
|
||||
|
||||
This is intended for testing only.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Messages({
|
||||
projectId,
|
||||
systemMessage,
|
||||
messages,
|
||||
toolCallResults,
|
||||
handleToolCallResults,
|
||||
loadingAssistantResponse,
|
||||
loadingUserResponse,
|
||||
workflow,
|
||||
onSystemMessageChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
systemMessage: string | undefined;
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[];
|
||||
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
|
||||
handleToolCallResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
|
||||
loadingAssistantResponse: boolean;
|
||||
loadingUserResponse: boolean;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
onSystemMessageChange: (message: string) => void;
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
let lastUserMessageTimestamp = 0;
|
||||
|
||||
const systemMessageLocked = messages.length > 0;
|
||||
|
||||
// scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, loadingAssistantResponse, loadingUserResponse]);
|
||||
|
||||
return <div className="grow pt-4 overflow-auto">
|
||||
<div className="max-w-[768px] mx-auto flex flex-col gap-8">
|
||||
<SystemMessage
|
||||
content={systemMessage || ''}
|
||||
onChange={onSystemMessageChange}
|
||||
locked={systemMessageLocked}
|
||||
/>
|
||||
{messages.map((message, index) => {
|
||||
if (message.role === 'assistant') {
|
||||
if ('tool_calls' in message) {
|
||||
return <ToolCalls
|
||||
key={index}
|
||||
toolCalls={message.tool_calls}
|
||||
results={toolCallResults}
|
||||
handleResults={handleToolCallResults}
|
||||
projectId={projectId}
|
||||
messages={messages}
|
||||
sender={message.agenticSender}
|
||||
workflow={workflow}
|
||||
/>;
|
||||
} else {
|
||||
// the assistant message createdAt is an ISO string timestamp
|
||||
const latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
|
||||
if (message.agenticResponseType === 'internal') {
|
||||
return (
|
||||
<InternalAssistantMessage
|
||||
key={index}
|
||||
content={message.content}
|
||||
sender={message.agenticSender}
|
||||
latency={latency}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={index}
|
||||
content={message.content}
|
||||
sender={message.agenticSender}
|
||||
latency={latency}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.role === 'user' && typeof message.content === 'string') {
|
||||
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
|
||||
return <UserMessage key={index} content={message.content} />;
|
||||
}
|
||||
return <></>;
|
||||
})}
|
||||
{loadingAssistantResponse && <AssistantMessageLoading key="assistant-loading" />}
|
||||
{loadingUserResponse && <UserMessageLoading key="user-loading" />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Spinner, Textarea } from "@nextui-org/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getScenarios, createScenario, updateScenario, deleteScenario } from "@/app/actions";
|
||||
import { Scenario, WithStringId } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import { HamburgerIcon } from "@/app/lib/components/icons";
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
|
||||
export function AddScenarioForm({
|
||||
onAdd,
|
||||
}: {
|
||||
onAdd: (name: string, description: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await onAdd(name, description);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Invalid input");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="flex flex-col gap-2 border rounded-lg p-4 shadow-sm">
|
||||
<div className="font-semibold text-gray-500">Add Scenario</div>
|
||||
<Input
|
||||
label="Scenario Name"
|
||||
labelPlacement="outside"
|
||||
value={name}
|
||||
placeholder="Provide a name for the scenario"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
isInvalid={!!error}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Scenario Description"
|
||||
labelPlacement="outside"
|
||||
value={description}
|
||||
placeholder="Describe the test scenario"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
isInvalid={!!error}
|
||||
required
|
||||
/>
|
||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
isLoading={saving}
|
||||
isDisabled={saving}
|
||||
size="sm"
|
||||
className="self-start"
|
||||
variant="bordered"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Add Scenario
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ScenarioList({
|
||||
projectId,
|
||||
onPlay,
|
||||
}: {
|
||||
projectId: string;
|
||||
onPlay: (scenario: z.infer<typeof Scenario>) => void;
|
||||
}) {
|
||||
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof Scenario> & {
|
||||
tmp?: boolean;
|
||||
}>[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tmpScenarioId, setTmpScenarioId] = useState<number>(0);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getScenarios(projectId)
|
||||
.then(setScenarios)
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
async function handleAddScenario(name: string, description: string) {
|
||||
try {
|
||||
const tmpId = 'tmp-' + tmpScenarioId;
|
||||
setTmpScenarioId(tmpScenarioId + 1);
|
||||
setSaving(true);
|
||||
setShowAddForm(false);
|
||||
setScenarios([...scenarios, {
|
||||
_id: tmpId,
|
||||
name,
|
||||
description,
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
tmp: true,
|
||||
}]);
|
||||
const id = await createScenario(projectId, name, description);
|
||||
setScenarios([...scenarios, {
|
||||
_id: id,
|
||||
name,
|
||||
description,
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
tmp: false,
|
||||
}]);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Invalid input");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleEditScenario(scenarioId: string, name: string, description: string) {
|
||||
setSaving(true);
|
||||
setScenarios(scenarios.map(scenario => scenario._id === scenarioId ? { ...scenario, name, description } : scenario));
|
||||
await updateScenario(projectId, scenarioId, name, description);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleDeleteScenario(scenarioId: string) {
|
||||
setSaving(true);
|
||||
setScenarios(scenarios.filter(scenario => scenario._id !== scenarioId));
|
||||
await deleteScenario(projectId, scenarioId);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between gap-2 items-center">
|
||||
<div className="font-semibold text-gray-500">Scenarios</div>
|
||||
{saving && <div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<div className="text-sm text-gray-500">Saving...</div>
|
||||
</div>}
|
||||
{!showAddForm && <Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
>
|
||||
Add Scenario
|
||||
</Button>}
|
||||
</div>
|
||||
{loading && <div className="flex justify-center items-center p-8 gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div className="text-sm text-gray-500">Loading scenarios...</div>
|
||||
</div>}
|
||||
|
||||
{showAddForm && <AddScenarioForm onAdd={handleAddScenario} />}
|
||||
|
||||
{!loading && scenarios.length === 0 && <div className="flex justify-center items-center p-8 gap-2">
|
||||
<div className="text-sm text-gray-500">No scenarios added</div>
|
||||
</div>}
|
||||
|
||||
{scenarios.length > 0 && <div className="flex flex-col gap-2">
|
||||
{scenarios.map((scenario) => (
|
||||
<div key={scenario._id} className="flex justify-between gap-2 border p-2 rounded-md shadow-sm">
|
||||
<div className="flex flex-col gap-1 grow">
|
||||
<EditableField
|
||||
key={'name'}
|
||||
label="Name"
|
||||
placeholder="Scenario Name"
|
||||
value={scenario.name}
|
||||
onChange={(value) => handleEditScenario(scenario._id, value, scenario.description)}
|
||||
locked={scenario.tmp}
|
||||
/>
|
||||
<EditableField
|
||||
key={'description'}
|
||||
label="Description"
|
||||
multiline
|
||||
markdown
|
||||
light
|
||||
placeholder="Scenario Description"
|
||||
value={scenario.description}
|
||||
onChange={(value) => handleEditScenario(scenario._id, scenario.name, value)}
|
||||
locked={scenario.tmp}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-gray-700 font-semibold uppercase"
|
||||
onClick={() => onPlay(scenario)}
|
||||
>
|
||||
Run →
|
||||
</button>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button className="text-gray-300 hover:text-gray-700">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={scenario.tmp ? ['delete'] : ['']}
|
||||
onAction={(key) => {
|
||||
if (key === 'delete') {
|
||||
handleDeleteScenario(scenario._id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
color="danger"
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
'use client';
|
||||
import { Input, Textarea } from "@nextui-org/react";
|
||||
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
|
||||
import { SimulationData } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { scrapeWebpage } from "@/app/actions";
|
||||
import { ScenarioList } from "./scenario-list";
|
||||
|
||||
export function SimulateURLOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
function handleUrlSimulationSubmit(formData: FormData) {
|
||||
const url = formData.get('url') as string;
|
||||
// fetch article content and title
|
||||
scrapeWebpage(url).then((result) => {
|
||||
beginSimulation({
|
||||
articleUrl: url,
|
||||
articleContent: result.content,
|
||||
articleTitle: result.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
return <form action={handleUrlSimulationSubmit} className="flex flex-col gap-2">
|
||||
<div>Use a URL / article link:</div>
|
||||
<input type="hidden" name="projectId" value={projectId} />
|
||||
<Input
|
||||
variant="bordered"
|
||||
placeholder="https://acme.com/articles/product-detiails"
|
||||
name="url"
|
||||
required
|
||||
endContent={<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
|
||||
</svg>,
|
||||
children: "Go"
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
||||
export function SimulateScenarioOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScenarioList
|
||||
projectId={projectId}
|
||||
onPlay={(scenario) => beginSimulation({
|
||||
scenario: scenario.description,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulateChatContextOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
function handleChatContextSimulationSubmit(formData: FormData) {
|
||||
beginSimulation({
|
||||
chatMessages: formData.get('context') as string,
|
||||
});
|
||||
}
|
||||
return <form action={handleChatContextSimulationSubmit} className="flex flex-col gap-2">
|
||||
<div>Use a previous chat context:</div>
|
||||
<input type="hidden" name="projectId" value={projectId} />
|
||||
<Textarea
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
required
|
||||
name="context"
|
||||
placeholder={JSON.stringify([
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hello! How can I help you today?"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello! I need help with..."
|
||||
}
|
||||
], null, 2)}
|
||||
endContent={<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
|
||||
</svg>,
|
||||
children: "Go"
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import { deleteDataSource } from "@/app/actions";
|
||||
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
|
||||
|
||||
export function DeleteSource({
|
||||
projectId,
|
||||
sourceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
}) {
|
||||
function handleDelete() {
|
||||
if (window.confirm('Are you sure you want to delete this data source?')) {
|
||||
deleteDataSource(projectId, sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return <form action={handleDelete}>
|
||||
<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
children: "Delete data source",
|
||||
className: "text-red-800",
|
||||
}}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { dataSourcesCollection } from "@/app/lib/mongodb";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { Metadata } from "next";
|
||||
import { SourcePage } from "./source-page";
|
||||
import { getDataSource } from "@/app/actions";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
projectId: string,
|
||||
sourceId: string
|
||||
}
|
||||
}) {
|
||||
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
'use client';
|
||||
import { DataSource } from "@/app/lib/types";
|
||||
import { PageSection } from "@/app/lib/components/PageSection";
|
||||
import { ToggleSource } from "../toggle-source";
|
||||
import { Link, Spinner } from "@nextui-org/react";
|
||||
import { SourceStatus } from "../source-status";
|
||||
import { DeleteSource } from "./delete";
|
||||
import { Recrawl } from "./web-recrawl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getDataSource, recrawlWebDataSource } from "@/app/actions";
|
||||
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
|
||||
import { z } from "zod";
|
||||
|
||||
function UrlList({ urls }: { urls: string }) {
|
||||
return <pre className="max-w-[450px] border p-1 border-gray-300 rounded overflow-auto min-h-7 max-h-52 text-nowrap">
|
||||
{urls}
|
||||
</pre>;
|
||||
}
|
||||
|
||||
function TableLabel({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return <th className={`font-medium text-gray-800 text-left align-top pr-4 py-4 ${className}`}>{children}</th>;
|
||||
}
|
||||
function TableValue({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return <td className={`align-top py-4 ${className}`}>{children}</td>;
|
||||
}
|
||||
|
||||
export function SourcePage({
|
||||
sourceId,
|
||||
projectId,
|
||||
}: {
|
||||
sourceId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const [source, setSource] = useState<z.infer<typeof DataSource> | null>(null);
|
||||
|
||||
// fetch source daat first time
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function fetchSource() {
|
||||
const source = await getDataSource(projectId, sourceId);
|
||||
if (!ignore) {
|
||||
setSource(source);
|
||||
}
|
||||
}
|
||||
fetchSource();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [projectId, sourceId]);
|
||||
|
||||
// refresh source data every 15 seconds
|
||||
// under certain conditions
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
if (source.status !== 'processing' && source.status !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const updatedSource = await getDataSource(projectId, sourceId);
|
||||
if (!ignore) {
|
||||
setSource(updatedSource);
|
||||
timeout = setTimeout(refresh, 15 * 1000);
|
||||
}
|
||||
}
|
||||
timeout = setTimeout(refresh, 15 * 1000);
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [source, projectId, sourceId]);
|
||||
|
||||
async function handleRefresh() {
|
||||
await recrawlWebDataSource(projectId, sourceId);
|
||||
const updatedSource = await getDataSource(projectId, sourceId);
|
||||
setSource(updatedSource);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return <div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="flex flex-col h-full">
|
||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg">{source.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto py-4">
|
||||
<div className="max-w-[768px] mx-auto">
|
||||
<PageSection title="Details">
|
||||
<table className="table-auto">
|
||||
<tbody>
|
||||
<tr>
|
||||
<TableLabel>Toggle:</TableLabel>
|
||||
<TableValue>
|
||||
<ToggleSource projectId={projectId} sourceId={sourceId} active={source.active} />
|
||||
</TableValue>
|
||||
</tr>
|
||||
<tr>
|
||||
<TableLabel>Type:</TableLabel>
|
||||
<TableValue>
|
||||
{source.data.type === 'crawl' && <div className="flex gap-1 items-center">
|
||||
<DataSourceIcon type="crawl" />
|
||||
<div>Crawl URLs</div>
|
||||
</div>}
|
||||
{source.data.type === 'urls' && <div className="flex gap-1 items-center">
|
||||
<DataSourceIcon type="urls" />
|
||||
<div>Specify URLs</div>
|
||||
</div>}
|
||||
</TableValue>
|
||||
</tr>
|
||||
<tr>
|
||||
<TableLabel>Source:</TableLabel>
|
||||
<TableValue>
|
||||
<SourceStatus status={source.status} projectId={projectId} />
|
||||
</TableValue>
|
||||
</tr>
|
||||
{source.data.type === 'urls' && source.data.missingUrls && <tr>
|
||||
<TableLabel className="text-red-500">Errors:</TableLabel>
|
||||
<TableValue>
|
||||
<div>Some URLs could not be scraped. See the list below.</div>
|
||||
</TableValue>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</PageSection>
|
||||
{source.data.type === 'crawl' && <PageSection title="Crawl details">
|
||||
<table className="table-auto">
|
||||
<tbody>
|
||||
<tr>
|
||||
<TableLabel>Starting URL:</TableLabel>
|
||||
<TableValue>
|
||||
<Link
|
||||
href={source.data.startUrl}
|
||||
target="_blank"
|
||||
showAnchorIcon
|
||||
color="foreground"
|
||||
underline="always"
|
||||
>
|
||||
{source.data.startUrl}
|
||||
</Link>
|
||||
</TableValue>
|
||||
</tr>
|
||||
<tr>
|
||||
<TableLabel>Limit:</TableLabel>
|
||||
<TableValue>
|
||||
{source.data.limit} pages
|
||||
</TableValue>
|
||||
</tr>
|
||||
{source.data.crawledUrls && <tr>
|
||||
<TableLabel>Crawled URLs:</TableLabel>
|
||||
<TableValue>
|
||||
<UrlList urls={source.data.crawledUrls} />
|
||||
</TableValue>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</PageSection>}
|
||||
{source.data.type === 'urls' && <PageSection title="Index details">
|
||||
<table className="table-auto">
|
||||
<tbody>
|
||||
<tr>
|
||||
<TableLabel>Input URLs:</TableLabel>
|
||||
<TableValue>
|
||||
<UrlList urls={source.data.urls.join('\n')} />
|
||||
</TableValue>
|
||||
</tr>
|
||||
{source.data.scrapedUrls && <tr>
|
||||
<TableLabel>Scraped URLs:</TableLabel>
|
||||
<TableValue>
|
||||
<UrlList urls={source.data.scrapedUrls} />
|
||||
</TableValue>
|
||||
</tr>}
|
||||
{source.data.missingUrls && <tr>
|
||||
<TableLabel className="text-red-500">The following URLs could not be scraped:</TableLabel>
|
||||
<TableValue>
|
||||
<UrlList urls={source.data.missingUrls} />
|
||||
</TableValue>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</PageSection>}
|
||||
{(source.status === 'completed' || source.status === 'error') && (source.data.type === 'crawl' || source.data.type === 'urls') && <PageSection title="Refresh">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<p>{source.data.type === 'crawl' ? 'Crawl' : 'Scrape'} the URLs again to fetch updated content:</p>
|
||||
<Recrawl projectId={projectId} sourceId={sourceId} handleRefresh={handleRefresh} />
|
||||
</div>
|
||||
</PageSection>}
|
||||
<PageSection title="Danger zone">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<p>Delete this data source:</p>
|
||||
<DeleteSource projectId={projectId} sourceId={sourceId} />
|
||||
</div>
|
||||
</PageSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import { recrawlWebDataSource } from "@/app/actions";
|
||||
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
|
||||
|
||||
export function Recrawl({
|
||||
projectId,
|
||||
sourceId,
|
||||
handleRefresh,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
handleRefresh: () => void;
|
||||
}) {
|
||||
return <form action={handleRefresh}>
|
||||
<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
startContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M17.651 7.65a7.131 7.131 0 0 0-12.68 3.15M18.001 4v4h-4m-7.652 8.35a7.13 7.13 0 0 0 12.68-3.15M6 20v-4h4" />
|
||||
</svg>,
|
||||
children: "Refresh",
|
||||
}}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
146
apps/rowboat/app/projects/[projectId]/sources/new/form.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'use client';
|
||||
import { Input, Select, SelectItem, Textarea } from "@nextui-org/react"
|
||||
import { useState } from "react";
|
||||
import { createCrawlDataSource, createUrlsDataSource } from "@/app/actions";
|
||||
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
|
||||
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
|
||||
|
||||
export function Form({
|
||||
projectId
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
const [sourceType, setSourceType] = useState("crawl");
|
||||
|
||||
const createCrawlDataSourceWithProjectId = createCrawlDataSource.bind(null, projectId);
|
||||
const createUrlsDataSourceWithProjectId = createUrlsDataSource.bind(null, projectId);
|
||||
|
||||
function handleSourceTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
setSourceType(event.target.value);
|
||||
}
|
||||
|
||||
return <div className="grow overflow-auto py-4">
|
||||
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
|
||||
<Select
|
||||
label="Select type"
|
||||
selectedKeys={[sourceType]}
|
||||
onChange={handleSourceTypeChange}
|
||||
>
|
||||
<SelectItem
|
||||
key="crawl"
|
||||
value="crawl"
|
||||
startContent={<DataSourceIcon type="crawl" />}
|
||||
>
|
||||
Crawl URLs
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key="urls"
|
||||
value="urls"
|
||||
startContent={<DataSourceIcon type="urls" />}
|
||||
>
|
||||
Specify URLs
|
||||
</SelectItem>
|
||||
</Select>
|
||||
{sourceType === "crawl" && <form
|
||||
action={createCrawlDataSourceWithProjectId}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
required
|
||||
type="text"
|
||||
name="url"
|
||||
label="Specify starting URL to crawl"
|
||||
labelPlacement="outside"
|
||||
placeholder="https://example.com"
|
||||
variant="bordered"
|
||||
/>
|
||||
<div className="self-start w-[200px]">
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min={1}
|
||||
max={5000}
|
||||
name="limit"
|
||||
label="Maximum pages to crawl"
|
||||
labelPlacement="outside"
|
||||
placeholder="100"
|
||||
defaultValue={"100"}
|
||||
variant="bordered"
|
||||
/>
|
||||
</div>
|
||||
<div className="self-start">
|
||||
<Input
|
||||
required
|
||||
type="text"
|
||||
name="name"
|
||||
label="Name this data source"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. Help articles"
|
||||
variant="bordered"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p>Note:</p>
|
||||
<ul className="list-disc ml-4">
|
||||
<li>Expect about 5-10 minutes to crawl 100 pages</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
children: "Add data source",
|
||||
className: "self-start",
|
||||
startContent: <svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>,
|
||||
}}
|
||||
/>
|
||||
</form>}
|
||||
|
||||
{sourceType === "urls" && <form
|
||||
action={createUrlsDataSourceWithProjectId}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Textarea
|
||||
required
|
||||
type="text"
|
||||
name="urls"
|
||||
label="Specify URLs (one per line)"
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
labelPlacement="outside"
|
||||
placeholder="https://example.com"
|
||||
variant="bordered"
|
||||
/>
|
||||
<div className="self-start">
|
||||
<Input
|
||||
required
|
||||
type="text"
|
||||
name="name"
|
||||
label="Name this data source"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. Help articles"
|
||||
variant="bordered"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p>Note:</p>
|
||||
<ul className="list-disc ml-4">
|
||||
<li>Expect about 5-10 minutes to scrape 100 pages</li>
|
||||
<li>Only the first 100 URLs will be scraped</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
children: "Add data source",
|
||||
className: "self-start",
|
||||
startContent: <svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>,
|
||||
}}
|
||||
/>
|
||||
</form>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
21
apps/rowboat/app/projects/[projectId]/sources/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Metadata } from "next";
|
||||
import { Form } from "./form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Add data source"
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
return <div className="flex flex-col h-full">
|
||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg">Add data source</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Form projectId={params.projectId} />
|
||||
</div>;
|
||||
}
|
||||
16
apps/rowboat/app/projects/[projectId]/sources/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Metadata } from "next";
|
||||
import { SourcesList } from "./sources-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Data sources",
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
return <SourcesList
|
||||
projectId={params.projectId}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
import { getUpdatedSourceStatus } from "@/app/actions";
|
||||
import { DataSource } from "@/app/lib/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from 'zod';
|
||||
import { SourceStatus } from "./source-status";
|
||||
|
||||
export function SelfUpdatingSourceStatus({
|
||||
projectId,
|
||||
sourceId,
|
||||
initialStatus,
|
||||
compact = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string,
|
||||
initialStatus: z.infer<typeof DataSource>['status'],
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("in effect i'm here")
|
||||
let unmounted = false;
|
||||
if (status !== 'processing' && status !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (unmounted) {
|
||||
return;
|
||||
}
|
||||
if (status !== 'processing' && status !== 'new') {
|
||||
return;
|
||||
}
|
||||
console.log("i'm here")
|
||||
getUpdatedSourceStatus(projectId, sourceId)
|
||||
.then((updatedStatus) => {
|
||||
console.log("updatedStatus", updatedStatus)
|
||||
setStatus(updatedStatus);
|
||||
setTimeout(check, 15 * 1000);
|
||||
});
|
||||
}
|
||||
setTimeout(check, 15 * 1000);
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
});
|
||||
|
||||
return <SourceStatus status={status} compact={compact} projectId={projectId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { DataSource } from "@/app/lib/types";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import { Link } from "@nextui-org/react";
|
||||
import { z } from 'zod';
|
||||
|
||||
export function SourceStatus({
|
||||
status,
|
||||
projectId,
|
||||
compact = false,
|
||||
}: {
|
||||
status: z.infer<typeof DataSource>['status'],
|
||||
projectId: string,
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return <div>
|
||||
{status == 'error' && <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-1 items-center">
|
||||
<svg className="w-[24px] h-[24px] text-red-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>Error</div>
|
||||
</div>
|
||||
{!compact && <div className="text-sm text-gray-400">
|
||||
There was an unexpected error while processing this resource.
|
||||
</div>}
|
||||
</div>}
|
||||
{status == 'processing' && <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Spinner size="sm" />
|
||||
<div className="text-gray-400">
|
||||
Processing…
|
||||
</div>
|
||||
</div>
|
||||
{!compact && <div className="text-sm text-gray-400">
|
||||
This source is being processed. This may take a few minutes.
|
||||
</div>}
|
||||
</div>}
|
||||
{status == 'new' && <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-1 items-center">
|
||||
<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<div>
|
||||
Queued
|
||||
</div>
|
||||
</div>
|
||||
{!compact && <div className="text-sm text-gray-400">
|
||||
This source is waiting to be processed.
|
||||
</div>}
|
||||
</div>}
|
||||
{status === 'completed' && <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-1 items-center">
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>Ready</div>
|
||||
</div>
|
||||
{!compact && <div>
|
||||
This source has been indexed and is ready to use.
|
||||
</div>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
106
apps/rowboat/app/projects/[projectId]/sources/sources-list.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Link, Spinner } from "@nextui-org/react";
|
||||
import { ToggleSource } from "./toggle-source";
|
||||
import { SelfUpdatingSourceStatus } from "./self-updating-source-status";
|
||||
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DataSource, WithStringId } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { listSources } from "@/app/actions";
|
||||
|
||||
export function SourcesList({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
const [sources, setSources] = useState<WithStringId<z.infer<typeof DataSource>>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
const sources = await listSources(projectId);
|
||||
if (!ignore) {
|
||||
setSources(sources);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return <div className="flex flex-col h-full">
|
||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg">Data sources</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
href={`/projects/${projectId}/sources/new`}
|
||||
as={Link}
|
||||
startContent=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>
|
||||
>
|
||||
Add data source
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto py-4">
|
||||
<div className="max-w-[768px] mx-auto">
|
||||
{loading && <div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>}
|
||||
{!loading && !sources.length && <p className="mt-4 text-center">You have not added any data sources.</p>}
|
||||
{!loading && sources.length > 0 && <table className="w-full mt-2">
|
||||
<thead className="pb-1 border-b border-b-gray-100">
|
||||
<tr>
|
||||
<th className="text-sm text-left font-medium text-gray-400">Name</th>
|
||||
<th className="text-sm text-left font-medium text-gray-400">Type</th>
|
||||
<th className="text-sm text-left font-medium text-gray-400">Status</th>
|
||||
<th className="text-sm text-left font-medium text-gray-400"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => {
|
||||
return <tr key={source._id}>
|
||||
<td className="py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/sources/${source._id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
{source.data.type == 'crawl' && <div className="flex gap-1 items-center">
|
||||
<DataSourceIcon type="crawl" />
|
||||
<div>Crawl URLs</div>
|
||||
</div>}
|
||||
{source.data.type == 'urls' && <div className="flex gap-1 items-center">
|
||||
<DataSourceIcon type="urls" />
|
||||
<div>Specify URLs</div>
|
||||
</div>}
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<SelfUpdatingSourceStatus sourceId={source._id} projectId={projectId} initialStatus={source.status} compact={true} />
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<ToggleSource projectId={projectId} sourceId={source._id} active={source.active} compact={true} />
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
import { toggleDataSource } from "@/app/actions";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import { Switch } from "@nextui-org/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ToggleSource({
|
||||
projectId,
|
||||
sourceId,
|
||||
active,
|
||||
compact=false,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
active: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isActive, setIsActive] = useState(active);
|
||||
|
||||
function handleActiveSwitchChange(isSelected: boolean) {
|
||||
setIsActive(isSelected);
|
||||
setLoading(true);
|
||||
toggleDataSource(projectId, sourceId, isSelected)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
size={compact ? 'sm' : 'md'}
|
||||
disabled={loading}
|
||||
isSelected={isActive}
|
||||
onValueChange={handleActiveSwitchChange}
|
||||
>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</Switch>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
{!compact && !isActive && <p className="text-sm text-red-800">This data source will not be used in chats.</p>}
|
||||
</div>;
|
||||
}
|
||||
380
apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"use client";
|
||||
import { AgenticAPITool, DataSource, WithStringId, WorkflowAgent, WorkflowPrompt } from "@/app/lib/types";
|
||||
import { Accordion, AccordionItem, Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Select, SelectItem, Textarea } from "@nextui-org/react";
|
||||
import { z } from "zod";
|
||||
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
|
||||
export function AgentConfig({
|
||||
agent,
|
||||
usedAgentNames,
|
||||
agents,
|
||||
tools,
|
||||
prompts,
|
||||
dataSources,
|
||||
handleUpdate,
|
||||
handleClose,
|
||||
}: {
|
||||
agent: z.infer<typeof WorkflowAgent>,
|
||||
usedAgentNames: Set<string>,
|
||||
agents: z.infer<typeof WorkflowAgent>[],
|
||||
tools: z.infer<typeof AgenticAPITool>[],
|
||||
prompts: z.infer<typeof WorkflowPrompt>[],
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[],
|
||||
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
|
||||
handleClose: () => void,
|
||||
}) {
|
||||
return <Pane title={agent.name} actions={[
|
||||
<ActionButton
|
||||
key="close"
|
||||
onClick={handleClose}
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!agent.locked && (
|
||||
<EditableField
|
||||
key="name"
|
||||
label="Name"
|
||||
value={agent.name}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
name: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter agent name"
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return { valid: false, errorMessage: "Name cannot be empty" };
|
||||
}
|
||||
if (usedAgentNames.has(value)) {
|
||||
return { valid: false, errorMessage: "This name is already taken" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditableField
|
||||
key="description"
|
||||
label="Description"
|
||||
value={agent.description || ""}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
description: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter a description for this agent"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col">
|
||||
<EditableField
|
||||
key="instructions"
|
||||
value={agent.instructions}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
instructions: value
|
||||
});
|
||||
}}
|
||||
markdown
|
||||
label="Instructions"
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col">
|
||||
<EditableField
|
||||
key="examples"
|
||||
value={agent.examples || ""}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
examples: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter examples for this agent"
|
||||
markdown
|
||||
label="Examples"
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="text-sm">Attach prompts:</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{agent.prompts.map((prompt) => (
|
||||
<div key={prompt} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
|
||||
<div>{prompt}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPrompts = agent.prompts.filter((p) => p !== prompt);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
prompts: newPrompts
|
||||
});
|
||||
}}
|
||||
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
>
|
||||
Add prompt
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => handleUpdate({
|
||||
...agent,
|
||||
prompts: [...agent.prompts, key as string]
|
||||
})}>
|
||||
{prompts.filter((prompt) => !agent.prompts.includes(prompt.name)).map((prompt) => (
|
||||
<DropdownItem key={prompt.name}>
|
||||
{prompt.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="text-sm">RAG:</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{agent.ragDataSources?.map((source) => (
|
||||
<div key={source} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<DataSourceIcon type={dataSources.find((ds) => ds._id === source)?.data.type} />
|
||||
<div>{dataSources.find((ds) => ds._id === source)?.name || "Unknown"}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newSources = agent.ragDataSources?.filter((s) => s !== source);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
ragDataSources: newSources
|
||||
});
|
||||
}}
|
||||
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
>
|
||||
Add data source
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => handleUpdate({
|
||||
...agent,
|
||||
ragDataSources: [...(agent.ragDataSources || []), key as string]
|
||||
})}>
|
||||
{dataSources.filter((ds) => !(agent.ragDataSources || []).includes(ds._id)).map((ds) => (
|
||||
<DropdownItem
|
||||
key={ds._id}
|
||||
startContent={<DataSourceIcon type={ds.data.type} />}
|
||||
>
|
||||
{ds.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && <Accordion>
|
||||
<AccordionItem
|
||||
key="rag"
|
||||
isCompact
|
||||
aria-label="Advanced RAG configuration"
|
||||
title="Advanced RAG configuration"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<RadioGroup
|
||||
label="Return type:"
|
||||
orientation="horizontal"
|
||||
value={agent.ragReturnType}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
|
||||
})}
|
||||
>
|
||||
<Radio value="chunks">Chunks</Radio>
|
||||
<Radio value="content">Content</Radio>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
label="No. of matches:"
|
||||
labelPlacement="outside"
|
||||
variant="bordered"
|
||||
value={agent.ragK.toString()}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
ragK: parseInt(value)
|
||||
})}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="text-sm">Tools:</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{agent.tools.map((tool) => (
|
||||
<div key={tool} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
|
||||
<div className="font-mono">{tool}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newTools = agent.tools.filter((t) => t !== tool);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
tools: newTools
|
||||
});
|
||||
}}
|
||||
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
>
|
||||
Add tool
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => handleUpdate({
|
||||
...agent,
|
||||
tools: [...(agent.tools || []), key as string]
|
||||
})}>
|
||||
{tools.filter((tool) => !(agent.tools || []).includes(tool.name)).map((tool) => (
|
||||
<DropdownItem key={tool.name}>
|
||||
<div className="font-mono">{tool.name}</div>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="text-sm">Connected agents:</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{agent.connectedAgents?.map((connectedAgentName) => (
|
||||
<div key={connectedAgentName} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
|
||||
<div>{connectedAgentName}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newAgents = (agent.connectedAgents || []).filter((a) => a !== connectedAgentName);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
connectedAgents: newAgents
|
||||
});
|
||||
}}
|
||||
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
>
|
||||
Connect agent
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => handleUpdate({
|
||||
...agent,
|
||||
connectedAgents: [...(agent.connectedAgents || []), key as string]
|
||||
})}>
|
||||
{agents.filter((a) =>
|
||||
a.name !== agent.name &&
|
||||
!(agent.connectedAgents || []).includes(a.name) &&
|
||||
!a.global
|
||||
).map((a) => (
|
||||
<DropdownItem key={a.name}>
|
||||
<div>{a.name}</div>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<EditableField
|
||||
label="Model:"
|
||||
value={agent.model}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
model: value
|
||||
});
|
||||
}}
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return { valid: false, errorMessage: "Model cannot be empty" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="text-sm">Conversation control after turn:</div>
|
||||
<Select
|
||||
variant="bordered"
|
||||
selectedKeys={[agent.controlType]}
|
||||
size="sm"
|
||||
onSelectionChange={(keys) => handleUpdate({
|
||||
...agent,
|
||||
controlType: keys.currentKey! as z.infer<typeof WorkflowAgent>['controlType']
|
||||
})}
|
||||
className="w-60"
|
||||
>
|
||||
<SelectItem key="retain" value="retain">Retain control</SelectItem>
|
||||
<SelectItem key="relinquish_to_parent" value="relinquish_to_parent">Relinquish to parent</SelectItem>
|
||||
<SelectItem key="relinquish_to_start" value="relinquish_to_start">Relinquish to 'start' agent</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
|
||||
import { WorkflowAgent } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
|
||||
export function AgentsList({
|
||||
agents,
|
||||
handleSelectAgent,
|
||||
handleAddAgent,
|
||||
handleToggleAgent,
|
||||
selectedAgent,
|
||||
handleSetMainAgent,
|
||||
handleDeleteAgent,
|
||||
startAgentName,
|
||||
}: {
|
||||
agents: z.infer<typeof WorkflowAgent>[];
|
||||
handleSelectAgent: (name: string) => void;
|
||||
handleAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
|
||||
handleToggleAgent: (name: string) => void;
|
||||
selectedAgent: string | null;
|
||||
handleSetMainAgent: (name: string) => void;
|
||||
handleDeleteAgent: (name: string) => void;
|
||||
startAgentName: string | null;
|
||||
}) {
|
||||
const selectedAgentRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedAgentIndex = agents.findIndex(agent => agent.name === selectedAgent);
|
||||
if (selectedAgentIndex !== -1 && selectedAgentRef.current) {
|
||||
selectedAgentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [selectedAgent, agents]);
|
||||
|
||||
return <Pane title="Agents" actions={[
|
||||
<ActionButton
|
||||
key="add"
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
onClick={() => handleAddAgent({})}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="overflow-auto flex flex-col justify-start">
|
||||
{agents.map((agent, index) => (
|
||||
<button
|
||||
key={index}
|
||||
ref={selectedAgent === agent.name ? selectedAgentRef : null}
|
||||
onClick={() => handleSelectAgent(agent.name)}
|
||||
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedAgent === agent.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className={`truncate ${agent.disabled ? 'text-gray-400' : ''}`}>{agent.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{startAgentName === agent.name && <div className="text-xs border bg-blue-500 text-white px-2 py-1 rounded-md">Start</div>}
|
||||
<Dropdown key={agent.name}>
|
||||
<DropdownTrigger>
|
||||
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
|
||||
</svg>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={[
|
||||
...(!agent.toggleAble ? ['toggle'] : []),
|
||||
...(agent.locked ? ['delete', 'set-main-agent'] : []),
|
||||
...(startAgentName === agent.name ? ['set-main-agent', 'delete', 'toggle'] : []),
|
||||
]}
|
||||
onAction={(key) => {
|
||||
switch (key) {
|
||||
case 'set-main-agent':
|
||||
handleSetMainAgent(agent.name);
|
||||
break;
|
||||
case 'delete':
|
||||
handleDeleteAgent(agent.name);
|
||||
break;
|
||||
case 'toggle':
|
||||
handleToggleAgent(agent.name);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem key="set-main-agent">Set as start agent</DropdownItem>
|
||||
<DropdownItem key="toggle">{agent.disabled ? 'Enable' : 'Disable'}</DropdownItem>
|
||||
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
111
apps/rowboat/app/projects/[projectId]/workflow/app.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
import { DataSource, Workflow, WithStringId } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { WorkflowEditor } from "./workflow_editor";
|
||||
import { WorkflowSelector } from "./workflow_selector";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow, listSources } from "@/app/actions";
|
||||
|
||||
export function App({
|
||||
projectId,
|
||||
startWithWorkflowId,
|
||||
}: {
|
||||
projectId: string;
|
||||
startWithWorkflowId: string | null;
|
||||
}) {
|
||||
const [selectorKey, setSelectorKey] = useState(0);
|
||||
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
|
||||
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
|
||||
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSelect = useCallback(async (workflowId: string) => {
|
||||
setLoading(true);
|
||||
const workflow = await fetchWorkflow(projectId, workflowId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listSources(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setDataSources(dataSources);
|
||||
setLoading(false);
|
||||
}, [projectId]);
|
||||
|
||||
function handleShowSelector() {
|
||||
// clear the last workflow id from local storage
|
||||
localStorage.removeItem(`lastWorkflowId_${projectId}`);
|
||||
setWorkflow(null);
|
||||
}
|
||||
|
||||
async function handleCreateNewVersion() {
|
||||
setLoading(true);
|
||||
const workflow = await createWorkflow(projectId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listSources(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setDataSources(dataSources);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleCloneVersion(workflowId: string) {
|
||||
setLoading(true);
|
||||
const workflow = await cloneWorkflow(projectId, workflowId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listSources(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setDataSources(dataSources);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// whenever workflow becomes null, increment selectorKey
|
||||
useEffect(() => {
|
||||
if (!workflow) {
|
||||
setSelectorKey(s => s + 1);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
// Add this useEffect for initial load
|
||||
useEffect(() => {
|
||||
// if startWithWorkflowId is provided, use it
|
||||
if (startWithWorkflowId) {
|
||||
handleSelect(startWithWorkflowId);
|
||||
return;
|
||||
}
|
||||
// Check localStorage first, fall back to lastWorkflowId prop
|
||||
const storedWorkflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
|
||||
if (storedWorkflowId) {
|
||||
handleSelect(storedWorkflowId);
|
||||
}
|
||||
}, [handleSelect, projectId, startWithWorkflowId]);
|
||||
|
||||
// if workflow is null, show the selector
|
||||
// else show workflow editor
|
||||
return <>
|
||||
{loading && <div className="flex items-center gap-1">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading workflow...</div>
|
||||
</div>}
|
||||
{!loading && workflow == null && <WorkflowSelector
|
||||
projectId={projectId}
|
||||
key={selectorKey}
|
||||
handleSelect={handleSelect}
|
||||
handleCreateNewVersion={handleCreateNewVersion}
|
||||
/>}
|
||||
{!loading && workflow && (dataSources !== null) && <WorkflowEditor
|
||||
key={workflow._id}
|
||||
workflow={workflow}
|
||||
dataSources={dataSources}
|
||||
publishedWorkflowId={publishedWorkflowId}
|
||||
handleShowSelector={handleShowSelector}
|
||||
handleCloneVersion={handleCloneVersion}
|
||||
/>}
|
||||
</>
|
||||
}
|
||||
497
apps/rowboat/app/projects/[projectId]/workflow/copilot.tsx
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
'use client';
|
||||
import { Button, Textarea } from "@nextui-org/react";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
import { useEffect, useRef, useState, createContext, useContext, useCallback } from "react";
|
||||
import { CopilotAssistantMessage, CopilotMessage, CopilotUserMessage, Workflow, CopilotChatContext, CopilotAssistantMessageActionPart } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { getCopilotResponse } from "@/app/actions";
|
||||
import { Action } from "./copilot_actions";
|
||||
import clsx from "clsx";
|
||||
import { Action as WorkflowDispatch } from "./workflow_editor";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
|
||||
|
||||
const CopilotContext = createContext<{
|
||||
workflow: z.infer<typeof Workflow> | null;
|
||||
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
|
||||
appliedChanges: Record<string, boolean>;
|
||||
}>({ workflow: null, handleApplyChange: () => {}, appliedChanges: {} });
|
||||
|
||||
export function getAppliedChangeKey(messageIndex: number, actionIndex: number, field: string) {
|
||||
return `${messageIndex}-${actionIndex}-${field}`;
|
||||
}
|
||||
|
||||
function AnimatedEllipsis() {
|
||||
const [dots, setDots] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDots(prev => prev === 3 ? 0 : prev + 1);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return <span className="inline-block w-8">{'.'.repeat(dots)}</span>;
|
||||
}
|
||||
|
||||
function ComposeBox({
|
||||
handleUserMessage,
|
||||
messages,
|
||||
}: {
|
||||
handleUserMessage: (prompt: string) => void;
|
||||
messages: z.infer<typeof CopilotMessage>[];
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function handleInput() {
|
||||
const prompt = input.trim();
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
setInput('');
|
||||
|
||||
handleUserMessage(prompt);
|
||||
}
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleInput();
|
||||
}
|
||||
}
|
||||
|
||||
// focus on the input field
|
||||
// only when there is at least one message
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return <Textarea
|
||||
required
|
||||
ref={inputRef}
|
||||
variant="bordered"
|
||||
placeholder="Enter message..."
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className="w-full"
|
||||
endContent={<Button
|
||||
isIconOnly
|
||||
onClick={handleInput}
|
||||
className="bg-gray-100"
|
||||
>
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 6v13m0-13 4 4m-4-4-4 4" />
|
||||
</svg>
|
||||
</Button>}
|
||||
/>
|
||||
}
|
||||
|
||||
function RawJsonResponse({
|
||||
message,
|
||||
}: {
|
||||
message: z.infer<typeof CopilotAssistantMessage>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return <div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="w-4 text-gray-300 hover:text-gray-600"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-rectangle-ellipsis"><rect width="20" height="12" x="2" y="6" rx="2" /><path d="M12 12h.01" /><path d="M17 12h.01" /><path d="M7 12h.01" /></svg>
|
||||
</button>
|
||||
<pre className={clsx("text-sm bg-gray-50 border border-gray-200 rounded-sm p-2 overflow-x-auto", {
|
||||
'hidden': !expanded,
|
||||
})}>
|
||||
{JSON.stringify(message.content, null, 2)}
|
||||
</pre>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
msgIndex,
|
||||
stale,
|
||||
}: {
|
||||
message: z.infer<typeof CopilotAssistantMessage>;
|
||||
msgIndex: number;
|
||||
stale: boolean;
|
||||
}) {
|
||||
const { workflow, handleApplyChange, appliedChanges } = useContext(CopilotContext);
|
||||
if (!workflow) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2 mb-8">
|
||||
<RawJsonResponse message={message} />
|
||||
<div className="flex flex-col gap-3">
|
||||
{message.content.response.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
return <div key={index}>
|
||||
<MarkdownContent content={part.content} />
|
||||
</div>;
|
||||
} else if (part.type === "action") {
|
||||
return <Action
|
||||
key={index}
|
||||
msgIndex={msgIndex}
|
||||
actionIndex={index}
|
||||
action={part.content}
|
||||
workflow={workflow}
|
||||
handleApplyChange={handleApplyChange}
|
||||
appliedChanges={appliedChanges}
|
||||
stale={stale}
|
||||
/>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UserMessage({
|
||||
message,
|
||||
}: {
|
||||
message: z.infer<typeof CopilotUserMessage>;
|
||||
}) {
|
||||
return <div className="bg-gray-50 border border-gray-200 rounded-sm px-2">
|
||||
<MarkdownContent content={message.content} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function App({
|
||||
projectId,
|
||||
workflow,
|
||||
dispatch,
|
||||
chatContext=undefined,
|
||||
}: {
|
||||
projectId: string;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
dispatch: (action: WorkflowDispatch) => void;
|
||||
chatContext?: z.infer<typeof CopilotChatContext>;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
|
||||
const [loadingResponse, setLoadingResponse] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState("Thinking...");
|
||||
const [responseError, setResponseError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
|
||||
const [discardContext, setDiscardContext] = useState(false);
|
||||
|
||||
// Cycle through loading messages until reaching the last one
|
||||
useEffect(() => {
|
||||
setLoadingMessage("Thinking");
|
||||
if (!loadingResponse) return;
|
||||
|
||||
const loadingMessages = [
|
||||
"Thinking",
|
||||
"Planning",
|
||||
"Generating",
|
||||
];
|
||||
let messageIndex = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (messageIndex < loadingMessages.length - 1) {
|
||||
messageIndex++;
|
||||
setLoadingMessage(loadingMessages[messageIndex]);
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadingResponse, messages]);
|
||||
|
||||
// Reset discardContext when chatContext changes
|
||||
useEffect(() => {
|
||||
setDiscardContext(false);
|
||||
}, [chatContext]);
|
||||
|
||||
// Get the effective context based on user preference
|
||||
const effectiveContext = discardContext ? null : chatContext;
|
||||
|
||||
function handleUserMessage(prompt: string) {
|
||||
setMessages([...messages, {
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
}]);
|
||||
}
|
||||
|
||||
const handleApplyChange = useCallback((
|
||||
messageIndex: number,
|
||||
actionIndex: number,
|
||||
field?: string
|
||||
) => {
|
||||
// validate
|
||||
console.log('apply change', messageIndex, actionIndex, field);
|
||||
const msg = messages[messageIndex];
|
||||
if (!msg) {
|
||||
console.log('no message');
|
||||
return;
|
||||
}
|
||||
if (msg.role !== 'assistant') {
|
||||
console.log('not assistant');
|
||||
return;
|
||||
}
|
||||
const action = msg.content.response[actionIndex].content as z.infer<typeof CopilotAssistantMessageActionPart>['content'];
|
||||
if (!action) {
|
||||
console.log('no action');
|
||||
return;
|
||||
}
|
||||
console.log('reached here');
|
||||
|
||||
if (action.action === 'create_new') {
|
||||
switch (action.config_type) {
|
||||
case 'agent':
|
||||
dispatch({
|
||||
type: 'add_agent',
|
||||
agent: {
|
||||
name: action.name,
|
||||
...action.config_changes
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'tool':
|
||||
dispatch({
|
||||
type: 'add_tool',
|
||||
tool: {
|
||||
name: action.name,
|
||||
...action.config_changes
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'prompt':
|
||||
dispatch({
|
||||
type: 'add_prompt',
|
||||
prompt: {
|
||||
name: action.name,
|
||||
...action.config_changes
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
|
||||
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setAppliedChanges({
|
||||
...appliedChanges,
|
||||
...appliedKeys,
|
||||
});
|
||||
} else if (action.action === 'edit') {
|
||||
const changes = field
|
||||
? { [field]: action.config_changes[field] }
|
||||
: action.config_changes;
|
||||
|
||||
switch (action.config_type) {
|
||||
case 'agent':
|
||||
dispatch({
|
||||
type: 'update_agent',
|
||||
name: action.name,
|
||||
agent: changes
|
||||
});
|
||||
break;
|
||||
case 'tool':
|
||||
dispatch({
|
||||
type: 'update_tool',
|
||||
name: action.name,
|
||||
tool: changes
|
||||
});
|
||||
break;
|
||||
case 'prompt':
|
||||
dispatch({
|
||||
type: 'update_prompt',
|
||||
name: action.name,
|
||||
prompt: changes
|
||||
});
|
||||
break;
|
||||
}
|
||||
const appliedKeys = Object.keys(changes).reduce((acc, key) => {
|
||||
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setAppliedChanges({
|
||||
...appliedChanges,
|
||||
...appliedKeys,
|
||||
});
|
||||
}
|
||||
}, [dispatch, appliedChanges, messages]);
|
||||
|
||||
// get copilot response
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function process() {
|
||||
setLoadingResponse(true);
|
||||
setResponseError(null);
|
||||
|
||||
try {
|
||||
const copilotMessage = await getCopilotResponse(
|
||||
projectId,
|
||||
messages,
|
||||
workflow,
|
||||
effectiveContext || null,
|
||||
);
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setMessages([...messages, copilotMessage]);
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setResponseError(`Failed to get copilot response: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
} finally {
|
||||
setLoadingResponse(false);
|
||||
}
|
||||
}
|
||||
|
||||
// if no messages, return
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if last message is not from role user
|
||||
// or tool, return
|
||||
const last = messages[messages.length - 1];
|
||||
if (responseError) {
|
||||
return;
|
||||
}
|
||||
if (last.role !== 'user') {
|
||||
return;
|
||||
}
|
||||
|
||||
process();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [messages, projectId, responseError, workflow, effectiveContext]);
|
||||
|
||||
// scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, loadingResponse]);
|
||||
|
||||
return <div className="h-full flex flex-col">
|
||||
<CopilotContext.Provider value={{ workflow, handleApplyChange, appliedChanges }}>
|
||||
<div className="grow flex flex-col gap-2 overflow-auto px-2">
|
||||
{messages.map((m, index) => {
|
||||
// Calculate if this assistant message is stale
|
||||
const isStale = m.role === 'assistant' && messages.slice(index + 1).some(
|
||||
laterMsg => laterMsg.role === 'assistant' &&
|
||||
'response' in laterMsg.content &&
|
||||
laterMsg.content.response.filter(part => part.type === 'action').length > 0
|
||||
);
|
||||
|
||||
return <>
|
||||
{m.role === 'user' && (
|
||||
<UserMessage
|
||||
key={index}
|
||||
message={m}
|
||||
/>
|
||||
)}
|
||||
{m.role === 'assistant' && (
|
||||
<AssistantMessage
|
||||
key={index}
|
||||
message={m}
|
||||
msgIndex={index}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
</>;
|
||||
})}
|
||||
{loadingResponse && <div className="p-2 flex items-center animate-pulse text-gray-600">
|
||||
<div>
|
||||
{loadingMessage}
|
||||
</div>
|
||||
<AnimatedEllipsis />
|
||||
</div>}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{responseError && (
|
||||
<div className="max-w-[768px] mx-auto mb-4 p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center">
|
||||
<p className="text-red-600">{responseError}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
setResponseError(null);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{effectiveContext && <div className="flex items-start">
|
||||
<div className="flex items-center gap-1 bg-gray-100 text-sm px-2 py-1 rounded-sm shadow-sm mb-2">
|
||||
<div>
|
||||
{effectiveContext.type === 'chat' && "Chat"}
|
||||
{effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`}
|
||||
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
|
||||
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-600"
|
||||
onClick={() => setDiscardContext(true)}
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
<ComposeBox
|
||||
handleUserMessage={handleUserMessage}
|
||||
messages={messages}
|
||||
/>
|
||||
</div>
|
||||
</CopilotContext.Provider>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function Copilot({
|
||||
projectId,
|
||||
workflow,
|
||||
chatContext=undefined,
|
||||
dispatch,
|
||||
}: {
|
||||
projectId: string;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
chatContext?: z.infer<typeof CopilotChatContext>;
|
||||
dispatch: (action: WorkflowDispatch) => void;
|
||||
}) {
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
function handleNewChat() {
|
||||
setKey(key + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pane fancy title="Copilot" actions={[
|
||||
<ActionButton
|
||||
key="ask"
|
||||
primary
|
||||
icon={
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
|
||||
</svg>
|
||||
}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
Ask
|
||||
</ActionButton>
|
||||
]}>
|
||||
<App
|
||||
key={key}
|
||||
projectId={projectId}
|
||||
workflow={workflow}
|
||||
dispatch={dispatch}
|
||||
chatContext={chatContext}
|
||||
|
||||
/>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
'use client';
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { z } from "zod";
|
||||
import { Workflow, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/app/lib/types";
|
||||
import { PreviewModalProvider, usePreviewModal } from './preview-modal';
|
||||
import { getAppliedChangeKey } from "./copilot";
|
||||
|
||||
const ActionContext = createContext<{
|
||||
msgIndex: number;
|
||||
actionIndex: number;
|
||||
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'] | null;
|
||||
workflow: z.infer<typeof Workflow> | null;
|
||||
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
|
||||
appliedFields: string[];
|
||||
stale: boolean;
|
||||
}>({ msgIndex: 0, actionIndex: 0, action: null, workflow: null, handleApplyChange: () => {}, appliedFields: [], stale: false });
|
||||
|
||||
export function Action({
|
||||
msgIndex,
|
||||
actionIndex,
|
||||
action,
|
||||
workflow,
|
||||
handleApplyChange,
|
||||
appliedChanges,
|
||||
stale,
|
||||
}: {
|
||||
msgIndex: number;
|
||||
actionIndex: number;
|
||||
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
|
||||
appliedChanges: Record<string, boolean>;
|
||||
stale: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(Object.entries(action.config_changes).length <= 2);
|
||||
const changes = Object.entries(action.config_changes).slice(0, expanded ? undefined : 2);
|
||||
|
||||
// determine whether all changes contained in this action are applied
|
||||
const appliedFields = Object.keys(action.config_changes).filter(key => appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]);
|
||||
console.log('appliedFields', appliedFields);
|
||||
|
||||
return <div className={clsx('flex flex-col rounded-sm border shadow-sm', {
|
||||
'bg-blue-50 border-blue-200': action.action === 'create_new',
|
||||
'bg-amber-50 border-amber-200': action.action === 'edit',
|
||||
'bg-gray-50 border-gray-200': stale,
|
||||
})}>
|
||||
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale }}>
|
||||
<ActionHeader />
|
||||
<PreviewModalProvider>
|
||||
<ActionBody>
|
||||
{changes.map(([key, value]) => {
|
||||
return <ActionField key={key} field={key} />
|
||||
})}
|
||||
</ActionBody>
|
||||
</PreviewModalProvider>
|
||||
{Object.entries(action.config_changes).length > 2 && <button className={clsx('flex rounded-b-sm flex-col items-center justify-center', {
|
||||
'bg-blue-100 hover:bg-blue-200 text-blue-600': action.action === 'create_new',
|
||||
'bg-amber-100 hover:bg-amber-200 text-amber-600': action.action === 'edit',
|
||||
'bg-gray-100 hover:bg-gray-200 text-gray-600': stale,
|
||||
})} onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-chevrons-up"><path d="m17 11-5-5-5 5" /><path d="m17 18-5-5-5 5" /></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-chevrons-down"><path d="m7 6 5 5 5-5" /><path d="m7 13 5 5 5-5" /></svg>
|
||||
)}
|
||||
</button>}
|
||||
</ActionContext.Provider>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ActionHeader() {
|
||||
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
|
||||
if (!action || !workflow) return null;
|
||||
|
||||
const targetType = action.config_type === 'tool' ? 'tool' : action.config_type === 'agent' ? 'agent' : 'prompt';
|
||||
const change = action.action === 'create_new' ? 'Create' : 'Edit';
|
||||
|
||||
// determine whether all changes contained in this action are applied
|
||||
const allApplied = Object.keys(action.config_changes).every(key => appliedFields.includes(key));
|
||||
|
||||
// generate apply change function
|
||||
const applyChangeHandler = () => {
|
||||
handleApplyChange(msgIndex, actionIndex);
|
||||
}
|
||||
|
||||
return <div className={clsx('flex justify-between items-center px-2 py-1 rounded-t-sm', {
|
||||
'bg-blue-100': action.action === 'create_new',
|
||||
'bg-amber-100': action.action === 'edit',
|
||||
'bg-gray-100': stale,
|
||||
})}>
|
||||
<div className={clsx('text-sm truncate', {
|
||||
'text-blue-600': action.action === 'create_new',
|
||||
'text-amber-600': action.action === 'edit',
|
||||
'text-gray-600': stale,
|
||||
})}>{`${change} ${targetType}`}: <span className="font-medium">{action.name}</span></div>
|
||||
<button className={clsx('flex gap-1 items-center text-sm hover:text-black', {
|
||||
'text-blue-600': action.action === 'create_new',
|
||||
'text-amber-600': action.action === 'edit',
|
||||
'text-green-600': allApplied,
|
||||
'text-gray-600': stale,
|
||||
})}
|
||||
onClick={applyChangeHandler}
|
||||
disabled={stale || allApplied}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-check-check"><path d="M18 6 7 17l-5-5" /><path d="m22 10-7.5 7.5L13 16" /></svg>
|
||||
{!allApplied && <div className="font-medium">Apply</div>}
|
||||
</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ActionBody({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-2 p-2">{children}</div>;
|
||||
}
|
||||
|
||||
export function ActionField({
|
||||
field,
|
||||
}: {
|
||||
field: string;
|
||||
}) {
|
||||
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
|
||||
const { showPreview } = usePreviewModal();
|
||||
if (!action || !workflow) return null;
|
||||
|
||||
// determine whether this field is applied
|
||||
const applied = appliedFields.includes(field);
|
||||
|
||||
const newValue = action.config_changes[field];
|
||||
// Get the old value if this is an edit action
|
||||
let oldValue = undefined;
|
||||
if (action.action === 'edit') {
|
||||
if (action.config_type === 'tool') {
|
||||
// Find the tool in the workflow
|
||||
const tool = workflow.tools.find(t => t.name === action.name);
|
||||
if (tool) {
|
||||
oldValue = tool[field as keyof typeof tool];
|
||||
}
|
||||
} else if (action.config_type === 'agent') {
|
||||
// Find the agent in the workflow
|
||||
const agent = workflow.agents.find(a => a.name === action.name);
|
||||
if (agent) {
|
||||
oldValue = agent[field as keyof typeof agent];
|
||||
}
|
||||
} else if (action.config_type === 'prompt') {
|
||||
// Find the prompt in the workflow
|
||||
const prompt = workflow.prompts.find(p => p.name === action.name);
|
||||
if (prompt) {
|
||||
oldValue = prompt[field as keyof typeof prompt];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if edit type of action, preview is enabled
|
||||
const previewCondition = action.action === 'edit' ||
|
||||
(action.config_type === 'agent' && field === 'instructions');
|
||||
|
||||
// enable markdown preview for some fields
|
||||
const markdownPreviewCondition = (action.config_type === 'agent' && field === 'instructions') ||
|
||||
(action.config_type === 'agent' && field === 'examples') ||
|
||||
(action.config_type === 'prompt' && field === 'prompt') ||
|
||||
(action.config_type === 'tool' && field === 'description');
|
||||
|
||||
// generate preview modal function
|
||||
const previewModalHandler = () => {
|
||||
if (previewCondition) {
|
||||
showPreview(
|
||||
oldValue ? (typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue)) : undefined,
|
||||
(typeof newValue === 'string' ? newValue : JSON.stringify(newValue)),
|
||||
markdownPreviewCondition,
|
||||
`${action.name} - ${field}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// generate apply change function
|
||||
const applyChangeHandler = () => {
|
||||
handleApplyChange(msgIndex, actionIndex, field);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col bg-white rounded-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className={clsx('text-xs font-semibold px-2 py-1', {
|
||||
'text-blue-600': action.action === 'create_new',
|
||||
'text-amber-600': action.action === 'edit',
|
||||
'text-gray-600': stale,
|
||||
})}>{field}</div>
|
||||
{previewCondition && <div className="flex gap-4 items-center bg-gray-50 rounded-bl-sm rounded-tr-sm px-2 py-1">
|
||||
<button
|
||||
className="text-gray-500 hover:text-black"
|
||||
onClick={previewModalHandler}
|
||||
>
|
||||
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeWidth="1.5" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z" />
|
||||
<path stroke="currentColor" strokeWidth="1.5" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{action.action === 'edit' && <button
|
||||
className={clsx("text-gray-500 hover:text-black", {
|
||||
'text-green-600': applied,
|
||||
'text-gray-600': stale,
|
||||
})}
|
||||
onClick={applyChangeHandler}
|
||||
disabled={stale || applied}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-check"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</button>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="px-2 pb-1">
|
||||
<div className="text-sm italic truncate">
|
||||
{JSON.stringify(newValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
// function ActionToolParamsView({
|
||||
// params,
|
||||
// }: {
|
||||
// params: z.infer<typeof Workflow>['tools'][number]['parameters'];
|
||||
// }) {
|
||||
// const required = params?.required || [];
|
||||
|
||||
// return <ActionField label="parameters">
|
||||
// <div className="flex flex-col gap-2 text-sm">
|
||||
// {Object.entries(params?.properties || {}).map(([paramName, paramConfig]) => {
|
||||
// return <div className="flex flex-col gap-1">
|
||||
// <div className="flex gap-1 items-center">
|
||||
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
|
||||
// </svg>
|
||||
// <div>{paramName}{required.includes(paramName) && <sup>*</sup>}</div>
|
||||
// <div className="text-gray-500">{paramConfig.type}</div>
|
||||
// </div>
|
||||
// <div className="flex gap-1 ml-4">
|
||||
// <div className="text-gray-500 italic">{paramConfig.description}</div>
|
||||
// </div>
|
||||
// </div>;
|
||||
// })}
|
||||
// </div>
|
||||
// </ActionField>;
|
||||
// }
|
||||
|
||||
// function ActionAgentToolsView({
|
||||
// action,
|
||||
// tools,
|
||||
// }: {
|
||||
// action: z.infer<typeof CopilotAssistantMessage>['content']['Actions'][number];
|
||||
// tools: z.infer<typeof Workflow>['agents'][number]['tools'];
|
||||
// }) {
|
||||
// const { workflow } = useContext(CopilotContext);
|
||||
// if (!workflow) {
|
||||
// return <></>;
|
||||
// }
|
||||
|
||||
// // find the agent in the workflow
|
||||
// const agent = workflow.agents.find((agent) => agent.name === action.name);
|
||||
// if (!agent) {
|
||||
// return <></>;
|
||||
// }
|
||||
|
||||
// // find the tools that were removed
|
||||
// const removedTools = agent.tools.filter((tool) => !tools.includes(tool));
|
||||
|
||||
// return <ActionField label="tools">
|
||||
// {removedTools.length > 0 && <div className="flex flex-col gap-1 text-sm">
|
||||
// <div className="text-gray-500 italic">The following tools were removed:</div>
|
||||
// <div className="flex flex-col gap-1">
|
||||
// {removedTools.map((tool) => {
|
||||
// return <div className="flex gap-1 items-center">
|
||||
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
|
||||
// </svg>
|
||||
// <div>{tool}</div>
|
||||
// </div>;
|
||||
// })}
|
||||
// </div>
|
||||
// </div>}
|
||||
// <div className="flex flex-col gap-1 text-sm">
|
||||
// <div className="text-gray-500 italic">The following tools were added:</div>
|
||||
// <div className="flex flex-col gap-1">
|
||||
// {tools.map((tool) => {
|
||||
// return <div className="flex gap-1 items-center">
|
||||
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
|
||||
// </svg>
|
||||
// <div>{tool}</div>
|
||||
// </div>;
|
||||
// })}
|
||||
// </div>
|
||||
// </div>
|
||||
// </ActionField>;
|
||||
// }
|
||||
51
apps/rowboat/app/projects/[projectId]/workflow/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Metadata } from "next";
|
||||
import { agentWorkflowsCollection, dataSourcesCollection, projectsCollection } from "@/app/lib/mongodb";
|
||||
import { App } from "./app";
|
||||
import { baseWorkflow } from "@/app/lib/utils";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workflow"
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
let startWithWorkflowId = null;
|
||||
const count = await agentWorkflowsCollection.countDocuments({
|
||||
projectId: params.projectId,
|
||||
});
|
||||
if (count === 0) {
|
||||
// get the next workflow number
|
||||
const doc = await projectsCollection.findOneAndUpdate({
|
||||
_id: params.projectId,
|
||||
}, {
|
||||
$inc: {
|
||||
nextWorkflowNumber: 1,
|
||||
},
|
||||
}, {
|
||||
returnDocument: 'after'
|
||||
});
|
||||
if (!doc) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const nextWorkflowNumber = doc.nextWorkflowNumber;
|
||||
|
||||
// create the workflow
|
||||
const workflow = {
|
||||
...baseWorkflow,
|
||||
projectId: params.projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
name: `Version ${nextWorkflowNumber}`,
|
||||
};
|
||||
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
|
||||
startWithWorkflowId = insertedId.toString();
|
||||
}
|
||||
|
||||
return <App
|
||||
projectId={params.projectId}
|
||||
startWithWorkflowId={startWithWorkflowId}
|
||||
/>;
|
||||
}
|
||||
48
apps/rowboat/app/projects/[projectId]/workflow/pane.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export function Pane({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
fancy = false,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
actions: React.ReactNode[];
|
||||
children: React.ReactNode;
|
||||
fancy?: boolean;
|
||||
}) {
|
||||
return <div className={`h-full flex flex-col overflow-auto border rounded-md ${fancy ? 'border-blue-200' : 'border-gray-200'}`}>
|
||||
<div className={`shrink-0 flex justify-between items-center gap-2 px-2 py-1 bg-gray-50 rounded-t-md ${fancy ? 'bg-blue-50' : ''}`}>
|
||||
<div className={`text-sm ${fancy ? 'text-blue-600' : 'text-gray-600'} uppercase font-semibold`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="rounded-md hover:text-gray-800 px-2 py-1 text-gray-600 text-sm flex items-center gap-1">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto flex flex-col justify-start p-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
icon = null,
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
primary = false,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return <button
|
||||
disabled={disabled}
|
||||
className={`rounded-md hover:text-gray-800 px-2 py-1 ${primary ? 'text-blue-600' : 'text-gray-600'} text-sm flex items-center gap-1 disabled:text-gray-300`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>;
|
||||
}
|
||||
144
apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
|
||||
|
||||
// Create the context type
|
||||
export type PreviewModalContextType = {
|
||||
showPreview: (
|
||||
oldValue: string | undefined,
|
||||
newValue: string,
|
||||
markdown: boolean,
|
||||
title: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Create the context
|
||||
export const PreviewModalContext = createContext<PreviewModalContextType>({
|
||||
showPreview: () => {}
|
||||
});
|
||||
|
||||
// Export the hook for easy usage
|
||||
export const usePreviewModal = () => useContext(PreviewModalContext);
|
||||
|
||||
// Create the provider component
|
||||
export function PreviewModalProvider({ children }: { children: React.ReactNode }) {
|
||||
const [modalProps, setModalProps] = useState<{
|
||||
oldValue?: string;
|
||||
newValue: string;
|
||||
markdown: boolean;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
}>({
|
||||
newValue: '',
|
||||
markdown: false,
|
||||
title: '',
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
// Handle Esc key
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setModalProps(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
const showPreview = (oldValue: string | undefined, newValue: string, markdown: boolean, title: string) => {
|
||||
setModalProps({ oldValue, newValue, markdown, title, isOpen: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<PreviewModalContext.Provider value={{ showPreview }}>
|
||||
{children}
|
||||
{modalProps.isOpen && (
|
||||
<PreviewModal
|
||||
{...modalProps}
|
||||
onClose={() => setModalProps(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
)}
|
||||
</PreviewModalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// The modal component
|
||||
function PreviewModal({
|
||||
oldValue = undefined,
|
||||
newValue,
|
||||
markdown = false,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
oldValue?: string | undefined;
|
||||
newValue: string;
|
||||
markdown?: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const buttonLabel = oldValue === undefined ? 'Preview' : 'Diff';
|
||||
const [view, setView] = useState<'preview' | 'markdown'>('preview');
|
||||
console.log(oldValue, newValue);
|
||||
|
||||
return <div className="fixed left-0 top-0 w-full h-full bg-gray-500/50 backdrop-blur-sm flex justify-center items-center z-50">
|
||||
<div className="bg-gray-100 rounded-md p-2 flex flex-col w-[90%] h-[90%] max-w-7xl max-h-[800px]">
|
||||
<button className="self-end text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>
|
||||
<div className="text-sm">Close</div>
|
||||
</button>
|
||||
<div className="flex flex-col overflow-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-md font-semibold">{title}</div>
|
||||
<div className="flex items-center">
|
||||
<button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
|
||||
'bg-white': view === 'preview',
|
||||
})} onClick={() => setView('preview')}>{buttonLabel}</button>
|
||||
{markdown && <button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
|
||||
'bg-white': view === 'markdown',
|
||||
})} onClick={() => setView('markdown')}>Markdown</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md grow overflow-auto">
|
||||
<div className="h-full flex flex-col overflow-auto">
|
||||
{view === 'preview' && <div className="flex gap-1 overflow-auto text-sm">
|
||||
{oldValue !== undefined && <ReactDiffViewer
|
||||
oldValue={oldValue}
|
||||
newValue={newValue}
|
||||
splitView={true}
|
||||
compareMethod={DiffMethod.WORDS_WITH_SPACE}
|
||||
/>}
|
||||
{oldValue === undefined && <pre className="p-2 overflow-auto">{newValue}</pre>}
|
||||
</div>}
|
||||
{view === 'markdown' && <div className="flex gap-1">
|
||||
{oldValue !== undefined && <div className="w-1/2 flex flex-col border-r-2 border-gray-200 overflow-auto">
|
||||
<div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b-1 border-gray-200">Old</div>
|
||||
<div className="p-2 overflow-auto">
|
||||
<MarkdownContent
|
||||
content={oldValue}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<div className={clsx("flex flex-col", {
|
||||
'w-1/2': oldValue !== undefined
|
||||
})}>
|
||||
{oldValue !== undefined && <div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b-1 border-gray-200">New</div>}
|
||||
<div className="p-2 overflow-auto">
|
||||
<MarkdownContent
|
||||
content={newValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { WorkflowPrompt } from "@/app/lib/types";
|
||||
import { Input, Textarea } from "@nextui-org/react";
|
||||
import { z } from "zod";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
|
||||
export function PromptConfig({
|
||||
prompt,
|
||||
usedPromptNames,
|
||||
handleUpdate,
|
||||
handleClose,
|
||||
}: {
|
||||
prompt: z.infer<typeof WorkflowPrompt>,
|
||||
usedPromptNames: Set<string>,
|
||||
handleUpdate: (prompt: z.infer<typeof WorkflowPrompt>) => void,
|
||||
handleClose: () => void,
|
||||
}) {
|
||||
return <Pane title={prompt.name} actions={[
|
||||
<ActionButton
|
||||
key="close"
|
||||
onClick={handleClose}
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{prompt.type === "base_prompt" && (
|
||||
<EditableField
|
||||
label="Name"
|
||||
value={prompt.name}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...prompt,
|
||||
name: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter prompt name"
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return { valid: false, errorMessage: "Name cannot be empty" };
|
||||
}
|
||||
if (usedPromptNames.has(value)) {
|
||||
return { valid: false, errorMessage: "This name is already taken" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditableField
|
||||
value={prompt.prompt}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...prompt,
|
||||
prompt: value
|
||||
});
|
||||
}}
|
||||
placeholder="Edit prompt here..."
|
||||
markdown
|
||||
label="Prompt"
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from "zod";
|
||||
import { WorkflowPrompt } from "@/app/lib/types";
|
||||
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
|
||||
export function PromptsList({
|
||||
prompts,
|
||||
handleSelectPrompt,
|
||||
handleAddPrompt,
|
||||
selectedPrompt,
|
||||
handleDeletePrompt,
|
||||
}: {
|
||||
prompts: z.infer<typeof WorkflowPrompt>[];
|
||||
handleSelectPrompt: (name: string) => void;
|
||||
handleAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
|
||||
selectedPrompt: string | null;
|
||||
handleDeletePrompt: (name: string) => void;
|
||||
}) {
|
||||
const selectedPromptRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedPromptIndex = prompts.findIndex(prompt => prompt.name === selectedPrompt);
|
||||
if (selectedPromptIndex !== -1 && selectedPromptRef.current) {
|
||||
selectedPromptRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [selectedPrompt, prompts]);
|
||||
|
||||
return <Pane title="Prompts" actions={[
|
||||
<ActionButton
|
||||
key="add"
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
onClick={() => handleAddPrompt({})}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="overflow-auto flex flex-col justify-start">
|
||||
{prompts.map((prompt, index) => (
|
||||
<button
|
||||
key={index}
|
||||
ref={selectedPrompt === prompt.name ? selectedPromptRef : null}
|
||||
onClick={() => handleSelectPrompt(prompt.name)}
|
||||
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedPrompt === prompt.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{prompt.type === 'style_prompt' && <svg className="w-5 h-5 text-gray-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="1" d="M20 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6h-2m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4" />
|
||||
</svg>}
|
||||
<div className="truncate">{prompt.name}</div>
|
||||
</div>
|
||||
<Dropdown key={prompt.name}>
|
||||
<DropdownTrigger>
|
||||
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
|
||||
</svg>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
onAction={(key) => {
|
||||
if (key === 'delete') {
|
||||
handleDeletePrompt(prompt.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { RadioIcon } from "lucide-react";
|
||||
|
||||
export function PublishedBadge() {
|
||||
return (
|
||||
<div className="bg-green-500/10 rounded-md px-2 py-1 flex items-center gap-1">
|
||||
<RadioIcon size={16} className="text-green-500" />
|
||||
<div className="text-green-500 text-xs font-medium uppercase">Live</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
apps/rowboat/app/projects/[projectId]/workflow/tool_config.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
import { WorkflowTool } from "@/app/lib/types";
|
||||
import { Button, Select, SelectItem, Switch } from "@nextui-org/react";
|
||||
import { z } from "zod";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
|
||||
export function ToolConfig({
|
||||
tool,
|
||||
usedToolNames,
|
||||
handleUpdate,
|
||||
handleClose
|
||||
}: {
|
||||
tool: z.infer<typeof WorkflowTool>,
|
||||
usedToolNames: Set<string>,
|
||||
handleUpdate: (tool: z.infer<typeof WorkflowTool>) => void,
|
||||
handleClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Pane title={tool.name} actions={[
|
||||
<ActionButton
|
||||
key="close"
|
||||
onClick={handleClose}
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
|
||||
</svg>}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<EditableField
|
||||
label="Name"
|
||||
value={tool.name}
|
||||
onChange={(value) => handleUpdate({
|
||||
...tool,
|
||||
name: value
|
||||
})}
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return { valid: false, errorMessage: "Name cannot be empty" };
|
||||
}
|
||||
if (usedToolNames.has(value)) {
|
||||
return { valid: false, errorMessage: "Tool name already exists" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={tool.description}
|
||||
onChange={(value) => handleUpdate({
|
||||
...tool,
|
||||
description: value
|
||||
})}
|
||||
placeholder="Describe what this tool does..."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={tool.mockInPlayground ?? false}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...tool,
|
||||
mockInPlayground: value
|
||||
})}
|
||||
/>
|
||||
<span>Mock tool in Playground</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="text-sm">Parameters:</div>
|
||||
|
||||
{Object.entries(tool.parameters?.properties || {}).map(([paramName, param], index) => (
|
||||
<div key={index} className="border border-gray-300 rounded p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<EditableField
|
||||
label="Parameter Name"
|
||||
value={paramName}
|
||||
onChange={(newName) => {
|
||||
if (newName && newName !== paramName) {
|
||||
const newProperties = { ...tool.parameters!.properties };
|
||||
newProperties[newName] = newProperties[paramName];
|
||||
delete newProperties[paramName];
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
...tool.parameters!,
|
||||
properties: newProperties,
|
||||
required: tool.parameters!.required?.map(
|
||||
name => name === paramName ? newName : name
|
||||
) || []
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Type"
|
||||
labelPlacement="outside"
|
||||
variant="bordered"
|
||||
selectedKeys={new Set([param.type])}
|
||||
onSelectionChange={(keys) => {
|
||||
const newProperties = { ...tool.parameters!.properties };
|
||||
newProperties[paramName] = {
|
||||
...newProperties[paramName],
|
||||
type: Array.from(keys)[0] as string
|
||||
};
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
...tool.parameters!,
|
||||
properties: newProperties
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={param.description}
|
||||
onChange={(desc) => {
|
||||
const newProperties = { ...tool.parameters!.properties };
|
||||
newProperties[paramName] = {
|
||||
...newProperties[paramName],
|
||||
description: desc
|
||||
};
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
...tool.parameters!,
|
||||
properties: newProperties
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={tool.parameters?.required?.includes(paramName)}
|
||||
onValueChange={() => {
|
||||
const required = [...(tool.parameters?.required || [])];
|
||||
const index = required.indexOf(paramName);
|
||||
if (index === -1) {
|
||||
required.push(paramName);
|
||||
} else {
|
||||
required.splice(index, 1);
|
||||
}
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
...tool.parameters!,
|
||||
required
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>Required</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="bordered"
|
||||
isIconOnly
|
||||
onClick={() => {
|
||||
const newProperties = { ...tool.parameters!.properties };
|
||||
delete newProperties[paramName];
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
...tool.parameters!,
|
||||
properties: newProperties,
|
||||
required: tool.parameters!.required?.filter(
|
||||
name => name !== paramName
|
||||
) || []
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end items-center">
|
||||
<Button
|
||||
variant="bordered"
|
||||
startContent={<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
onClick={() => {
|
||||
const newParamName = `param${Object.keys(tool.parameters?.properties || {}).length + 1}`;
|
||||
const newProperties = {
|
||||
...(tool.parameters?.properties || {}),
|
||||
[newParamName]: {
|
||||
type: 'string',
|
||||
description: ''
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdate({
|
||||
...tool,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: newProperties,
|
||||
required: [...(tool.parameters?.required || []), newParamName]
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { z } from "zod";
|
||||
import { AgenticAPITool } from "@/app/lib/types";
|
||||
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
|
||||
export function ToolsList({
|
||||
tools,
|
||||
handleSelectTool,
|
||||
handleAddTool,
|
||||
selectedTool,
|
||||
handleDeleteTool,
|
||||
}: {
|
||||
tools: z.infer<typeof AgenticAPITool>[];
|
||||
handleSelectTool: (name: string) => void;
|
||||
handleAddTool: (tool: Partial<z.infer<typeof AgenticAPITool>>) => void;
|
||||
selectedTool: string | null;
|
||||
handleDeleteTool: (name: string) => void;
|
||||
}) {
|
||||
const selectedToolRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedToolIndex = tools.findIndex(tool => tool.name === selectedTool);
|
||||
if (selectedToolIndex !== -1 && selectedToolRef.current) {
|
||||
selectedToolRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [selectedTool, tools]);
|
||||
|
||||
return <Pane title="Tools" actions={[
|
||||
<ActionButton
|
||||
key="add"
|
||||
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
|
||||
</svg>}
|
||||
onClick={() => handleAddTool({})}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
]}>
|
||||
<div className="overflow-auto flex flex-col justify-start">
|
||||
{tools.map((tool, index) => (
|
||||
<button
|
||||
key={index}
|
||||
ref={selectedTool === tool.name ? selectedToolRef : null}
|
||||
onClick={() => handleSelectTool(tool.name)}
|
||||
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedTool === tool.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{tool.name}</div>
|
||||
</div>
|
||||
<Dropdown key={tool.name}>
|
||||
<DropdownTrigger>
|
||||
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
|
||||
</svg>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
onAction={(key) => {
|
||||
if (key === 'delete') {
|
||||
handleDeleteTool(tool.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
|
|
@ -0,0 +1,860 @@
|
|||
"use client";
|
||||
import { DataSource, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool, WithStringId } from "@/app/lib/types";
|
||||
import { useReducer, Reducer, useState, useCallback, useEffect, useRef, Dispatch } from "react";
|
||||
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
|
||||
import { AgentConfig } from "./agent_config";
|
||||
import { ToolConfig } from "./tool_config";
|
||||
import { App as ChatApp } from "../playground/app";
|
||||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner } from "@nextui-org/react";
|
||||
import { PromptConfig } from "./prompt_config";
|
||||
import { AgentsList } from "./agents_list";
|
||||
import { PromptsList } from "./prompts_list";
|
||||
import { ToolsList } from "./tools_list";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { Copilot } from "./copilot";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { publishWorkflow, renameWorkflow, saveWorkflow } from "@/app/actions";
|
||||
import { PublishedBadge } from "./published_badge";
|
||||
import { BackIcon, HamburgerIcon, WorkflowIcon } from "@/app/lib/components/icons";
|
||||
import { ClipboardIcon, Layers2Icon, RadioIcon } from "lucide-react";
|
||||
|
||||
enablePatches();
|
||||
|
||||
interface StateItem {
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
publishedWorkflowId: string | null;
|
||||
publishing: boolean;
|
||||
selection: {
|
||||
type: "agent" | "tool" | "prompt";
|
||||
name: string;
|
||||
} | null;
|
||||
saving: boolean;
|
||||
publishError: string | null;
|
||||
publishSuccess: boolean;
|
||||
pendingChanges: boolean;
|
||||
chatKey: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
present: StateItem;
|
||||
patches: Patch[][];
|
||||
inversePatches: Patch[][];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export type Action = {
|
||||
type: "update_workflow_name";
|
||||
name: string;
|
||||
} | {
|
||||
type: "set_publishing";
|
||||
publishing: boolean;
|
||||
} | {
|
||||
type: "set_published_workflow_id";
|
||||
workflowId: string;
|
||||
} | {
|
||||
type: "add_agent";
|
||||
agent: Partial<z.infer<typeof WorkflowAgent>>;
|
||||
} | {
|
||||
type: "add_tool";
|
||||
tool: Partial<z.infer<typeof WorkflowTool>>;
|
||||
} | {
|
||||
type: "add_prompt";
|
||||
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
|
||||
} | {
|
||||
type: "select_agent";
|
||||
name: string;
|
||||
} | {
|
||||
type: "select_tool";
|
||||
name: string;
|
||||
} | {
|
||||
type: "delete_agent";
|
||||
name: string;
|
||||
} | {
|
||||
type: "delete_tool";
|
||||
name: string;
|
||||
} | {
|
||||
type: "update_agent";
|
||||
name: string;
|
||||
agent: Partial<z.infer<typeof WorkflowAgent>>;
|
||||
} | {
|
||||
type: "update_tool";
|
||||
name: string;
|
||||
tool: Partial<z.infer<typeof WorkflowTool>>;
|
||||
} | {
|
||||
type: "set_saving";
|
||||
saving: boolean;
|
||||
} | {
|
||||
type: "unselect_agent";
|
||||
} | {
|
||||
type: "unselect_tool";
|
||||
} | {
|
||||
type: "undo";
|
||||
} | {
|
||||
type: "redo";
|
||||
} | {
|
||||
type: "select_prompt";
|
||||
name: string;
|
||||
} | {
|
||||
type: "unselect_prompt";
|
||||
} | {
|
||||
type: "delete_prompt";
|
||||
name: string;
|
||||
} | {
|
||||
type: "update_prompt";
|
||||
name: string;
|
||||
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
|
||||
} | {
|
||||
type: "toggle_agent";
|
||||
name: string;
|
||||
} | {
|
||||
type: "set_main_agent";
|
||||
name: string;
|
||||
} | {
|
||||
type: "set_publish_error";
|
||||
error: string | null;
|
||||
} | {
|
||||
type: "set_publish_success";
|
||||
success: boolean;
|
||||
} | {
|
||||
type: "restore_state";
|
||||
state: StateItem;
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
console.log('running reducer', action);
|
||||
let newState: State;
|
||||
|
||||
if (action.type === "restore_state") {
|
||||
return {
|
||||
present: action.state,
|
||||
patches: [],
|
||||
inversePatches: [],
|
||||
currentIndex: 0
|
||||
};
|
||||
}
|
||||
|
||||
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
|
||||
|
||||
switch (action.type) {
|
||||
case "undo": {
|
||||
if (state.currentIndex <= 0) return state;
|
||||
newState = produce(state, draft => {
|
||||
const inverse = state.inversePatches[state.currentIndex - 1];
|
||||
draft.present = applyPatches(state.present, inverse);
|
||||
draft.currentIndex--;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "redo": {
|
||||
if (state.currentIndex >= state.patches.length) return state;
|
||||
newState = produce(state, draft => {
|
||||
const patch = state.patches[state.currentIndex];
|
||||
draft.present = applyPatches(state.present, patch);
|
||||
draft.currentIndex++;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update_workflow_name": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.workflow.name = action.name;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_publishing": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishing = action.publishing;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_published_workflow_id": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishedWorkflowId = action.workflowId;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_publish_error": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishError = action.error;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_publish_success": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishSuccess = action.success;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_saving": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.saving = action.saving;
|
||||
draft.present.pendingChanges = action.saving;
|
||||
draft.present.workflow.lastUpdatedAt = !action.saving ? new Date().toISOString() : state.present.workflow.lastUpdatedAt;
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const [nextState, patches, inversePatches] = produceWithPatches(
|
||||
state.present,
|
||||
(draft) => {
|
||||
switch (action.type) {
|
||||
case "select_agent":
|
||||
draft.selection = {
|
||||
type: "agent",
|
||||
name: action.name
|
||||
};
|
||||
break;
|
||||
case "select_tool":
|
||||
draft.selection = {
|
||||
type: "tool",
|
||||
name: action.name
|
||||
};
|
||||
break;
|
||||
case "select_prompt":
|
||||
draft.selection = {
|
||||
type: "prompt",
|
||||
name: action.name
|
||||
};
|
||||
break;
|
||||
case "unselect_agent":
|
||||
case "unselect_tool":
|
||||
case "unselect_prompt":
|
||||
draft.selection = null;
|
||||
break;
|
||||
case "add_agent": {
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
let newAgentName = "New agent";
|
||||
if (draft.workflow?.agents.some((agent) => agent.name === newAgentName)) {
|
||||
newAgentName = `New agent ${draft.workflow.agents.filter((agent) =>
|
||||
agent.name.startsWith("New agent")).length + 1}`;
|
||||
}
|
||||
draft.workflow?.agents.push({
|
||||
name: newAgentName,
|
||||
type: "conversation",
|
||||
description: "",
|
||||
disabled: false,
|
||||
instructions: "",
|
||||
prompts: [],
|
||||
tools: [],
|
||||
model: "gpt-4o-mini",
|
||||
locked: false,
|
||||
toggleAble: true,
|
||||
ragReturnType: "chunks",
|
||||
ragK: 3,
|
||||
connectedAgents: [],
|
||||
controlType: "retain",
|
||||
...action.agent
|
||||
});
|
||||
draft.selection = {
|
||||
type: "agent",
|
||||
name: action.agent.name || newAgentName
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
break;
|
||||
}
|
||||
case "add_tool": {
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
let newToolName = "New tool";
|
||||
if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) {
|
||||
newToolName = `New tool ${draft.workflow.tools.filter((tool) =>
|
||||
tool.name.startsWith("New tool")).length + 1}`;
|
||||
}
|
||||
draft.workflow?.tools.push({
|
||||
name: newToolName,
|
||||
description: "",
|
||||
parameters: undefined,
|
||||
mockInPlayground: true,
|
||||
...action.tool
|
||||
});
|
||||
draft.selection = {
|
||||
type: "tool",
|
||||
name: action.tool.name || newToolName
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
break;
|
||||
}
|
||||
case "add_prompt": {
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
let newPromptName = "New prompt";
|
||||
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
|
||||
newPromptName = `New prompt ${draft.workflow?.prompts.filter((prompt) =>
|
||||
prompt.name.startsWith("New prompt")).length + 1}`;
|
||||
}
|
||||
draft.workflow?.prompts.push({
|
||||
name: newPromptName,
|
||||
type: "base_prompt",
|
||||
prompt: "",
|
||||
...action.prompt
|
||||
});
|
||||
draft.selection = {
|
||||
type: "prompt",
|
||||
name: action.prompt.name || newPromptName
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
break;
|
||||
}
|
||||
case "delete_agent":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.agents = draft.workflow.agents.filter(
|
||||
(agent) => agent.name !== action.name
|
||||
);
|
||||
draft.selection = null;
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "delete_tool":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.tools = draft.workflow.tools.filter(
|
||||
(tool) => tool.name !== action.name
|
||||
);
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent => ({
|
||||
...agent,
|
||||
tools: agent.tools.filter(toolName => toolName !== action.name)
|
||||
}));
|
||||
draft.selection = null;
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "delete_prompt":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.prompts = draft.workflow.prompts.filter(
|
||||
(prompt) => prompt.name !== action.name
|
||||
);
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent => ({
|
||||
...agent,
|
||||
prompts: agent.prompts.filter(promptName => promptName !== action.name)
|
||||
}));
|
||||
draft.selection = null;
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "update_agent":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.agents = draft.workflow.agents.map((agent) =>
|
||||
agent.name === action.name ? { ...agent, ...action.agent } : agent
|
||||
);
|
||||
if (action.agent.name && draft.workflow.startAgent === action.name) {
|
||||
draft.workflow.startAgent = action.agent.name;
|
||||
}
|
||||
if (action.agent.name && action.agent.name !== action.name) {
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent => ({
|
||||
...agent,
|
||||
connectedAgents: agent.connectedAgents.map(connectedAgent =>
|
||||
connectedAgent === action.name ? action.agent.name! : connectedAgent
|
||||
)
|
||||
}));
|
||||
}
|
||||
if (action.agent.name && draft.selection?.type === "agent" && draft.selection.name === action.name) {
|
||||
draft.selection = {
|
||||
type: "agent",
|
||||
name: action.agent.name
|
||||
};
|
||||
}
|
||||
draft.selection = {
|
||||
type: "agent",
|
||||
name: action.agent.name || action.name,
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "update_tool":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.tools = draft.workflow.tools.map((tool) =>
|
||||
tool.name === action.name ? { ...tool, ...action.tool } : tool
|
||||
);
|
||||
if (action.tool.name && action.tool.name !== action.name) {
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent => ({
|
||||
...agent,
|
||||
tools: agent.tools.map(toolName =>
|
||||
toolName === action.name ? action.tool.name! : toolName
|
||||
)
|
||||
}));
|
||||
}
|
||||
if (action.tool.name && draft.selection?.type === "tool" && draft.selection.name === action.name) {
|
||||
draft.selection = {
|
||||
type: "tool",
|
||||
name: action.tool.name
|
||||
};
|
||||
}
|
||||
draft.selection = {
|
||||
type: "tool",
|
||||
name: action.tool.name || action.name,
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "update_prompt":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
|
||||
prompt.name === action.name ? { ...prompt, ...action.prompt } : prompt
|
||||
);
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent => ({
|
||||
...agent,
|
||||
prompts: agent.prompts.map(promptName =>
|
||||
promptName === action.name ? action.prompt.name! : promptName
|
||||
)
|
||||
}));
|
||||
if (action.prompt.name && draft.selection?.type === "prompt" && draft.selection.name === action.name) {
|
||||
draft.selection = {
|
||||
type: "prompt",
|
||||
name: action.prompt.name
|
||||
};
|
||||
}
|
||||
draft.selection = {
|
||||
type: "prompt",
|
||||
name: action.prompt.name || action.name,
|
||||
};
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "toggle_agent":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.agents = draft.workflow.agents.map(agent =>
|
||||
agent.name === action.name ? { ...agent, disabled: !agent.disabled } : agent
|
||||
);
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "set_main_agent":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
draft.workflow.startAgent = action.name;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
newState = produce(state, draft => {
|
||||
draft.patches.splice(state.currentIndex);
|
||||
draft.inversePatches.splice(state.currentIndex);
|
||||
draft.patches.push(patches);
|
||||
draft.inversePatches.push(inversePatches);
|
||||
draft.currentIndex++;
|
||||
draft.present = nextState;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
export function WorkflowEditor({
|
||||
dataSources,
|
||||
workflow,
|
||||
publishedWorkflowId,
|
||||
handleShowSelector,
|
||||
handleCloneVersion,
|
||||
}: {
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[];
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
publishedWorkflowId: string | null;
|
||||
handleShowSelector: () => void;
|
||||
handleCloneVersion: (workflowId: string) => void;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
|
||||
patches: [],
|
||||
inversePatches: [],
|
||||
currentIndex: 0,
|
||||
present: {
|
||||
publishing: false,
|
||||
selection: null,
|
||||
workflow: workflow,
|
||||
publishedWorkflowId: publishedWorkflowId,
|
||||
saving: false,
|
||||
publishError: null,
|
||||
publishSuccess: false,
|
||||
pendingChanges: false,
|
||||
chatKey: 0,
|
||||
}
|
||||
});
|
||||
const [chatMessages, setChatMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>([]);
|
||||
const updateChatMessages = useCallback((messages: z.infer<typeof apiV1.ChatMessage>[]) => {
|
||||
setChatMessages(messages);
|
||||
}, []);
|
||||
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
|
||||
const saving = useRef(false);
|
||||
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
|
||||
console.log(`workflow editor chat key: ${state.present.chatKey}`);
|
||||
|
||||
function handleSelectAgent(name: string) {
|
||||
dispatch({ type: "select_agent", name });
|
||||
}
|
||||
|
||||
function handleSelectTool(name: string) {
|
||||
dispatch({ type: "select_tool", name });
|
||||
}
|
||||
|
||||
function handleSelectPrompt(name: string) {
|
||||
dispatch({ type: "select_prompt", name });
|
||||
}
|
||||
|
||||
function handleUnselectAgent() {
|
||||
dispatch({ type: "unselect_agent" });
|
||||
}
|
||||
|
||||
function handleUnselectTool() {
|
||||
dispatch({ type: "unselect_tool" });
|
||||
}
|
||||
|
||||
function handleUnselectPrompt() {
|
||||
dispatch({ type: "unselect_prompt" });
|
||||
}
|
||||
|
||||
function handleAddAgent(agent: Partial<z.infer<typeof WorkflowAgent>> = {}) {
|
||||
dispatch({ type: "add_agent", agent });
|
||||
}
|
||||
|
||||
function handleAddTool(tool: Partial<z.infer<typeof WorkflowTool>> = {}) {
|
||||
dispatch({ type: "add_tool", tool });
|
||||
}
|
||||
|
||||
function handleAddPrompt(prompt: Partial<z.infer<typeof WorkflowPrompt>> = {}) {
|
||||
dispatch({ type: "add_prompt", prompt });
|
||||
}
|
||||
|
||||
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
|
||||
dispatch({ type: "update_agent", name, agent });
|
||||
}
|
||||
|
||||
function handleDeleteAgent(name: string) {
|
||||
if (window.confirm(`Are you sure you want to delete the agent "${name}"?`)) {
|
||||
dispatch({ type: "delete_agent", name });
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateTool(name: string, tool: Partial<z.infer<typeof WorkflowTool>>) {
|
||||
dispatch({ type: "update_tool", name, tool });
|
||||
}
|
||||
|
||||
function handleDeleteTool(name: string) {
|
||||
if (window.confirm(`Are you sure you want to delete the tool "${name}"?`)) {
|
||||
dispatch({ type: "delete_tool", name });
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdatePrompt(name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
|
||||
dispatch({ type: "update_prompt", name, prompt });
|
||||
}
|
||||
|
||||
function handleDeletePrompt(name: string) {
|
||||
if (window.confirm(`Are you sure you want to delete the prompt "${name}"?`)) {
|
||||
dispatch({ type: "delete_prompt", name });
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleAgent(name: string) {
|
||||
dispatch({ type: "toggle_agent", name });
|
||||
}
|
||||
|
||||
function handleSetMainAgent(name: string) {
|
||||
dispatch({ type: "set_main_agent", name });
|
||||
}
|
||||
|
||||
async function handleRenameWorkflow(name: string) {
|
||||
await renameWorkflow(state.present.workflow.projectId, state.present.workflow._id, name);
|
||||
dispatch({ type: "update_workflow_name", name });
|
||||
}
|
||||
|
||||
async function handlePublishWorkflow() {
|
||||
dispatch({ type: "set_publishing", publishing: true });
|
||||
await publishWorkflow(state.present.workflow.projectId, state.present.workflow._id);
|
||||
dispatch({ type: "set_publishing", publishing: false });
|
||||
dispatch({ type: "set_published_workflow_id", workflowId: state.present.workflow._id });
|
||||
}
|
||||
|
||||
function handleCopyJSON() {
|
||||
const { _id, projectId, ...workflow } = state.present.workflow;
|
||||
const json = JSON.stringify(workflow, null, 2);
|
||||
navigator.clipboard.writeText(json);
|
||||
setShowCopySuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowCopySuccess(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
const processQueue = useCallback(async (state: State, dispatch: React.Dispatch<Action>) => {
|
||||
if (saving.current || saveQueue.current.length === 0) return;
|
||||
|
||||
saving.current = true;
|
||||
const workflowToSave = saveQueue.current[saveQueue.current.length - 1];
|
||||
saveQueue.current = [];
|
||||
|
||||
try {
|
||||
if (isLive) {
|
||||
return;
|
||||
} else {
|
||||
await saveWorkflow(state.present.workflow.projectId, state.present.workflow._id, workflowToSave);
|
||||
}
|
||||
} finally {
|
||||
saving.current = false;
|
||||
if (saveQueue.current.length > 0) {
|
||||
processQueue(state, dispatch);
|
||||
} else {
|
||||
dispatch({ type: "set_saving", saving: false });
|
||||
}
|
||||
}
|
||||
}, [isLive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.present.pendingChanges && state.present.workflow) {
|
||||
saveQueue.current.push(state.present.workflow);
|
||||
const timeoutId = setTimeout(() => {
|
||||
dispatch({ type: "set_saving", saving: true });
|
||||
processQueue(state, dispatch);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [state.present.workflow, state.present.pendingChanges, processQueue, state]);
|
||||
|
||||
return <div className="flex flex-col h-full relative">
|
||||
<div className="shrink-0 flex justify-between items-center pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">Workflow</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<WorkflowIcon />
|
||||
<div className="font-semibold">
|
||||
<EditableField
|
||||
key={state.present.workflow._id}
|
||||
value={state.present.workflow?.name || ''}
|
||||
onChange={handleRenameWorkflow}
|
||||
placeholder="Name this version"
|
||||
/>
|
||||
</div>
|
||||
{state.present.publishing && <Spinner size="sm" />}
|
||||
{isLive && <PublishedBadge />}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
>
|
||||
<HamburgerIcon size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={[
|
||||
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
|
||||
...(isLive ? ['publish'] : []),
|
||||
]}
|
||||
onAction={(key) => {
|
||||
if (key === 'switch') {
|
||||
handleShowSelector();
|
||||
}
|
||||
if (key === 'clone') {
|
||||
handleCloneVersion(state.present.workflow._id);
|
||||
}
|
||||
if (key === 'publish') {
|
||||
handlePublishWorkflow();
|
||||
}
|
||||
if (key === 'clipboard') {
|
||||
handleCopyJSON();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key="switch"
|
||||
startContent={<BackIcon size={16} />}
|
||||
>
|
||||
Switch version
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="clone"
|
||||
startContent={<Layers2Icon size={16} />}
|
||||
>
|
||||
Clone this version
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="publish"
|
||||
color="danger"
|
||||
startContent={<RadioIcon size={16} />}
|
||||
>
|
||||
Deploy to Production
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="clipboard"
|
||||
startContent={<ClipboardIcon size={16} />}
|
||||
>
|
||||
Copy as JSON
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{showCopySuccess && <div className="flex items-center gap-2">
|
||||
<div className="text-green-500">Copied to clipboard</div>
|
||||
</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLive && <div className="flex items-center gap-2">
|
||||
<div className="bg-yellow-50 text-yellow-500 px-2 py-1 rounded-md text-sm">
|
||||
This version is locked. You cannot make changes.
|
||||
</div>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={() => handleCloneVersion(state.present.workflow._id)}
|
||||
>
|
||||
Clone this version
|
||||
</Button>
|
||||
</div>}
|
||||
{!isLive && <>
|
||||
{state.present.saving && <div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div className="text-sm text-gray-500">Saving...</div>
|
||||
</div>}
|
||||
{!state.present.saving && state.present.workflow && <div className="text-sm text-gray-500">
|
||||
Updated <RelativeTime date={new Date(state.present.workflow.lastUpdatedAt)} />
|
||||
</div>}
|
||||
</>}
|
||||
{!isLive && <>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="bordered"
|
||||
title="Undo"
|
||||
size="sm"
|
||||
disabled={state.currentIndex <= 0}
|
||||
onClick={() => dispatch({ type: "undo" })}
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M3 9h13a5 5 0 0 1 0 10H7M3 9l4-4M3 9l4 4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="bordered"
|
||||
title="Redo"
|
||||
size="sm"
|
||||
disabled={state.currentIndex >= state.patches.length}
|
||||
onClick={() => dispatch({ type: "redo" })}
|
||||
>
|
||||
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M21 9H8a5 5 0 0 0 0 10h9m4-10-4-4m4 4-4 4" />
|
||||
</svg>
|
||||
</Button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
|
||||
<ResizablePanel minSize={10} defaultSize={20}>
|
||||
<ResizablePanelGroup direction="vertical" className="flex flex-col gap-1">
|
||||
<ResizablePanel minSize={10} defaultSize={50}>
|
||||
<AgentsList
|
||||
agents={state.present.workflow.agents}
|
||||
handleSelectAgent={handleSelectAgent}
|
||||
handleAddAgent={handleAddAgent}
|
||||
selectedAgent={state.present.selection?.type === "agent" ? state.present.selection.name : null}
|
||||
handleToggleAgent={handleToggleAgent}
|
||||
handleSetMainAgent={handleSetMainAgent}
|
||||
handleDeleteAgent={handleDeleteAgent}
|
||||
startAgentName={state.present.workflow.startAgent}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={10} defaultSize={30}>
|
||||
<ToolsList
|
||||
tools={state.present.workflow.tools}
|
||||
handleSelectTool={handleSelectTool}
|
||||
handleAddTool={handleAddTool}
|
||||
selectedTool={state.present.selection?.type === "tool" ? state.present.selection.name : null}
|
||||
handleDeleteTool={handleDeleteTool}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={10} defaultSize={20}>
|
||||
<PromptsList
|
||||
prompts={state.present.workflow.prompts}
|
||||
handleSelectPrompt={handleSelectPrompt}
|
||||
handleAddPrompt={handleAddPrompt}
|
||||
selectedPrompt={state.present.selection?.type === "prompt" ? state.present.selection.name : null}
|
||||
handleDeletePrompt={handleDeletePrompt}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={20} defaultSize={50} className="overflow-auto">
|
||||
<ChatApp
|
||||
key={'' + state.present.chatKey}
|
||||
hidden={state.present.selection !== null}
|
||||
projectId={state.present.workflow.projectId}
|
||||
workflow={state.present.workflow}
|
||||
messageSubscriber={updateChatMessages}
|
||||
/>
|
||||
{state.present.selection?.type === "agent" && <AgentConfig
|
||||
key={state.present.selection.name}
|
||||
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
|
||||
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
|
||||
agents={state.present.workflow.agents}
|
||||
tools={state.present.workflow.tools}
|
||||
prompts={state.present.workflow.prompts}
|
||||
dataSources={dataSources}
|
||||
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
|
||||
handleClose={handleUnselectAgent}
|
||||
/>}
|
||||
{state.present.selection?.type === "tool" && <ToolConfig
|
||||
key={state.present.selection.name}
|
||||
tool={state.present.workflow.tools.find((tool) => tool.name === state.present.selection!.name)!}
|
||||
usedToolNames={new Set(state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name))}
|
||||
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
|
||||
handleClose={handleUnselectTool}
|
||||
/>}
|
||||
{state.present.selection?.type === "prompt" && <PromptConfig
|
||||
key={state.present.selection.name}
|
||||
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
|
||||
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
|
||||
handleUpdate={handleUpdatePrompt.bind(null, state.present.selection.name)}
|
||||
handleClose={handleUnselectPrompt}
|
||||
/>}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={10} defaultSize={30}>
|
||||
<Copilot
|
||||
projectId={state.present.workflow.projectId}
|
||||
workflow={state.present.workflow}
|
||||
dispatch={dispatch}
|
||||
chatContext={
|
||||
state.present.selection ? {
|
||||
type: state.present.selection.type,
|
||||
name: state.present.selection.name
|
||||
} : chatMessages.length > 0 ? {
|
||||
type: 'chat',
|
||||
messages: chatMessages
|
||||
} : undefined
|
||||
}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
import { Workflow, WithStringId } from "@/app/lib/types";
|
||||
import { z } from "zod";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { PublishedBadge } from "./published_badge";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { listWorkflows } from "@/app/actions";
|
||||
import { Button, Divider, Pagination } from "@nextui-org/react";
|
||||
import { WorkflowIcon } from "@/app/lib/components/icons";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
const pageSize = 5;
|
||||
|
||||
function WorkflowCard({
|
||||
workflow,
|
||||
live = false,
|
||||
handleSelect,
|
||||
}: {
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
live?: boolean;
|
||||
handleSelect: (workflowId: string) => void;
|
||||
}) {
|
||||
return <button className="flex items-center gap-2 p-2 rounded hover:bg-gray-100 cursor-pointer" onClick={() => handleSelect(workflow._id)}>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex items-center gap-1">
|
||||
<WorkflowIcon />
|
||||
<div className="text-black truncate">{workflow.name || 'Unnamed workflow'}</div>
|
||||
{live && <PublishedBadge />}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function WorkflowSelector({
|
||||
projectId,
|
||||
handleSelect,
|
||||
handleCreateNewVersion,
|
||||
}: {
|
||||
projectId: string;
|
||||
handleSelect: (workflowId: string) => void;
|
||||
handleCreateNewVersion: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workflows, setWorkflows] = useState<(WithStringId<z.infer<typeof Workflow>>)[]>([]);
|
||||
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page);
|
||||
setWorkflows([]);
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
setRetryCount(retryCount + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchWorkflows() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { workflows, total, publishedWorkflowId } = await listWorkflows(projectId, currentPage, pageSize);
|
||||
if (ignore) {
|
||||
console.log('ignoring', currentPage);
|
||||
return;
|
||||
}
|
||||
setWorkflows(workflows);
|
||||
setTotalPages(Math.ceil(total / pageSize));
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError('Failed to load workflows');
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkflows();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
}
|
||||
}, [projectId, currentPage, retryCount]);
|
||||
|
||||
return <div className="flex flex-col gap-2 max-w-[768px] mx-auto w-full border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="text-lg">Select a workflow version</div>
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onClick={handleCreateNewVersion}
|
||||
>
|
||||
Create new version
|
||||
</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
{loading && <div className="flex flex-col gap-2">
|
||||
{[...Array(pageSize)].map((_, i) => {
|
||||
const widths = ['w-32', 'w-40', 'w-48', 'w-56'];
|
||||
const randomWidth = widths[Math.floor(Math.random() * widths.length)];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-2 p-2 rounded"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-5 ${randomWidth} bg-gray-200 rounded animate-pulse`}></div>
|
||||
</div>
|
||||
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
{error && <div className="flex flex-col items-center gap-2 text-red-600">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 text-sm bg-red-100 hover:bg-red-200 rounded"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>}
|
||||
{!loading && !error && workflows.length == 0 && <div className="flex flex-col items-center gap-2">
|
||||
<div className="text-sm text-gray-500">No versions found. Create a new version to get started.</div>
|
||||
</div>}
|
||||
{!loading && !error && workflows.length > 0 && <div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{workflows.map((workflow) => (
|
||||
<WorkflowCard
|
||||
key={workflow._id}
|
||||
workflow={workflow}
|
||||
live={publishedWorkflowId == workflow._id}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
73
apps/rowboat/app/projects/app.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
'use client';
|
||||
|
||||
import { Link, Button, Spinner } from "@nextui-org/react";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { Project } from "../lib/types";
|
||||
import { default as NextLink } from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { listProjects } from "../actions";
|
||||
|
||||
export default function App() {
|
||||
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchProjects() {
|
||||
setIsLoading(true);
|
||||
const projects = await listProjects();
|
||||
if (!ignore) {
|
||||
setProjects(projects);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProjects();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full pt-4 px-4 overflow-auto">
|
||||
<div className="max-w-[768px] mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-lg">Select a project</div>
|
||||
<Button
|
||||
href="/projects/new"
|
||||
as={Link}
|
||||
startContent={
|
||||
<svg className="w-[18px] h-[18px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Create new project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && <Spinner size="sm" />}
|
||||
{!isLoading && projects.length == 0 && <p className="mt-4 text-center text-gray-600 text-sm">You do not have any projects.</p>}
|
||||
{!isLoading && projects.length > 0 && <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
{projects.map((project) => (
|
||||
<NextLink
|
||||
key={project._id}
|
||||
href={`/projects/${project._id}`}
|
||||
className="flex flex-col gap-2 border border-gray-300 hover:border-gray-500 rounded p-4"
|
||||
>
|
||||
<div className="text-lg">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="shrink-0 text-sm text-gray-500">
|
||||
Created <RelativeTime date={new Date(project.createdAt)} />
|
||||
</div>
|
||||
</NextLink>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/rowboat/app/projects/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import logo from "@/public/rowboat-logo.png";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { UserButton } from "@/app/lib/components/user_button";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <>
|
||||
<header className="shrink-0 flex justify-between items-center px-4 py-2 border-b border-b-gray-100">
|
||||
<div className="flex items-center gap-12">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src={logo}
|
||||
height={24}
|
||||
alt="RowBoat Labs Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<UserButton />
|
||||
</header>
|
||||
<main className="grow overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</>;
|
||||
}
|
||||
22
apps/rowboat/app/projects/new/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Input } from "@nextui-org/react";
|
||||
import { createProject } from "@/app/actions";
|
||||
import { Submit } from "./submit";
|
||||
|
||||
export default async function Page() {
|
||||
return <div className="h-full pt-4 px-4 overflow-auto">
|
||||
<div className="max-w-[768px] mx-auto">
|
||||
<div className="text-lg pb-2 border-b border-b-gray-100">Create new Project</div>
|
||||
<form className="mt-4 flex flex-col gap-4" action={createProject}>
|
||||
<Input
|
||||
required
|
||||
name="name"
|
||||
label="Name this project:"
|
||||
placeholder="Project name or description (internal only)"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
/>
|
||||
<Submit />
|
||||
</form>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
21
apps/rowboat/app/projects/new/submit.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function Submit() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return <>
|
||||
{pending && <div className="text-gray-400">Please hold on while we set up your project…</div>}
|
||||
<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
children: "Create",
|
||||
className: "self-start",
|
||||
startContent: <svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
|
||||
</svg>,
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
5
apps/rowboat/app/projects/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from "./app";
|
||||
|
||||
export default function Page() {
|
||||
return <App />
|
||||
}
|
||||
15
apps/rowboat/app/providers.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// app/providers.tsx
|
||||
'use client'
|
||||
|
||||
import { NextUIProvider } from '@nextui-org/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function Providers({ className, children }: { className: string, children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<NextUIProvider className={className} navigate={router.push}>
|
||||
{children}
|
||||
</NextUIProvider >
|
||||
)
|
||||
}
|
||||
29
apps/rowboat/app/safari-pinned-tab.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M5707 6514 c-1 -1 -54 -4 -117 -8 -63 -3 -133 -8 -155 -11 -22 -3
|
||||
-80 -10 -130 -16 -94 -11 -297 -43 -375 -59 -25 -6 -56 -12 -70 -14 -51 -9
|
||||
-187 -38 -265 -57 -44 -10 -89 -21 -100 -23 -11 -2 -49 -12 -85 -21 -36 -9
|
||||
-72 -18 -80 -20 -8 -2 -49 -13 -90 -25 -41 -11 -84 -23 -95 -25 -71 -16 -567
|
||||
-168 -770 -236 -326 -110 -953 -337 -1020 -369 -11 -5 -108 -44 -215 -86 -107
|
||||
-41 -208 -82 -225 -89 -16 -8 -55 -23 -85 -35 -30 -11 -64 -25 -75 -30 -11 -5
|
||||
-117 -49 -235 -99 -178 -74 -597 -254 -740 -319 -19 -9 -37 -16 -40 -17 -6 -2
|
||||
-97 -43 -305 -137 -182 -83 -234 -107 -309 -143 -39 -19 -84 -38 -99 -44 l-27
|
||||
-11 0 -2310 0 -2310 2599 0 2599 0 10 27 c10 26 152 465 158 488 2 6 44 141
|
||||
94 300 133 427 133 426 224 740 75 258 151 530 161 574 2 9 16 59 30 111 31
|
||||
113 105 397 111 424 2 11 17 74 33 140 17 67 41 171 56 231 14 61 29 126 35
|
||||
145 5 19 11 49 14 65 3 17 8 39 10 50 21 97 59 274 62 290 2 11 8 40 14 65 9
|
||||
44 56 297 80 435 7 39 14 79 17 90 2 11 6 36 9 55 3 19 7 46 9 60 5 26 23 156
|
||||
30 212 2 18 6 49 9 68 9 58 24 180 30 250 3 25 8 72 11 105 18 182 29 406 28
|
||||
613 -1 244 -9 366 -39 560 -12 76 -45 219 -68 292 l-24 75 -44 7 c-23 3 -59 9
|
||||
-78 13 -51 9 -86 14 -172 21 -70 5 -296 12 -301 8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
979
apps/rowboat/app/scripts/crawlUrls.ts
Normal file
|
|
@ -0,0 +1,979 @@
|
|||
import '../lib/loadenv';
|
||||
import FirecrawlApp, { CrawlStatusResponse, ErrorResponse, FirecrawlDocument } from '@mendable/firecrawl-js';
|
||||
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
|
||||
import { z } from 'zod';
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import * as fs from 'fs/promises';
|
||||
import { dataSourcesCollection, embeddingsCollection, webpagesCollection } from '../lib/mongodb';
|
||||
import { DataSource, EmbeddingDoc } from '../lib/types';
|
||||
import { WithId } from 'mongodb';
|
||||
import assert from 'assert';
|
||||
import { embedMany, generateText } from 'ai';
|
||||
import { embeddingModel } from '../lib/embedding';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { WriteStream } from 'fs';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import path from 'path';
|
||||
const oxylabsUsername = process.env.OXYLABS_USERNAME;
|
||||
const oxylabsPassword = process.env.OXYLABS_PASSWORD;
|
||||
const firecrawl = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY });
|
||||
|
||||
const oxylabsHttpAuth = {
|
||||
'Authorization': 'Basic ' + Buffer.from(`${oxylabsUsername}:${oxylabsPassword}`).toString('base64'),
|
||||
}
|
||||
const firecrawlHttpAuth = {
|
||||
'Authorization': `Bearer ${process.env.FIRECRAWL_API_KEY}`,
|
||||
}
|
||||
|
||||
type Webpage = {
|
||||
title: string,
|
||||
url: string,
|
||||
markdown: string,
|
||||
html: string,
|
||||
}
|
||||
|
||||
const splitter = new RecursiveCharacterTextSplitter({
|
||||
separators: ['\n\n', '\n', '. ', '.', ''],
|
||||
chunkSize: 1024,
|
||||
chunkOverlap: 20,
|
||||
});
|
||||
|
||||
type OxylabsDocument = {
|
||||
url: string,
|
||||
content: string,
|
||||
}
|
||||
|
||||
const second = 1000;
|
||||
const minute = 60 * second;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
const firecrawlStatusPollInterval = 60 * second;
|
||||
const oxylabsStatusPollInterval = 60 * second;
|
||||
|
||||
|
||||
// create a PrefixLogger class that wraps console.log with a prefix
|
||||
// and allows chaining with a parent logger
|
||||
class PrefixLogger {
|
||||
private prefix: string;
|
||||
private parent: PrefixLogger | null;
|
||||
|
||||
constructor(prefix: string, parent: PrefixLogger | null = null) {
|
||||
this.prefix = prefix;
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = '[' + this.prefix + ']';
|
||||
|
||||
if (this.parent) {
|
||||
this.parent.log(prefix, ...args);
|
||||
} else {
|
||||
console.log(timestamp, prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
child(childPrefix: string): PrefixLogger {
|
||||
return new PrefixLogger(childPrefix, this);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
const source: z.infer<typeof SourceSchema> = {
|
||||
_id: new ObjectId(),
|
||||
url: "https://www.example.com",
|
||||
type: "web",
|
||||
status: 'processing',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
*/
|
||||
|
||||
async function retryable<T>(fn: () => Promise<T>, maxAttempts: number = 3): Promise<T> {
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMode<InputType, OutputType>(opts: {
|
||||
batchSize: number,
|
||||
} & ({
|
||||
outputFilePath: string,
|
||||
processBatch: (batch: InputType[]) => Promise<OutputType[]>
|
||||
} | {
|
||||
processBatch: (batch: InputType[]) => Promise<void>
|
||||
}) & ({
|
||||
input: InputType[],
|
||||
} | {
|
||||
inputFilePath: string,
|
||||
})) {
|
||||
let inFile: fs.FileHandle | null = null;
|
||||
let outFile: fs.FileHandle | null = null;
|
||||
let ws: WriteStream | null = null;
|
||||
if ('inputFilePath' in opts) {
|
||||
inFile = await fs.open(opts.inputFilePath || '', 'r');
|
||||
}
|
||||
if ('outputFilePath' in opts) {
|
||||
outFile = await fs.open(opts.outputFilePath, 'w');
|
||||
ws = outFile.createWriteStream();
|
||||
}
|
||||
|
||||
let batch: InputType[] = [];
|
||||
|
||||
async function process() {
|
||||
const processed = await opts.processBatch(batch);
|
||||
if (ws && processed?.length) {
|
||||
for (const doc of processed) {
|
||||
ws.write(JSON.stringify(doc) + '\n');
|
||||
}
|
||||
}
|
||||
batch = [];
|
||||
}
|
||||
|
||||
try {
|
||||
if ('input' in opts) {
|
||||
for (const doc of opts.input) {
|
||||
batch.push(doc);
|
||||
if (batch.length < opts.batchSize) {
|
||||
continue;
|
||||
}
|
||||
await process();
|
||||
}
|
||||
} else {
|
||||
assert(inFile);
|
||||
for await (const line of inFile.readLines()) {
|
||||
const parsed: InputType = JSON.parse(line);
|
||||
batch.push(parsed);
|
||||
if (batch.length < opts.batchSize) {
|
||||
continue;
|
||||
}
|
||||
await process();
|
||||
}
|
||||
}
|
||||
// if there are any leftover documents
|
||||
if (batch.length > 0) {
|
||||
await process();
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
if (outFile) {
|
||||
await outFile.close();
|
||||
}
|
||||
if (inFile) {
|
||||
await inFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeUsingOxylabs(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource> & { data: { type: 'urls' } }>) {
|
||||
const logger = _logger.child('scrapeUsingOxylabs');
|
||||
|
||||
// disable this for now
|
||||
throw new Error("OxyLabs scraping is disabled for now");
|
||||
|
||||
await batchMode({
|
||||
input: job.data.urls,
|
||||
outputFilePath: 'crawled-oxylabs.jsonl',
|
||||
batchSize: 5,
|
||||
processBatch: async (batch: string[]) => {
|
||||
const results = await Promise.all(batch.map(async (url) => {
|
||||
try {
|
||||
logger.log("Scraping URL", url);
|
||||
const response = await retryable(async () => {
|
||||
const res = await fetch('https://realtime.oxylabs.io/v1/queries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
'source': 'universal',
|
||||
'url': url,
|
||||
'context': [
|
||||
{ 'key': 'follow_redirects', 'value': true }
|
||||
]
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...oxylabsHttpAuth,
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unable to scrape URL: ${url} with status: ${res.status} and text: ${res.statusText}, body: ${await res.text()}`);
|
||||
}
|
||||
return res;
|
||||
}, 3); // Retry up to 3 times
|
||||
const parsed: {
|
||||
"results": {
|
||||
"url": string,
|
||||
"content": string,
|
||||
"status_code": number,
|
||||
}[],
|
||||
} = await response.json();
|
||||
const result = parsed.results[0];
|
||||
if (!result) {
|
||||
throw new Error("No results found for URL: " + url);
|
||||
}
|
||||
if (result.status_code !== 200) {
|
||||
throw new Error("Non-200 status code for URL: " + url);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
logger.log("Error scraping URL: " + url, e);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
return results.filter(r => r !== null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function scrapeUsingFirecrawl(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource> & { data: { type: 'urls' } }>) {
|
||||
const logger = _logger.child('scrapeUsingFirecrawl');
|
||||
|
||||
await batchMode({
|
||||
input: job.data.urls,
|
||||
outputFilePath: 'crawled-firecrawl.jsonl',
|
||||
batchSize: 1, // how many firecrawl requests to make at a time
|
||||
processBatch: async (batch: string[]): Promise<FirecrawlDocument[]> => {
|
||||
const results = await Promise.all(batch.map(async (url) => {
|
||||
try {
|
||||
logger.log("Scraping URL", url);
|
||||
const result = await retryable(async () => {
|
||||
const scrapeResult = await firecrawl.scrapeUrl(url, {
|
||||
formats: ['html', 'markdown'],
|
||||
onlyMainContent: true,
|
||||
excludeTags: ['script', 'style', 'noscript', 'img',]
|
||||
});
|
||||
if (!scrapeResult.success) {
|
||||
throw new Error("Unable to scrape URL: " + url);
|
||||
}
|
||||
return scrapeResult;
|
||||
}, 3); // Retry up to 3 times
|
||||
return result;
|
||||
} catch (e) {
|
||||
logger.log("Error scraping URL: " + url, e);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
return results.filter(r => r !== null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function crawlUsingFirecrawl(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>> & { data: { type: 'crawl' } }) {
|
||||
const logger = _logger.child('crawlUsingFirecrawl');
|
||||
|
||||
// empty the output file before starting
|
||||
await fs.writeFile('crawled-firecrawl.jsonl', '');
|
||||
|
||||
// check if we have an existing firecrawl ID
|
||||
// if not, start a new crawl job
|
||||
let firecrawlId = job.data.firecrawlId;
|
||||
if (!firecrawlId) {
|
||||
logger.log('Starting firecrawl crawl...');
|
||||
// start crawl
|
||||
const result = await retryable(async () => {
|
||||
const response = await fetch('https://api.firecrawl.dev/v1/crawl', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(1 * minute),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...firecrawlHttpAuth,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: job.data.startUrl,
|
||||
limit: job.data.limit,
|
||||
maxDepth: 2,
|
||||
scrapeOptions: {
|
||||
formats: ['html', 'markdown'],
|
||||
onlyMainContent: true,
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to call /crawl API: " + response.statusText);
|
||||
}
|
||||
return response;
|
||||
}, 3);
|
||||
const parsed = await result.json();
|
||||
if (!parsed.success) {
|
||||
throw new Error("Unable to start crawl: parsed.succes = false");
|
||||
}
|
||||
const crawlId = parsed.id;
|
||||
logger.log("Firecrawl job started with ID", crawlId);
|
||||
firecrawlId = crawlId;
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
}, {
|
||||
$set: {
|
||||
'data.firecrawlId': firecrawlId,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.log("Using existing firecrawl job with ID", firecrawlId);
|
||||
}
|
||||
|
||||
// wait for crawl job to complete
|
||||
let counter = 0;
|
||||
let resp: CrawlStatusResponse;
|
||||
while (true) {
|
||||
// wait for 60s
|
||||
await new Promise(resolve => setTimeout(resolve, firecrawlStatusPollInterval));
|
||||
|
||||
// check status
|
||||
resp = await retryable(async (): Promise<CrawlStatusResponse> => {
|
||||
logger.log("Polling firecrawl status...")
|
||||
const result = await fetch(`https://api.firecrawl.dev/v1/crawl/${firecrawlId}`, {
|
||||
signal: AbortSignal.timeout(1 * minute),
|
||||
headers: {
|
||||
...firecrawlHttpAuth,
|
||||
}
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error("Unable to fetch crawl status: " + result.statusText);
|
||||
}
|
||||
const parsed = await result.json();
|
||||
if (!parsed.success) {
|
||||
throw new Error("Unable to fetch crawl status: " + parsed.error);
|
||||
}
|
||||
return parsed;
|
||||
}, 3);
|
||||
|
||||
if (resp.status !== 'completed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// open a file and append data line by line
|
||||
logger.log("First page collected from firecrawl: ", resp.data.length);
|
||||
counter += resp.data.length;
|
||||
const file = await fs.open('crawled-firecrawl.jsonl', 'w');
|
||||
const ws = file.createWriteStream();
|
||||
try {
|
||||
for (const doc of resp.data) {
|
||||
if (doc && doc.metadata?.statusCode === 200) {
|
||||
ws.write(JSON.stringify(doc) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
let nextUrl = resp.next;
|
||||
while (nextUrl) {
|
||||
const parsed = await retryable(async () => {
|
||||
// fetch next page from firecrawl and pass on the firecrawl api key
|
||||
// as a bearer token
|
||||
assert(nextUrl);
|
||||
const result = await fetch(nextUrl, {
|
||||
signal: AbortSignal.timeout(1 * minute),
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env["FIRECRAWL_API_KEY"]}`,
|
||||
}
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error("Unable to fetch next page from firecrawl: " + result.statusText);
|
||||
}
|
||||
return await result.json();
|
||||
}, 3);
|
||||
logger.log("Next page collected from firecrawl: ", parsed.data.length);
|
||||
counter += parsed.data.length;
|
||||
for (const doc of parsed.data) {
|
||||
if (doc && doc.metadata?.statusCode === 200) {
|
||||
ws.write(JSON.stringify(doc) + '\n');
|
||||
}
|
||||
}
|
||||
nextUrl = parsed.next;
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
ws.close();
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function crawlUsingOxylabs(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>> & { data: { type: 'crawl' } }) {
|
||||
const logger = _logger.child('crawlUsingOxyLabs');
|
||||
|
||||
// empty the output file before starting
|
||||
await fs.writeFile('crawled-oxylabs.jsonl', '');
|
||||
|
||||
// disable this for now
|
||||
throw new Error("OxyLabs crawling is disabled for now");
|
||||
|
||||
// check if we have an existing oxylabs ID
|
||||
// if not, start a new crawl job
|
||||
let oxylabsId = job.data.oxylabsId;
|
||||
if (!oxylabsId) {
|
||||
oxylabsId = await retryable(async () => {
|
||||
// if url ends with a slash, remove it
|
||||
let url = job.data.startUrl;
|
||||
if (job.data.startUrl.endsWith('/')) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
|
||||
// create a regex for the starting url
|
||||
// that matches any subpath
|
||||
const baseRegex = (new RegExp(url)).toString().slice(1, -1);
|
||||
const subpathRegex = (new RegExp(`${url}/.*`)).toString().slice(1, -1);
|
||||
/*
|
||||
const escapedOrigin = url.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapedPathname = url.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\/$/, '');
|
||||
const regex = new RegExp(`${escapedOrigin}${escapedPathname}(/.*)?`);
|
||||
*/
|
||||
|
||||
logger.log(`Starting crawl for ${url}`);
|
||||
// Initiate a new Web Crawler job
|
||||
const response = await fetch('https://ect.oxylabs.io/v1/jobs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...oxylabsHttpAuth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.toString(),
|
||||
filters: {
|
||||
crawl: [baseRegex, subpathRegex],
|
||||
process: [baseRegex, subpathRegex],
|
||||
max_depth: 2,
|
||||
max_urls: job.data.limit,
|
||||
},
|
||||
scrape_params: {
|
||||
source: "universal",
|
||||
user_agent_type: "desktop",
|
||||
render: "html",
|
||||
},
|
||||
output: {
|
||||
type_: "html", // Changed from "sitemap" to "html"
|
||||
aggregate_chunk_size_bytes: 100 * 1024 * 1024, // 100 MB
|
||||
},
|
||||
context: {
|
||||
follow_redirects: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, ${response.statusText}, body: ${errorBody}`);
|
||||
}
|
||||
|
||||
const jobData = await response.json();
|
||||
const jobId = jobData.id;
|
||||
logger.log(`Crawl job initiated. Job ID: ${jobId}`);
|
||||
return jobId;
|
||||
}, 3);
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
}, {
|
||||
$set: {
|
||||
'data.oxylabsId': oxylabsId,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.log("Using existing oxylabs job with ID", oxylabsId);
|
||||
}
|
||||
|
||||
// Poll for job completion
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, oxylabsStatusPollInterval));
|
||||
logger.log(`Checking job status...`);
|
||||
const jobStatusResponse = await retryable(async () => {
|
||||
const response = await fetch(`https://ect.oxylabs.io/v1/jobs/${oxylabsId}`, {
|
||||
headers: oxylabsHttpAuth,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch job status: ${response.statusText}`);
|
||||
}
|
||||
return response;
|
||||
}, 3);
|
||||
const jobStatus = await jobStatusResponse.json();
|
||||
const jobCompleted = jobStatus.events.some((event: { event: string; status: string }) =>
|
||||
event.event === "job_results_aggregated" && event.status === "done"
|
||||
);
|
||||
if (jobCompleted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
logger.log('Crawl job completed successfully');
|
||||
|
||||
// Get the list of aggregate result chunks
|
||||
logger.log('Fetching aggregate result chunks...');
|
||||
const aggregateResponse = await retryable(async () => {
|
||||
const response = await fetch(`https://ect.oxylabs.io/v1/jobs/${oxylabsId}/aggregate`, {
|
||||
headers: oxylabsHttpAuth,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch aggregate results: ${response.statusText}`);
|
||||
}
|
||||
return response;
|
||||
}, 3);
|
||||
const aggregateData = await aggregateResponse.json();
|
||||
logger.log('Aggregate chunks response:', JSON.stringify(aggregateData));
|
||||
|
||||
// Download and write JSONL content to file
|
||||
const file = await fs.open('crawled-oxylabs.jsonl', 'w');
|
||||
const ws = file.createWriteStream();
|
||||
try {
|
||||
for (const chunk of aggregateData.chunk_urls) {
|
||||
logger.log("Fetching chunk", chunk.href);
|
||||
// During development, i found that oxylabs returns http URLs
|
||||
// Convert chunk href URL to https if it's http
|
||||
const secureChunkUrl = new URL(chunk.href);
|
||||
if (secureChunkUrl.protocol === 'http:') {
|
||||
secureChunkUrl.protocol = 'https:';
|
||||
}
|
||||
const chunkResponse = await retryable(async () => {
|
||||
const response = await fetch(secureChunkUrl.toString(), {
|
||||
headers: oxylabsHttpAuth,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("No body in chunk response");
|
||||
}
|
||||
return response;
|
||||
}, 3);
|
||||
|
||||
const chunkContent = await chunkResponse.text();
|
||||
ws.write(chunkContent);
|
||||
if (!chunkContent.endsWith('\n')) {
|
||||
ws.write('\n');
|
||||
}
|
||||
logger.log("Wrote chunk to file", chunk.href);
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
ws.close();
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeFirecrawlAndOxylabs(_logger: PrefixLogger): Promise<Set<string>> {
|
||||
const logger = _logger.child('mergeFirecrawlAndOxylabs');
|
||||
const urlSet = new Set<string>();
|
||||
const outputFile = await fs.open('crawled.jsonl', 'w');
|
||||
const outputStream = outputFile.createWriteStream();
|
||||
|
||||
let firecrawlCount = 0;
|
||||
let oxylabsCount = 0;
|
||||
|
||||
try {
|
||||
// Read Firecrawl JSONL file
|
||||
const firecrawlFile = await fs.open('crawled-firecrawl.jsonl', 'r');
|
||||
try {
|
||||
for await (const line of firecrawlFile.readLines()) {
|
||||
// if line is empty, skip it
|
||||
if (line.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
const fcDoc: FirecrawlDocument = JSON.parse(line);
|
||||
urlSet.add(fcDoc.metadata?.sourceURL || '');
|
||||
const webpage = {
|
||||
url: fcDoc.metadata?.sourceURL || '',
|
||||
markdown: fcDoc.markdown || '',
|
||||
title: fcDoc.metadata?.title || '',
|
||||
html: fcDoc.html || '',
|
||||
} as Webpage;
|
||||
outputStream.write(JSON.stringify(webpage) + '\n');
|
||||
firecrawlCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
await firecrawlFile.close();
|
||||
}
|
||||
|
||||
// Read OxyLabs JSONL file
|
||||
const oxylabsFile = await fs.open('crawled-oxylabs.jsonl', 'r');
|
||||
try {
|
||||
let lineNumber = 0;
|
||||
for await (const line of oxylabsFile.readLines()) {
|
||||
lineNumber++;
|
||||
// if line is empty, skip it
|
||||
if (line.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
let oxDoc: OxylabsDocument;
|
||||
try {
|
||||
oxDoc = JSON.parse(line);
|
||||
} catch (e) {
|
||||
logger.log("Error parsing line number", lineNumber);
|
||||
throw e;
|
||||
}
|
||||
if (urlSet.has(oxDoc.url)) {
|
||||
continue;
|
||||
}
|
||||
urlSet.add(oxDoc.url);
|
||||
|
||||
// parse the html using cheerio
|
||||
// and extract the title
|
||||
const $ = cheerio.load(oxDoc.content);
|
||||
const title = $('title').text();
|
||||
const webpage = {
|
||||
url: oxDoc.url,
|
||||
markdown: '',
|
||||
title: title,
|
||||
html: oxDoc.content,
|
||||
} as Webpage;
|
||||
outputStream.write(JSON.stringify(webpage) + '\n');
|
||||
oxylabsCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
await oxylabsFile.close();
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
outputStream.end();
|
||||
await outputFile.close();
|
||||
}
|
||||
|
||||
logger.log(`Merged Firecrawl and OxyLabs data. Total unique URLs: ${urlSet.size}`);
|
||||
logger.log(`URLs crawled by Firecrawl: ${firecrawlCount}`);
|
||||
logger.log(`URLs crawled by OxyLabs: ${oxylabsCount}`);
|
||||
return urlSet;
|
||||
}
|
||||
|
||||
async function saveWebpagesToMongodb(logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>) {
|
||||
await batchMode({
|
||||
inputFilePath: 'rewritten.jsonl',
|
||||
batchSize: 100,
|
||||
processBatch: async (batch: Webpage[]) => {
|
||||
// perform a bulkwrite operation on the mongodb webpages collection
|
||||
// it is possible that the webpage already exists in the collection
|
||||
// in which case we should update the existing document, otherwise
|
||||
// we should insert a new document with _id = sourceURL
|
||||
const bulkWriteOps = [];
|
||||
for (const doc of batch) {
|
||||
bulkWriteOps.push({
|
||||
updateOne: {
|
||||
filter: { _id: doc.url },
|
||||
update: {
|
||||
$set: {
|
||||
title: doc.title,
|
||||
contentSimple: doc.markdown,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
},
|
||||
upsert: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
if (bulkWriteOps.length === 0) {
|
||||
return;
|
||||
}
|
||||
await webpagesCollection.bulkWrite(bulkWriteOps);
|
||||
logger.log("Saved webpage contents to mongo", batch.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function rewrite(_logger: PrefixLogger) {
|
||||
const logger = _logger.child('rewrite');
|
||||
|
||||
await batchMode({
|
||||
inputFilePath: 'crawled.jsonl',
|
||||
outputFilePath: 'rewritten.jsonl',
|
||||
batchSize: 10,
|
||||
processBatch: async (batch: Webpage[]): Promise<Webpage[]> => {
|
||||
// use cheerio to strip extraneous tags and attributes
|
||||
batch.forEach((doc) => {
|
||||
const $ = cheerio.load(doc.html);
|
||||
[
|
||||
"aside",
|
||||
"audio",
|
||||
"button",
|
||||
"canvas",
|
||||
"embed",
|
||||
"footer",
|
||||
"form",
|
||||
"header",
|
||||
"iframe",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"nav",
|
||||
"noscript",
|
||||
"object",
|
||||
"script",
|
||||
"select",
|
||||
"style",
|
||||
"svg",
|
||||
"textarea",
|
||||
"video"
|
||||
].forEach((tag) => {
|
||||
$(tag).remove();
|
||||
});
|
||||
|
||||
// Remove comments
|
||||
$('*').contents().filter(function () {
|
||||
return this.type === 'comment';
|
||||
}).remove();
|
||||
|
||||
// Remove most attributes, but keep some for semantic meaning
|
||||
$('*').each(function () {
|
||||
const attrsToKeep = ['href', 'src', 'alt', 'title'];
|
||||
const attrs = $(this).attr();
|
||||
for (const attr in attrs) {
|
||||
if (!attrsToKeep.includes(attr)) {
|
||||
$(this).removeAttr(attr);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove empty elements
|
||||
$('*').filter(function () {
|
||||
return $(this).text().trim() === '' && $(this).children().length === 0;
|
||||
}).remove();
|
||||
|
||||
doc.html = $.html();
|
||||
});
|
||||
|
||||
const prompt = `
|
||||
Rewrite the below html article as Markdown by removing all extra content that does not belong in the main help content for the topic. Extra content can include extraneous links, generic website text, any content about related articles, etc.
|
||||
|
||||
Tip: Such content will generally be placed at the start and / or at the end of the article. You can identify the topic from the article's URL and/or Title.
|
||||
|
||||
Strictly do not make any other changes to the article.
|
||||
|
||||
<START_ARTICLE_CONTENT>
|
||||
Title: {{title}}
|
||||
{{content}}
|
||||
<END_ARTICLE_CONTENT>
|
||||
`,
|
||||
rewritten = await Promise.all(batch.map(async (doc) => {
|
||||
try {
|
||||
// if doc already contains markdown, skip it
|
||||
// if (doc.markdown) {
|
||||
// return doc;
|
||||
// }
|
||||
const now = Date.now();
|
||||
const { text } = await generateText({
|
||||
model: openai('gpt-4o'),
|
||||
prompt: prompt
|
||||
.replace('{{title}}', doc.title)
|
||||
.replace('{{content}}', doc.html),
|
||||
});
|
||||
// log the time taken (in s) to rewrite the text
|
||||
logger.log("\tCompleted rewrite", doc.url, (Date.now() - now) / 1000, "s");
|
||||
return {
|
||||
...doc,
|
||||
markdown: text,
|
||||
};
|
||||
} catch (e) {
|
||||
return doc;
|
||||
}
|
||||
}));
|
||||
logger.log("Rewrote batch of documents", batch.length);
|
||||
return rewritten;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function chunk(logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>) {
|
||||
await batchMode({
|
||||
inputFilePath: 'rewritten.jsonl',
|
||||
outputFilePath: 'chunked.jsonl',
|
||||
batchSize: 1000,
|
||||
processBatch: async (batch: Webpage[]): Promise<Document[]> => {
|
||||
const results = [];
|
||||
for await (const doc of batch) {
|
||||
const splits = await splitter.createDocuments([doc.markdown]);
|
||||
splits.forEach((split) => {
|
||||
split.metadata.sourceURL = doc.url;
|
||||
split.metadata.title = doc.title;
|
||||
split.metadata.sourceId = job._id.toString();
|
||||
});
|
||||
results.push(...splits);
|
||||
}
|
||||
logger.log("Chunked batch of documents", batch.length);
|
||||
return results;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function embeddings(logger: PrefixLogger) {
|
||||
await batchMode({
|
||||
inputFilePath: 'chunked.jsonl',
|
||||
outputFilePath: 'embeddings.jsonl',
|
||||
batchSize: 200,
|
||||
processBatch: async (batch: Document[]): Promise<z.infer<typeof EmbeddingDoc>[]> => {
|
||||
const { embeddings } = await embedMany({
|
||||
model: embeddingModel,
|
||||
values: batch.map((doc) => doc.pageContent)
|
||||
});
|
||||
|
||||
logger.log("Embedded batch of documents", batch.length);
|
||||
return batch.map((doc, i) => ({
|
||||
sourceId: doc.metadata.sourceId as string,
|
||||
content: doc.pageContent,
|
||||
metadata: {
|
||||
sourceURL: doc.metadata.sourceURL as string,
|
||||
title: doc.metadata.title as string,
|
||||
},
|
||||
embeddings: embeddings[i],
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function mongodb(logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>) {
|
||||
logger.log("Deleting old embeddings...");
|
||||
await embeddingsCollection.deleteMany({ sourceId: job._id.toString() });
|
||||
|
||||
await batchMode({
|
||||
inputFilePath: 'embeddings.jsonl',
|
||||
batchSize: 100,
|
||||
processBatch: async (batch: z.infer<typeof EmbeddingDoc>[]) => {
|
||||
await embeddingsCollection.insertMany(batch);
|
||||
logger.log("Inserted batch of documents", batch.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// fetch next job from mongodb
|
||||
(async () => {
|
||||
while (true) {
|
||||
console.log("Polling for job...")
|
||||
const now = Date.now();
|
||||
const job = await dataSourcesCollection.findOneAndUpdate(
|
||||
{
|
||||
$and: [
|
||||
{ 'data.type': { $in: ["crawl", "urls"] } },
|
||||
{
|
||||
$or: [
|
||||
{ status: "new" },
|
||||
{
|
||||
status: "error",
|
||||
attempts: { $lt: 3 },
|
||||
},
|
||||
{
|
||||
status: "error",
|
||||
lastAttemptAt: { $lt: new Date(now - 5 * minute).toISOString() },
|
||||
},
|
||||
{
|
||||
status: "processing",
|
||||
lastAttemptAt: { $lt: new Date(now - 12 * hour).toISOString() },
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: "processing",
|
||||
lastAttemptAt: new Date().toISOString(),
|
||||
},
|
||||
$inc: {
|
||||
attempts: 1
|
||||
},
|
||||
},
|
||||
{ returnDocument: "after", sort: { createdAt: 1 } }
|
||||
);
|
||||
if (job === null) {
|
||||
// if no doc found, sleep for a bit and start again
|
||||
await new Promise(resolve => setTimeout(resolve, 5 * second));
|
||||
continue;
|
||||
}
|
||||
|
||||
// pick a job as a test from db
|
||||
// const job = await dataSourcesCollection.findOne({
|
||||
// _id: new ObjectId("6715e9218a128eae83550cc9"),
|
||||
// });
|
||||
// assert(job !== null);
|
||||
|
||||
const logger = new PrefixLogger(job._id.toString());
|
||||
logger.log(`Starting job ${job._id}. Type: ${job.data.type}`);
|
||||
|
||||
try {
|
||||
let firecrawlResult;
|
||||
let oxylabsResult;
|
||||
if (job.data.type === "crawl") {
|
||||
// Run the crawl using firecrawl and oxylabs in parallel
|
||||
// If both fail, throw an error; if one fails, log the error and continue
|
||||
logger.log("Starting Firecrawl and OxyLabs crawls in parallel...");
|
||||
[firecrawlResult, oxylabsResult] = await Promise.allSettled([
|
||||
crawlUsingFirecrawl(logger, job as WithId<z.infer<typeof DataSource>> & { data: { type: 'crawl' } }),
|
||||
crawlUsingOxylabs(logger, job as WithId<z.infer<typeof DataSource>> & { data: { type: 'crawl' } }),
|
||||
]);
|
||||
} else if (job.data.type === "urls") {
|
||||
// scrape the urls using firecrawl and oxylabs in parallel
|
||||
logger.log("Starting Firecrawl and OxyLabs scrapes in parallel...");
|
||||
[firecrawlResult, oxylabsResult] = await Promise.allSettled([
|
||||
scrapeUsingFirecrawl(logger, job as WithId<z.infer<typeof DataSource>> & { data: { type: 'urls' } }),
|
||||
scrapeUsingOxylabs(logger, job as WithId<z.infer<typeof DataSource>> & { data: { type: 'urls' } }),
|
||||
]);
|
||||
}
|
||||
assert(firecrawlResult !== undefined);
|
||||
assert(oxylabsResult !== undefined);
|
||||
if (firecrawlResult.status === 'rejected' && oxylabsResult.status === 'rejected') {
|
||||
logger.log('Both Firecrawl and OxyLabs jobs failed', {
|
||||
firecrawlError: firecrawlResult.reason,
|
||||
oxylabsError: oxylabsResult.reason
|
||||
});
|
||||
throw new Error('Both Firecrawl and OxyLabs jobs failed');
|
||||
}
|
||||
if (firecrawlResult.status === 'rejected') {
|
||||
logger.log('Firecrawl job failed, but OxyLabs succeeded:', {
|
||||
error: firecrawlResult.reason
|
||||
});
|
||||
}
|
||||
if (oxylabsResult.status === 'rejected') {
|
||||
logger.log('OxyLabs job failed, but Firecrawl succeeded:', {
|
||||
error: oxylabsResult.reason
|
||||
});
|
||||
}
|
||||
// merge the firecrawl and oxylabs results
|
||||
const crawledUrls = await mergeFirecrawlAndOxylabs(logger);
|
||||
// update the job with the crawled urls
|
||||
if (job.data.type === "crawl") {
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { 'data.crawledUrls': Array.from(crawledUrls).join('\n') } });
|
||||
} else if (job.data.type === "urls") {
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { 'data.scrapedUrls': Array.from(crawledUrls).join('\n') } });
|
||||
}
|
||||
// rewrite the merged results as simplified html and markdown
|
||||
await rewrite(logger);
|
||||
await saveWebpagesToMongodb(logger, job);
|
||||
await chunk(logger, job);
|
||||
await embeddings(logger);
|
||||
await mongodb(logger, job);
|
||||
|
||||
// if this is a scrape urls job, compare the input urls with the scraped urls
|
||||
// if there are any urls that were not scraped, set a missingUrls field on the job
|
||||
if (job.data.type === "urls") {
|
||||
const missingUrls = job.data.urls.filter((url: string) => !crawledUrls.has(url));
|
||||
if (missingUrls.length > 0) {
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { 'data.missingUrls': missingUrls.join('\n') } });
|
||||
} else {
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { 'data.missingUrls': null } });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.log("Error processing job; will retry:", e);
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { status: "error" } });
|
||||
continue;
|
||||
}
|
||||
|
||||
// mark job as complete
|
||||
logger.log("Marking job as completed...");
|
||||
await dataSourcesCollection.updateOne({ _id: job._id }, { $set: { status: "completed" } });
|
||||
// break;
|
||||
}
|
||||
})();
|
||||
|
||||
19
apps/rowboat/app/site.webmanifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
21
apps/rowboat/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
45
apps/rowboat/components/ui/resizable.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
23
apps/rowboat/hooks/use-click-away.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
export function useClickAway(
|
||||
ref: RefObject<HTMLElement>,
|
||||
handler: (event: MouseEvent | TouchEvent) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
6
apps/rowboat/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||