add rowboat app

This commit is contained in:
ramnique 2025-01-13 15:31:31 +05:30
parent b83b5f8a07
commit 10f76ef49f
117 changed files with 25370 additions and 0 deletions

1
apps/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.DS_Store

View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

42
apps/rowboat/.gitignore vendored Normal file
View 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
View 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
View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,3 @@
import { handleAuth } from '@auth0/nextjs-auth0';
export const GET = handleAuth();

View 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);
});
}

View 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);
});
}

View 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,
});
});
}

View 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,
});
});
}

View 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);
});
}

View 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);
});
}

View 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);
});
}

View 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
View 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">&copy; 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>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View 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;
}
}

View 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>;
}

View 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} />;
}

View 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>
}

View 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>}
</>;
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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}`);
}}
/>;
}

View 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>;
};

View 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>
}

View file

@ -0,0 +1,3 @@
import { openai } from "@ai-sdk/openai";
export const embeddingModel = openai.embedding('text-embedding-3-small');

View file

@ -0,0 +1,2 @@
import dotenv from 'dotenv'
dotenv.config({path: [".env.local", ".env"]});

View 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");

View 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,
};
}

View 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: [],
};

View 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" />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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 &rarr;
</Link>
}

View file

@ -0,0 +1,5 @@
import { App } from "./app";
export default function Home() {
return <App />
}

View 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 &lt;/body&gt; 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&apos;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>;
}

View 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>
);
}

View 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} />;
}

View 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&apos;t be shown again!
</div>
)}
</div>
);
}

View 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>
);
}

View 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 >;
}

View 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>;
}

View 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>;
}

View file

@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
export default function Page({
params
}: {
params: { projectId: string }
}) {
redirect(`/projects/${params.projectId}/workflow`);
}

View 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>;
}

View 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>;
}

View file

@ -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>}
/>;
}

View 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>;
}

View file

@ -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 &rarr;
</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>
);
}

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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} />;
}

View file

@ -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>;
}

View file

@ -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>;
}

View 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>;
}

View 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>;
}

View 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}
/>;
}

View file

@ -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} />;
}

View file

@ -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&hellip;
</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>;
}

View 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>;
}

View file

@ -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>;
}

View 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 &apos;start&apos; agent</SelectItem>
</Select>
</div>
</div>
</Pane>;
}

View file

@ -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>;
}

View 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}
/>}
</>
}

View 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>
);
}

View file

@ -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>;
// }

View 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}
/>;
}

View 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>;
}

View 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>;
}

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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>
}

View 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>
);
}

View 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>
</>;
}

View 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>;
}

View 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&hellip;</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>,
}}
/>
</>;
}

View file

@ -0,0 +1,5 @@
import App from "./app";
export default function Page() {
return <App />
}

View 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 >
)
}

View 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

View 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;
}
})();

View 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"
}

View 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"
}

View 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 }

View 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]);
}

View 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))
}

Some files were not shown because too many files have changed in this diff Show more