mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
f9d2c31238
31 changed files with 781 additions and 529 deletions
|
|
@ -1,24 +1,31 @@
|
||||||
'use server';
|
'use server';
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ObjectId } from "mongodb";
|
import { ObjectId } from "mongodb";
|
||||||
import { db, dataSourcesCollection, embeddingsCollection, projectsCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "../lib/mongodb";
|
import { db, dataSourcesCollection, projectsCollection, projectMembersCollection, dataSourceDocsCollection } from "../lib/mongodb";
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { templates } from "../lib/project_templates";
|
import { templates } from "../lib/project_templates";
|
||||||
import { authCheck } from "./auth_actions";
|
import { authCheck } from "./auth_actions";
|
||||||
import { User, WithStringId } from "../lib/types/types";
|
import { User, WithStringId } from "../lib/types/types";
|
||||||
import { ApiKey } from "../lib/types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { Project } from "../lib/types/project_types";
|
import { Project } from "../lib/types/project_types";
|
||||||
import { USE_AUTH } from "../lib/feature_flags";
|
import { USE_AUTH } from "../lib/feature_flags";
|
||||||
import { authorizeUserAction } from "./billing_actions";
|
import { authorizeUserAction } from "./billing_actions";
|
||||||
import { Workflow } from "../lib/types/workflow_types";
|
import { Workflow } from "../lib/types/workflow_types";
|
||||||
import { container } from "@/di/container";
|
import { container } from "@/di/container";
|
||||||
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
import { ICreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller";
|
||||||
|
import { IListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller";
|
||||||
|
import { IDeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller";
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
|
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
|
||||||
|
|
||||||
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
|
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
|
||||||
|
const createApiKeyController = container.resolve<ICreateApiKeyController>('createApiKeyController');
|
||||||
|
const listApiKeysController = container.resolve<IListApiKeysController>('listApiKeysController');
|
||||||
|
const deleteApiKeyController = container.resolve<IDeleteApiKeyController>('deleteApiKeyController');
|
||||||
|
const apiKeysRepository = container.resolve<IApiKeysRepository>('apiKeysRepository');
|
||||||
|
|
||||||
export async function listTemplates() {
|
export async function listTemplates() {
|
||||||
const templatesArray = Object.entries(templates)
|
const templatesArray = Object.entries(templates)
|
||||||
|
|
@ -180,36 +187,32 @@ export async function updateWebhookUrl(projectId: string, url: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApiKey(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>> {
|
export async function createApiKey(projectId: string): Promise<z.infer<typeof ApiKey>> {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
|
return await createApiKeyController.execute({
|
||||||
// count existing keys
|
caller: 'user',
|
||||||
const count = await apiKeysCollection.countDocuments({ projectId });
|
userId: user._id,
|
||||||
if (count >= 3) {
|
|
||||||
throw new Error('Maximum number of API keys reached');
|
|
||||||
}
|
|
||||||
|
|
||||||
// create key
|
|
||||||
const key = crypto.randomBytes(32).toString('hex');
|
|
||||||
const doc: z.infer<typeof ApiKey> = {
|
|
||||||
projectId,
|
projectId,
|
||||||
key,
|
});
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await apiKeysCollection.insertOne(doc);
|
|
||||||
const { _id, ...rest } = doc as WithStringId<z.infer<typeof ApiKey>>;
|
|
||||||
return { ...rest, _id: _id.toString() };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(projectId: string, id: string) {
|
export async function deleteApiKey(projectId: string, id: string) {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) });
|
return await deleteApiKeyController.execute({
|
||||||
|
caller: 'user',
|
||||||
|
userId: user._id,
|
||||||
|
projectId,
|
||||||
|
id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> {
|
export async function listApiKeys(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
const keys = await apiKeysCollection.find({ projectId }).toArray();
|
return await listApiKeysController.execute({
|
||||||
return keys.map(k => ({ ...k, _id: k._id.toString() }));
|
caller: 'user',
|
||||||
|
userId: user._id,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProjectName(projectId: string, name: string) {
|
export async function updateProjectName(projectId: string, name: string) {
|
||||||
|
|
@ -227,9 +230,7 @@ export async function deleteProject(projectId: string) {
|
||||||
await projectAuthCheck(projectId);
|
await projectAuthCheck(projectId);
|
||||||
|
|
||||||
// delete api keys
|
// delete api keys
|
||||||
await apiKeysCollection.deleteMany({
|
await apiKeysRepository.deleteAll(projectId);
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// delete embeddings
|
// delete embeddings
|
||||||
const sources = await dataSourcesCollection.find({
|
const sources = await dataSourcesCollection.find({
|
||||||
|
|
@ -243,9 +244,6 @@ export async function deleteProject(projectId: string) {
|
||||||
const ids = sources.map(s => s._id);
|
const ids = sources.map(s => s._id);
|
||||||
|
|
||||||
// delete data sources
|
// delete data sources
|
||||||
await embeddingsCollection.deleteMany({
|
|
||||||
sourceId: { $in: ids.map(i => i.toString()) },
|
|
||||||
});
|
|
||||||
await dataSourcesCollection.deleteMany({
|
await dataSourcesCollection.deleteMany({
|
||||||
_id: {
|
_id: {
|
||||||
$in: ids,
|
$in: ids,
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ function TextInputField({
|
||||||
|
|
||||||
{/* Input field */}
|
{/* Input field */}
|
||||||
{mentions ? (
|
{mentions ? (
|
||||||
<div className="w-full">
|
<div className="w-full min-h-[300px]">
|
||||||
<MentionsEditor
|
<MentionsEditor
|
||||||
atValues={mentionsAtValues}
|
atValues={mentionsAtValues}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -264,7 +264,7 @@ function TextInputField({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
size={getInputSize()}
|
size={getInputSize()}
|
||||||
minRows={3}
|
minRows={12}
|
||||||
maxRows={20}
|
maxRows={20}
|
||||||
isInvalid={!isValid}
|
isInvalid={!isValid}
|
||||||
errorMessage={validationResult?.errorMessage}
|
errorMessage={validationResult?.errorMessage}
|
||||||
|
|
@ -321,11 +321,13 @@ function TextInputField({
|
||||||
{/* Content display */}
|
{/* Content display */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[40px] transition-all duration-200",
|
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 transition-all duration-200",
|
||||||
{
|
{
|
||||||
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
|
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
|
||||||
"cursor-not-allowed opacity-60": locked || disabled,
|
"cursor-not-allowed opacity-60": locked || disabled,
|
||||||
"border-0 bg-transparent p-0": inline,
|
"border-0 bg-transparent p-0": inline,
|
||||||
|
"min-h-[300px]": multiline,
|
||||||
|
"min-h-[40px]": !multiline,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
onClick={() => !locked && !disabled && setIsEditing(true)}
|
onClick={() => !locked && !disabled && setIsEditing(true)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { MongoClient } from "mongodb";
|
import { MongoClient } from "mongodb";
|
||||||
import { User, Webpage } from "./types/types";
|
import { User, Webpage } from "./types/types";
|
||||||
import { Workflow } from "./types/workflow_types";
|
import { Workflow } from "./types/workflow_types";
|
||||||
import { ApiKey } from "./types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { ProjectMember } from "./types/project_types";
|
import { ProjectMember } from "./types/project_types";
|
||||||
import { Project } from "./types/project_types";
|
import { Project } from "./types/project_types";
|
||||||
import { EmbeddingDoc } from "./types/datasource_types";
|
import { EmbeddingDoc } from "./types/datasource_types";
|
||||||
|
|
@ -16,12 +16,9 @@ const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mong
|
||||||
export const db = client.db("rowboat");
|
export const db = client.db("rowboat");
|
||||||
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
|
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
|
||||||
export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs");
|
export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs");
|
||||||
export const embeddingsCollection = db.collection<z.infer<typeof EmbeddingDoc>>("embeddings");
|
|
||||||
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
|
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
|
||||||
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
|
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 agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
|
||||||
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
|
|
||||||
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||||
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
||||||
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,3 @@ export const ProjectMember = z.object({
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
lastUpdatedAt: z.string().datetime(),
|
lastUpdatedAt: z.string().datetime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ApiKey = z.object({
|
|
||||||
projectId: z.string(),
|
|
||||||
key: z.string(),
|
|
||||||
createdAt: z.string().datetime(),
|
|
||||||
lastUsedAt: z.string().datetime().optional(),
|
|
||||||
});
|
|
||||||
|
|
@ -4,14 +4,10 @@ import { Metadata } from "next";
|
||||||
import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
|
import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
import { getProjectConfig, updateProjectName, updateWebhookUrl, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
||||||
import { CopyButton } from "../../../../components/common/copy-button";
|
import { CopyButton } from "../../../../components/common/copy-button";
|
||||||
import { InputField } from "../../../lib/components/input-field";
|
import { InputField } from "../../../lib/components/input-field";
|
||||||
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
||||||
import { WithStringId } from "../../../lib/types/types";
|
|
||||||
import { ApiKey } from "../../../lib/types/project_types";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { RelativeTime } from "@primer/react";
|
|
||||||
import { Label } from "../../../lib/components/label";
|
import { Label } from "../../../lib/components/label";
|
||||||
import { FormSection } from "../../../lib/components/form-section";
|
import { FormSection } from "../../../lib/components/form-section";
|
||||||
import { Panel } from "@/components/common/panel-common";
|
import { Panel } from "@/components/common/panel-common";
|
||||||
|
|
@ -108,156 +104,6 @@ export function BasicSettingsSection({
|
||||||
</Section>;
|
</Section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKeysSection({
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [message, setMessage] = useState<{
|
|
||||||
type: 'success' | 'error' | 'info';
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadKeys = async () => {
|
|
||||||
const keys = await listApiKeys(projectId);
|
|
||||||
setKeys(keys);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
loadKeys();
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const handleCreateKey = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const key = await createApiKey(projectId);
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'API key created successfully',
|
|
||||||
});
|
|
||||||
setKeys([...keys, key]);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessage(null);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : "Failed to create API key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteKey = async (id: string) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
await deleteApiKey(projectId, id);
|
|
||||||
setKeys(keys.filter((k) => k._id !== id));
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'info',
|
|
||||||
text: 'API key deleted successfully',
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessage(null);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : "Failed to delete API key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Section title="API keys">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
API keys are used to authenticate requests to the Rowboat API.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateKey}
|
|
||||||
size="sm"
|
|
||||||
startContent={<Plus className="h-4 w-4" />}
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Create API key
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
{loading && <Spinner size="sm" />}
|
|
||||||
{!loading && <div className="border border rounded-lg text-sm">
|
|
||||||
<div className="flex items-center border-b border p-4">
|
|
||||||
<div className="flex-3 font-normal">API Key</div>
|
|
||||||
<div className="flex-1 font-normal">Created</div>
|
|
||||||
<div className="flex-1 font-normal">Last Used</div>
|
|
||||||
<div className="w-10"></div>
|
|
||||||
</div>
|
|
||||||
{message?.type === 'success' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-green-50 text-green-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
{message?.type === 'error' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-red-50 text-red-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
{message?.type === 'info' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-yellow-50 text-yellow-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{keys.map((key) => (
|
|
||||||
<div key={key._id} className="flex items-start border-b border last:border-b-0 p-4">
|
|
||||||
<div className="flex-3 p-2">
|
|
||||||
<ApiKeyDisplay apiKey={key.key} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-2">
|
|
||||||
<RelativeTime date={new Date(key.createdAt)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-2">
|
|
||||||
{key.lastUsedAt ? <RelativeTime date={new Date(key.lastUsedAt)} /> : 'Never'}
|
|
||||||
</div>
|
|
||||||
<div className="w-10 p-2">
|
|
||||||
<Dropdown>
|
|
||||||
<DropdownTrigger>
|
|
||||||
<button className="text-muted-foreground hover:text-foreground">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownTrigger>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownItem
|
|
||||||
key='delete'
|
|
||||||
className="text-destructive"
|
|
||||||
onPress={() => handleDeleteKey(key._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{keys.length === 0 && (
|
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
|
||||||
No API keys created yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</Section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecretSection({
|
export function SecretSection({
|
||||||
projectId,
|
projectId,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProjec
|
||||||
import { CopyButton } from "../../../../../components/common/copy-button";
|
import { CopyButton } from "../../../../../components/common/copy-button";
|
||||||
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||||
import { WithStringId } from "../../../../lib/types/types";
|
import { WithStringId } from "../../../../lib/types/types";
|
||||||
import { ApiKey } from "../../../../lib/types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { RelativeTime } from "@primer/react";
|
import { RelativeTime } from "@primer/react";
|
||||||
import { Label } from "../../../../lib/components/label";
|
import { Label } from "../../../../lib/components/label";
|
||||||
|
|
@ -224,7 +224,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeysSection({ projectId }: { projectId: string }) {
|
function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
|
const [keys, setKeys] = useState<z.infer<typeof ApiKey>[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: 'success' | 'error' | 'info';
|
type: 'success' | 'error' | 'info';
|
||||||
|
|
@ -270,7 +270,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await deleteApiKey(projectId, id);
|
await deleteApiKey(projectId, id);
|
||||||
setKeys(keys.filter((k) => k._id !== id));
|
setKeys(keys.filter((k) => k.id !== id));
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: 'API key deleted successfully',
|
text: 'API key deleted successfully',
|
||||||
|
|
@ -325,11 +325,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<div key={key._id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
|
<div key={key.id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
|
||||||
<div className="col-span-7">
|
<div className="col-span-7">
|
||||||
<ApiKeyDisplay
|
<ApiKeyDisplay
|
||||||
apiKey={key.key}
|
apiKey={key.key}
|
||||||
onDelete={() => handleDeleteKey(key._id)}
|
onDelete={() => handleDeleteKey(key.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 text-sm text-gray-500">
|
<div className="col-span-3 text-sm text-gray-500">
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ import { CopilotMessage } from "../../../lib/types/copilot_types";
|
||||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||||
import { DataSource } from "@/app/lib/types/datasource_types";
|
import { DataSource } from "@/app/lib/types/datasource_types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Action as WorkflowDispatch } from "../workflow/workflow_editor";
|
import { Action as WorkflowDispatch } from "@/app/projects/[projectId]/workflow/workflow_editor";
|
||||||
import { Panel } from "@/components/common/panel-common";
|
import { Panel } from "@/components/common/panel-common";
|
||||||
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
|
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
|
||||||
import { Messages } from "./components/messages";
|
import { Messages } from "./components/messages";
|
||||||
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
|
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon, Sparkles } from "lucide-react";
|
||||||
import { useCopilot } from "./use-copilot";
|
import { useCopilot } from "./use-copilot";
|
||||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||||
import { WithStringId } from "@/app/lib/types/types";
|
import { WithStringId } from "@/app/lib/types/types";
|
||||||
|
|
@ -225,7 +225,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="shrink-0 px-1 pb-6">
|
<div className="shrink-0 px-1">
|
||||||
{responseError && (
|
{responseError && (
|
||||||
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
|
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
|
||||||
<p className="text-red-600 dark:text-red-400">{responseError}</p>
|
<p className="text-red-600 dark:text-red-400">{responseError}</p>
|
||||||
|
|
@ -322,16 +322,11 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
||||||
variant="copilot"
|
variant="copilot"
|
||||||
tourTarget="copilot"
|
tourTarget="copilot"
|
||||||
showWelcome={messages.length === 0}
|
showWelcome={messages.length === 0}
|
||||||
title={
|
icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
|
||||||
<div className="flex items-center gap-3">
|
title="Skipper"
|
||||||
|
subtitle="Build your assistant"
|
||||||
|
rightActions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
|
||||||
Skipper
|
|
||||||
</div>
|
|
||||||
<Tooltip content="A copilot to help you build and modify your workflow">
|
|
||||||
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -342,10 +337,6 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 h-4" />
|
<PlusIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
rightActions={
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Spinner } from "@heroui/react";
|
import { Spinner } from "@heroui/react";
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Workflow} from "@/app/lib/types/workflow_types";
|
import { Workflow} from "@/app/lib/types/workflow_types";
|
||||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||||
|
|
@ -153,16 +153,7 @@ function InternalAssistantMessage({ content }: { content: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionPanelBlock = {
|
|
||||||
part: {
|
|
||||||
type: 'action';
|
|
||||||
action: any;
|
|
||||||
} | {
|
|
||||||
type: 'streaming_action';
|
|
||||||
action: any;
|
|
||||||
};
|
|
||||||
actionIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AssistantMessage component that renders copilot responses with action cards.
|
* AssistantMessage component that renders copilot responses with action cards.
|
||||||
|
|
@ -194,26 +185,24 @@ function AssistantMessage({
|
||||||
// Remove autoApplyEnabled and useEffect for auto-apply
|
// Remove autoApplyEnabled and useEffect for auto-apply
|
||||||
|
|
||||||
// parse actions from parts
|
// parse actions from parts
|
||||||
let parsed: z.infer<typeof CopilotResponsePart>[] = [];
|
const parsed = useMemo(() => {
|
||||||
|
const result: z.infer<typeof CopilotResponsePart>[] = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
parsed.push({
|
result.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: block.content,
|
content: block.content,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
parsed.push(enrich(block.content));
|
result.push(enrich(block.content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
// Only render text outside the panel
|
// Count action cards for tracking
|
||||||
const textBlocks = parsed.filter(part => part.type === 'text');
|
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
|
||||||
// All cards (action and streaming_action) go inside the panel
|
const totalActions = parsed.filter(part => part.type === 'action').length;
|
||||||
const cardBlocks: ActionPanelBlock[] = parsed
|
|
||||||
.map((part, actionIndex) => ({ part, actionIndex }))
|
|
||||||
.filter(({ part }) => part.type === 'action' || part.type === 'streaming_action') as ActionPanelBlock[];
|
|
||||||
const hasCards = cardBlocks.length > 0;
|
|
||||||
const totalActions = cardBlocks.filter(({ part }) => part.type === 'action').length;
|
|
||||||
const appliedCount = Array.from(appliedActions).length;
|
const appliedCount = Array.from(appliedActions).length;
|
||||||
const pendingCount = Math.max(0, totalActions - appliedCount);
|
const pendingCount = Math.max(0, totalActions - appliedCount);
|
||||||
const allApplied = pendingCount === 0 && totalActions > 0;
|
const allApplied = pendingCount === 0 && totalActions > 0;
|
||||||
|
|
@ -307,9 +296,14 @@ function AssistantMessage({
|
||||||
// Memoized handleApplyAll for useEffect dependencies
|
// Memoized handleApplyAll for useEffect dependencies
|
||||||
const handleApplyAll = useCallback(() => {
|
const handleApplyAll = useCallback(() => {
|
||||||
// Find all unapplied action indices
|
// Find all unapplied action indices
|
||||||
const unapplied = cardBlocks
|
const unapplied = parsed
|
||||||
|
.map((part, idx) => ({ part, actionIndex: idx }))
|
||||||
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
|
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
|
||||||
.map(({ part, actionIndex }) => ({ action: part.action, actionIndex }));
|
.map(({ part, actionIndex }) => ({
|
||||||
|
action: part.type === 'action' ? part.action : null,
|
||||||
|
actionIndex
|
||||||
|
}))
|
||||||
|
.filter(({ action }) => action !== null);
|
||||||
|
|
||||||
// Synchronously apply all unapplied actions
|
// Synchronously apply all unapplied actions
|
||||||
unapplied.forEach(({ action, actionIndex }) => {
|
unapplied.forEach(({ action, actionIndex }) => {
|
||||||
|
|
@ -322,7 +316,7 @@ function AssistantMessage({
|
||||||
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
|
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [cardBlocks, appliedActions, setAppliedActions, applyAction]);
|
}, [parsed, appliedActions, setAppliedActions, applyAction]);
|
||||||
|
|
||||||
// Manual single apply (from card)
|
// Manual single apply (from card)
|
||||||
const handleSingleApply = (action: any, actionIndex: number) => {
|
const handleSingleApply = (action: any, actionIndex: number) => {
|
||||||
|
|
@ -343,37 +337,20 @@ function AssistantMessage({
|
||||||
// Removed useEffect for auto-apply
|
// Removed useEffect for auto-apply
|
||||||
|
|
||||||
// Find streaming/ongoing card and extract name
|
// Find streaming/ongoing card and extract name
|
||||||
const streamingBlock = cardBlocks.find(({ part }) => part.type === 'streaming_action');
|
const streamingPart = parsed.find(part => part.type === 'streaming_action');
|
||||||
let streamingLine = '';
|
let streamingLine = '';
|
||||||
if (streamingBlock && streamingBlock.part.type === 'streaming_action' && streamingBlock.part.action && streamingBlock.part.action.name) {
|
if (streamingPart && streamingPart.type === 'streaming_action' && streamingPart.action && streamingPart.action.name) {
|
||||||
streamingLine = `Generating ${streamingBlock.part.action.name}...`;
|
streamingLine = `Generating ${streamingPart.action.name}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first card index
|
|
||||||
const firstCardIdx = parsed.findIndex(part => part.type === 'action' || part.type === 'streaming_action');
|
|
||||||
// Group blocks into: beforePanel, cardBlocks, afterPanel
|
|
||||||
const beforePanel = firstCardIdx === -1 ? parsed : parsed.slice(0, firstCardIdx);
|
|
||||||
const panelBlocks = firstCardIdx === -1 ? [] : parsed.slice(firstCardIdx).filter(part => part.type === 'action' || part.type === 'streaming_action');
|
|
||||||
// Find where the card blocks end (first non-card after first card)
|
|
||||||
let afterPanelStart = firstCardIdx;
|
|
||||||
if (firstCardIdx !== -1) {
|
|
||||||
for (let i = firstCardIdx; i < parsed.length; i++) {
|
|
||||||
if (parsed[i].type !== 'action' && parsed[i].type !== 'streaming_action') {
|
|
||||||
afterPanelStart = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const afterPanel = (firstCardIdx !== -1 && afterPanelStart > firstCardIdx) ? parsed.slice(afterPanelStart) : [];
|
|
||||||
|
|
||||||
// Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished
|
// Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished
|
||||||
const allCardsLoaded = !loading && panelBlocks.length > 0 && panelBlocks.every(part => part.type === 'action');
|
const allCardsLoaded = !loading && actionParts.length > 0 && actionParts.every(part => part.type === 'action');
|
||||||
// When all cards are loaded, show summary of agents created/updated
|
// When all cards are loaded, show summary of agents created/updated
|
||||||
let completedSummary = '';
|
let completedSummary = '';
|
||||||
if (allCardsLoaded && totalActions > 0) {
|
if (allCardsLoaded && totalActions > 0) {
|
||||||
// Count how many are create vs edit
|
// Count how many are create vs edit
|
||||||
const createCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'create_new').length;
|
const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
|
||||||
const editCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'edit').length;
|
const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
|
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
|
||||||
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
|
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
|
||||||
|
|
@ -381,51 +358,13 @@ function AssistantMessage({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if any card has an error or is cancelled
|
// Detect if any card has an error or is cancelled
|
||||||
const hasPanelWarning = cardBlocks.some(
|
const hasPanelWarning = parsed.some(
|
||||||
({ part }) =>
|
part =>
|
||||||
part.type === 'action' &&
|
part.type === 'action' &&
|
||||||
part.action &&
|
part.action &&
|
||||||
(part.action.error || ('cancelled' in part.action && part.action.cancelled))
|
(part.action.error || ('cancelled' in part.action && part.action.cancelled))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ticker summary for collapsed state (two lines)
|
|
||||||
const ticker = (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{allCardsLoaded && completedSummary ? (
|
|
||||||
<span className="font-medium text-xs sm:text-sm">{completedSummary}</span>
|
|
||||||
) : streamingLine && (
|
|
||||||
<span className="font-medium text-xs sm:text-sm">{streamingLine}</span>
|
|
||||||
)}
|
|
||||||
<span className="font-medium text-xs sm:text-sm">{appliedCount} applied, {pendingCount} pending</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyAllButton = (
|
|
||||||
<button
|
|
||||||
onClick={handleApplyAll}
|
|
||||||
disabled={allApplied} // Changed to allApplied
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-full font-medium text-sm transition-colors duration-200
|
|
||||||
${
|
|
||||||
allApplied
|
|
||||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none'
|
|
||||||
: 'bg-blue-100 dark:bg-zinc-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-zinc-800 border border-blue-200 dark:border-zinc-800 shadow-sm'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{allApplied ? (
|
|
||||||
<>
|
|
||||||
<CheckCheckIcon size={16} />
|
|
||||||
All applied!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCheckIcon size={16} />
|
|
||||||
Apply all
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Utility to filter out divider/empty markdown blocks
|
// Utility to filter out divider/empty markdown blocks
|
||||||
function isNonDividerMarkdown(content: string) {
|
function isNonDividerMarkdown(content: string) {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
|
|
@ -435,9 +374,6 @@ function AssistantMessage({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore panelOpen state if missing
|
|
||||||
const [panelOpen, setPanelOpen] = useState(false); // collapsed by default
|
|
||||||
|
|
||||||
// At the end of the render, call onStatusBarChange with the current status bar props
|
// At the end of the render, call onStatusBarChange with the current status bar props
|
||||||
// Track the latest status bar info
|
// Track the latest status bar info
|
||||||
const latestStatusBar = useRef<any>(null);
|
const latestStatusBar = useRef<any>(null);
|
||||||
|
|
|
||||||
|
|
@ -363,11 +363,12 @@ export function AgentConfig({
|
||||||
});
|
});
|
||||||
showSavedMessage();
|
showSavedMessage();
|
||||||
}}
|
}}
|
||||||
|
placeholder="Type agent instructions..."
|
||||||
markdown
|
markdown
|
||||||
multiline
|
multiline
|
||||||
mentions
|
mentions
|
||||||
mentionsAtValues={atMentions}
|
mentionsAtValues={atMentions}
|
||||||
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
|
className="h-full min-h-0 overflow-auto !mb-0 !mt-0 min-h-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Examples Section */}
|
{/* Examples Section */}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Chat } from "./components/chat";
|
||||||
import { Panel } from "@/components/common/panel-common";
|
import { Panel } from "@/components/common/panel-common";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip } from "@heroui/react";
|
import { Tooltip } from "@heroui/react";
|
||||||
import { CheckIcon, CopyIcon, PlusIcon, InfoIcon, BugIcon, BugOffIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, PlusIcon, InfoIcon, BugIcon, BugOffIcon, MessageCircle } from "lucide-react";
|
||||||
|
|
||||||
export function App({
|
export function App({
|
||||||
hidden = false,
|
hidden = false,
|
||||||
|
|
@ -56,16 +56,11 @@ export function App({
|
||||||
className={`${hidden ? 'hidden' : 'block'}`}
|
className={`${hidden ? 'hidden' : 'block'}`}
|
||||||
variant="playground"
|
variant="playground"
|
||||||
tourTarget="playground"
|
tourTarget="playground"
|
||||||
title={
|
icon={<MessageCircle className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
|
||||||
<div className="flex items-center gap-3">
|
title="Playground"
|
||||||
|
subtitle="Chat with your assistant"
|
||||||
|
rightActions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
|
||||||
Playground
|
|
||||||
</div>
|
|
||||||
<Tooltip content="Test your workflow and chat with your agents in real-time">
|
|
||||||
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -90,10 +85,6 @@ export function App({
|
||||||
<BugOffIcon className="w-4 h-4" />
|
<BugOffIcon className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
rightActions={
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,17 @@ interface SelectComposioToolkitProps {
|
||||||
tools: z.infer<typeof Workflow.shape.tools>;
|
tools: z.infer<typeof Workflow.shape.tools>;
|
||||||
onSelectToolkit: (toolkit: ToolkitType) => void;
|
onSelectToolkit: (toolkit: ToolkitType) => void;
|
||||||
initialToolkitSlug?: string | null;
|
initialToolkitSlug?: string | null;
|
||||||
|
filterByTriggers?: boolean; // New prop to filter toolkits that have triggers
|
||||||
|
filterByTools?: boolean; // New prop to filter toolkits that have tools
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectComposioToolkit({
|
export function SelectComposioToolkit({
|
||||||
projectId,
|
projectId,
|
||||||
tools,
|
tools,
|
||||||
onSelectToolkit,
|
onSelectToolkit,
|
||||||
initialToolkitSlug
|
initialToolkitSlug,
|
||||||
|
filterByTriggers = false,
|
||||||
|
filterByTools = false
|
||||||
}: SelectComposioToolkitProps) {
|
}: SelectComposioToolkitProps) {
|
||||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||||
|
|
@ -59,17 +63,16 @@ export function SelectComposioToolkit({
|
||||||
cursor = response.next_cursor;
|
cursor = response.next_cursor;
|
||||||
} while (cursor !== null);
|
} while (cursor !== null);
|
||||||
|
|
||||||
// // Only show those toolkits that
|
// Filter toolkits based on the filter props
|
||||||
// // - either do not require authentication, OR
|
let finalToolkits = allToolkits;
|
||||||
// // - have oauth2 managed by Composio
|
if (filterByTriggers) {
|
||||||
// const filteredToolkits = allToolkits.filter(toolkit => {
|
finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.triggers_count > 0);
|
||||||
// const noAuth = toolkit.no_auth;
|
}
|
||||||
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2');
|
if (filterByTools) {
|
||||||
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2');
|
finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.tools_count > 0);
|
||||||
// return noAuth || hasOAuth2;
|
}
|
||||||
// });
|
|
||||||
|
|
||||||
setToolkits(allToolkits);
|
setToolkits(finalToolkits);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
|
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
|
||||||
|
|
@ -78,7 +81,7 @@ export function SelectComposioToolkit({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectId]);
|
}, [projectId, filterByTriggers, filterByTools]);
|
||||||
|
|
||||||
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
||||||
onSelectToolkit(toolkit);
|
onSelectToolkit(toolkit);
|
||||||
|
|
@ -125,7 +128,14 @@ export function SelectComposioToolkit({
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p>
|
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{filterByTriggers
|
||||||
|
? 'Loading Composio toolkits with triggers...'
|
||||||
|
: filterByTools
|
||||||
|
? 'Loading Composio toolkits with tools...'
|
||||||
|
: 'Loading Composio toolkits...'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +171,13 @@ export function SelectComposioToolkit({
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search toolkits..."
|
placeholder={
|
||||||
|
filterByTriggers
|
||||||
|
? "Search toolkits with triggers..."
|
||||||
|
: filterByTools
|
||||||
|
? "Search toolkits with tools..."
|
||||||
|
: "Search toolkits..."
|
||||||
|
}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||||
|
|
@ -173,6 +189,8 @@ export function SelectComposioToolkit({
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
|
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
|
||||||
|
{filterByTriggers && ' with triggers'}
|
||||||
|
{filterByTools && ' with tools'}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,6 +222,7 @@ export function SelectComposioToolkit({
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
workflowTools={tools}
|
workflowTools={tools}
|
||||||
onSelectToolkit={() => handleSelectToolkit(toolkit)}
|
onSelectToolkit={() => handleSelectToolkit(toolkit)}
|
||||||
|
showTriggerCounts={filterByTriggers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -212,7 +231,14 @@ export function SelectComposioToolkit({
|
||||||
{filteredToolkits.length === 0 && !loading && (
|
{filteredToolkits.length === 0 && !loading && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'}
|
{searchQuery
|
||||||
|
? 'No toolkits found matching your search.'
|
||||||
|
: filterByTriggers
|
||||||
|
? 'No toolkits with triggers available.'
|
||||||
|
: filterByTools
|
||||||
|
? 'No toolkits with tools available.'
|
||||||
|
: 'No toolkits available.'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ interface ToolkitCardProps {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
onSelectToolkit: () => void;
|
onSelectToolkit: () => void;
|
||||||
workflowTools: z.infer<typeof Workflow.shape.tools>;
|
workflowTools: z.infer<typeof Workflow.shape.tools>;
|
||||||
|
showTriggerCounts?: boolean; // New prop to show trigger counts instead of tool counts
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolkitCard({
|
export function ToolkitCard({
|
||||||
|
|
@ -37,6 +38,7 @@ export function ToolkitCard({
|
||||||
isConnected,
|
isConnected,
|
||||||
onSelectToolkit,
|
onSelectToolkit,
|
||||||
workflowTools,
|
workflowTools,
|
||||||
|
showTriggerCounts = false,
|
||||||
}: ToolkitCardProps) {
|
}: ToolkitCardProps) {
|
||||||
const handleCardClick = useCallback(() => {
|
const handleCardClick = useCallback(() => {
|
||||||
onSelectToolkit();
|
onSelectToolkit();
|
||||||
|
|
@ -69,7 +71,9 @@ export function ToolkitCard({
|
||||||
variant="faded"
|
variant="faded"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{selectedToolsCount > 0
|
{showTriggerCounts
|
||||||
|
? `${toolkit.meta.triggers_count} triggers`
|
||||||
|
: selectedToolsCount > 0
|
||||||
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
|
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
|
||||||
: `${toolkit.meta.tools_count} tools`
|
: `${toolkit.meta.tools_count} tools`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function ToolsConfig({
|
||||||
tools={tools}
|
tools={tools}
|
||||||
onSelectToolkit={handleSelectToolkit}
|
onSelectToolkit={handleSelectToolkit}
|
||||||
initialToolkitSlug={initialToolkitSlug}
|
initialToolkitSlug={initialToolkitSlug}
|
||||||
|
filterByTools={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
|
||||||
|
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
localProjectName: string;
|
||||||
|
projectNameError: string | null;
|
||||||
|
onProjectNameChange: (value: string) => void;
|
||||||
|
publishing: boolean;
|
||||||
|
isLive: boolean;
|
||||||
|
showCopySuccess: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
showCopilot: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
onDownloadJSON: () => void;
|
||||||
|
onPublishWorkflow: () => void;
|
||||||
|
onChangeMode: (mode: 'draft' | 'live') => void;
|
||||||
|
onRevertToLive: () => void;
|
||||||
|
onToggleCopilot: () => void;
|
||||||
|
onSettingsModalOpen: () => void;
|
||||||
|
onTriggersModalOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
localProjectName,
|
||||||
|
projectNameError,
|
||||||
|
onProjectNameChange,
|
||||||
|
publishing,
|
||||||
|
isLive,
|
||||||
|
showCopySuccess,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
showCopilot,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onDownloadJSON,
|
||||||
|
onPublishWorkflow,
|
||||||
|
onChangeMode,
|
||||||
|
onRevertToLive,
|
||||||
|
onToggleCopilot,
|
||||||
|
onSettingsModalOpen,
|
||||||
|
onTriggersModalOpen,
|
||||||
|
}: TopBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-5 py-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
|
||||||
|
{/* Project Name Editor */}
|
||||||
|
<div className="flex flex-col min-w-0 max-w-xs">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={localProjectName}
|
||||||
|
onChange={(e) => onProjectNameChange(e.target.value)}
|
||||||
|
isInvalid={!!projectNameError}
|
||||||
|
errorMessage={projectNameError}
|
||||||
|
placeholder="Project name..."
|
||||||
|
variant="bordered"
|
||||||
|
size="sm"
|
||||||
|
classNames={{
|
||||||
|
base: "max-w-xs",
|
||||||
|
input: "text-base font-semibold px-2",
|
||||||
|
inputWrapper: "min-h-[28px] h-[28px] border-gray-200 dark:border-gray-700 px-0"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{publishing && <Spinner size="sm" />}
|
||||||
|
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<RadioIcon size={16} />
|
||||||
|
Live workflow
|
||||||
|
</div>}
|
||||||
|
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<PenLine size={16} />
|
||||||
|
Draft workflow
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Download JSON icon button, with tooltip, to the left of the menu */}
|
||||||
|
<Tooltip content="Download Assistant JSON">
|
||||||
|
<button
|
||||||
|
onClick={onDownloadJSON}
|
||||||
|
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
aria-label="Download JSON"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<DownloadIcon size={20} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</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-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
This version is locked. Changes applied will not be reflected.
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{!isLive && <>
|
||||||
|
<button
|
||||||
|
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
||||||
|
title="Undo"
|
||||||
|
disabled={!canUndo}
|
||||||
|
onClick={onUndo}
|
||||||
|
>
|
||||||
|
<UndoIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
||||||
|
title="Redo"
|
||||||
|
disabled={!canRedo}
|
||||||
|
onClick={onRedo}
|
||||||
|
>
|
||||||
|
<RedoIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{/* Deploy CTA - always visible */}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
onPress={onPublishWorkflow}
|
||||||
|
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
|
||||||
|
startContent={<RocketIcon size={16} />}
|
||||||
|
data-tour-target="deploy"
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
className="min-w-0 px-2 bg-green-600 hover:bg-green-700 border-l-1 border-green-500 text-white font-semibold text-sm rounded-l-none"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu aria-label="Deploy actions">
|
||||||
|
<DropdownItem
|
||||||
|
key="settings"
|
||||||
|
startContent={<SettingsIcon size={16} />}
|
||||||
|
onPress={onSettingsModalOpen}
|
||||||
|
>
|
||||||
|
API & SDK settings
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="manage-triggers"
|
||||||
|
startContent={<ZapIcon size={16} />}
|
||||||
|
onPress={onTriggersModalOpen}
|
||||||
|
>
|
||||||
|
Manage triggers
|
||||||
|
</DropdownItem>
|
||||||
|
{!isLive ? (
|
||||||
|
<>
|
||||||
|
<DropdownItem
|
||||||
|
key="view-live"
|
||||||
|
startContent={<RadioIcon size={16} />}
|
||||||
|
onPress={() => onChangeMode('live')}
|
||||||
|
>
|
||||||
|
View live version
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="reset-to-live"
|
||||||
|
startContent={<AlertTriangle size={16} />}
|
||||||
|
onPress={onRevertToLive}
|
||||||
|
className="text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Reset to live version
|
||||||
|
</DropdownItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLive && <div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
onPress={() => onChangeMode('draft')}
|
||||||
|
className="gap-2 px-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-sm"
|
||||||
|
>
|
||||||
|
Switch to draft
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{!isLive && <Button
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
onPress={onToggleCopilot}
|
||||||
|
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
|
||||||
|
startContent={showCopilot ? null : <span className="text-indigo-300">✨</span>}
|
||||||
|
>
|
||||||
|
{showCopilot ? "Hide Skipper" : "Skipper"}
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -299,6 +299,7 @@ export function TriggersModal({
|
||||||
tools={[]} // Empty array since we're not using this for tools
|
tools={[]} // Empty array since we're not using this for tools
|
||||||
onSelectToolkit={handleSelectToolkit}
|
onSelectToolkit={handleSelectToolkit}
|
||||||
initialToolkitSlug={null}
|
initialToolkitSlug={null}
|
||||||
|
filterByTriggers={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { InputField } from "@/app/lib/components/input-field";
|
||||||
import { VoiceSection } from "../config/components/voice";
|
import { VoiceSection } from "../config/components/voice";
|
||||||
import { ChatWidgetSection } from "../config/components/project";
|
import { ChatWidgetSection } from "../config/components/project";
|
||||||
import { TriggersModal } from "./components/TriggersModal";
|
import { TriggersModal } from "./components/TriggersModal";
|
||||||
|
import { TopBar } from "./components/TopBar";
|
||||||
|
|
||||||
enablePatches();
|
enablePatches();
|
||||||
|
|
||||||
|
|
@ -1221,161 +1222,31 @@ export function WorkflowEditor({
|
||||||
onSelectTool: handleSelectTool,
|
onSelectTool: handleSelectTool,
|
||||||
onSelectPrompt: handleSelectPrompt,
|
onSelectPrompt: handleSelectPrompt,
|
||||||
}}>
|
}}>
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="h-full flex flex-col gap-5">
|
||||||
<div className="shrink-0 flex justify-between items-center pb-6">
|
{/* Top Bar - Isolated like sidebar */}
|
||||||
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
|
<TopBar
|
||||||
{/* Project Name Editor */}
|
localProjectName={localProjectName}
|
||||||
<div className="flex flex-col min-w-0 max-w-xs">
|
projectNameError={projectNameError}
|
||||||
<InputField
|
onProjectNameChange={handleProjectNameChange}
|
||||||
type="text"
|
publishing={state.present.publishing}
|
||||||
value={localProjectName}
|
isLive={isLive}
|
||||||
onChange={handleProjectNameChange}
|
showCopySuccess={showCopySuccess}
|
||||||
error={projectNameError}
|
canUndo={state.currentIndex > 0}
|
||||||
placeholder="Project name..."
|
canRedo={state.currentIndex < state.patches.length}
|
||||||
className="text-lg font-semibold !min-h-[24px] !py-0.5 !border !border-gray-200 dark:!border-gray-700 !rounded-lg"
|
showCopilot={showCopilot}
|
||||||
inline={true}
|
onUndo={() => dispatch({ type: "undo" })}
|
||||||
|
onRedo={() => dispatch({ type: "redo" })}
|
||||||
|
onDownloadJSON={handleDownloadJSON}
|
||||||
|
onPublishWorkflow={handlePublishWorkflow}
|
||||||
|
onChangeMode={onChangeMode}
|
||||||
|
onRevertToLive={handleRevertToLive}
|
||||||
|
onToggleCopilot={() => setShowCopilot(!showCopilot)}
|
||||||
|
onSettingsModalOpen={onSettingsModalOpen}
|
||||||
|
onTriggersModalOpen={onTriggersModalOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
|
{/* Content Area */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{state.present.publishing && <Spinner size="sm" />}
|
|
||||||
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
|
||||||
<RadioIcon size={16} />
|
|
||||||
Live workflow
|
|
||||||
</div>}
|
|
||||||
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
|
||||||
<PenLine size={16} />
|
|
||||||
Draft workflow
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{/* Download JSON icon button, with tooltip, to the left of the menu */}
|
|
||||||
<Tooltip content="Download Assistant JSON">
|
|
||||||
<button
|
|
||||||
onClick={handleDownloadJSON}
|
|
||||||
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
|
||||||
aria-label="Download JSON"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<DownloadIcon size={20} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</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-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
|
||||||
<AlertTriangle size={16} />
|
|
||||||
This version is locked. Changes applied will not be reflected.
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{!isLive && <>
|
|
||||||
<button
|
|
||||||
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
|
||||||
title="Undo"
|
|
||||||
disabled={state.currentIndex <= 0}
|
|
||||||
onClick={() => dispatch({ type: "undo" })}
|
|
||||||
>
|
|
||||||
<UndoIcon size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
|
||||||
title="Redo"
|
|
||||||
disabled={state.currentIndex >= state.patches.length}
|
|
||||||
onClick={() => dispatch({ type: "redo" })}
|
|
||||||
>
|
|
||||||
<RedoIcon size={16} />
|
|
||||||
</button>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{/* Deploy CTA - always visible */}
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
onPress={handlePublishWorkflow}
|
|
||||||
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
|
|
||||||
startContent={<RocketIcon size={16} />}
|
|
||||||
data-tour-target="deploy"
|
|
||||||
>
|
|
||||||
Deploy
|
|
||||||
</Button>
|
|
||||||
<Dropdown>
|
|
||||||
<DropdownTrigger>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
className="min-w-0 px-2 bg-green-600 hover:bg-green-700 border-l-1 border-green-500 text-white font-semibold text-sm rounded-l-none"
|
|
||||||
>
|
|
||||||
<ChevronDownIcon size={14} />
|
|
||||||
</Button>
|
|
||||||
</DropdownTrigger>
|
|
||||||
<DropdownMenu aria-label="Deploy actions">
|
|
||||||
<DropdownItem
|
|
||||||
key="settings"
|
|
||||||
startContent={<SettingsIcon size={16} />}
|
|
||||||
onPress={onSettingsModalOpen}
|
|
||||||
>
|
|
||||||
API & SDK settings
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem
|
|
||||||
key="manage-triggers"
|
|
||||||
startContent={<ZapIcon size={16} />}
|
|
||||||
onPress={onTriggersModalOpen}
|
|
||||||
>
|
|
||||||
Manage triggers
|
|
||||||
</DropdownItem>
|
|
||||||
{!isLive ? (
|
|
||||||
<>
|
|
||||||
<DropdownItem
|
|
||||||
key="view-live"
|
|
||||||
startContent={<RadioIcon size={16} />}
|
|
||||||
onPress={() => onChangeMode('live')}
|
|
||||||
>
|
|
||||||
View live version
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem
|
|
||||||
key="reset-to-live"
|
|
||||||
startContent={<AlertTriangle size={16} />}
|
|
||||||
onPress={handleRevertToLive}
|
|
||||||
className="text-red-600 dark:text-red-400"
|
|
||||||
>
|
|
||||||
Reset to live version
|
|
||||||
</DropdownItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLive && <div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
onPress={() => onChangeMode('draft')}
|
|
||||||
className="gap-2 px-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-sm"
|
|
||||||
>
|
|
||||||
Switch to draft
|
|
||||||
</Button>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{!isLive && <Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
onPress={() => setShowCopilot(!showCopilot)}
|
|
||||||
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
|
|
||||||
startContent={showCopilot ? null : <Sparkles size={16} />}
|
|
||||||
>
|
|
||||||
{showCopilot ? "Hide Skipper" : "Skipper"}
|
|
||||||
</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
|
|
||||||
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
|
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<EntityList
|
<EntityList
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900">
|
<div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900">
|
||||||
{/* Sidebar with improved shadow and blur */}
|
{/* Sidebar with improved shadow and blur */}
|
||||||
<div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
|
<div className="h-full overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
projectId={projectId ?? undefined}
|
projectId={projectId ?? undefined}
|
||||||
useAuth={useAuth}
|
useAuth={useAuth}
|
||||||
|
|
@ -53,8 +53,8 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<main className="flex gap-2 flex-col flex-1 overflow-auto rounded-xl bg-white dark:bg-zinc-800 shadow-sm p-4">
|
<main className="flex-1 h-full overflow-auto">
|
||||||
{billingPastDue && <div className="shrink-0">
|
{billingPastDue && <div className="shrink-0 mb-2">
|
||||||
<div className="bg-red-50 text-red-500 px-2 py-1 text-sm rounded-md flex items-center gap-2">
|
<div className="bg-red-50 text-red-500 px-2 py-1 text-sm rounded-md flex items-center gap-2">
|
||||||
<span>Your subscription is past due. Please update your payment information to avoid losing access to your projects.</span>
|
<span>Your subscription is past due. Please update your payment information to avoid losing access to your projects.</span>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -68,9 +68,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,12 @@
|
||||||
.ql-editor {
|
.ql-editor {
|
||||||
font-size: 1rem !important; /* Increase base font size */
|
font-size: 1rem !important; /* Increase base font size */
|
||||||
line-height: 1.6 !important; /* Adjust line height for better readability */
|
line-height: 1.6 !important; /* Adjust line height for better readability */
|
||||||
|
min-height: 300px !important; /* Set minimum height for better editing experience */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quill editor container height */
|
||||||
|
.ql-container {
|
||||||
|
min-height: 300px !important; /* Ensure the container also has minimum height */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the rendered markdown view at its original size */
|
/* Keep the rendered markdown view at its original size */
|
||||||
|
|
|
||||||
|
|
@ -100,13 +100,12 @@ export function ComposeBoxCopilot({
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
autoResize={true}
|
autoResize={true}
|
||||||
maxHeight={120}
|
maxHeight={200}
|
||||||
className={`
|
className={`
|
||||||
min-h-0!
|
min-h-6
|
||||||
border-0! shadow-none! ring-0!
|
border-0! shadow-none! ring-0!
|
||||||
bg-transparent
|
bg-transparent
|
||||||
resize-none
|
resize-none
|
||||||
overflow-y-auto
|
|
||||||
[&::-webkit-scrollbar]:w-1
|
[&::-webkit-scrollbar]:w-1
|
||||||
[&::-webkit-scrollbar-track]:bg-transparent
|
[&::-webkit-scrollbar-track]:bg-transparent
|
||||||
[&::-webkit-scrollbar-thumb]:bg-gray-300
|
[&::-webkit-scrollbar-thumb]:bg-gray-300
|
||||||
|
|
@ -294,7 +293,7 @@ function CopilotStatusBar({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-auto max-w-[calc(100%-16px)] mx-auto flex items-center px-3 py-1 pt-2.5 pb-5 mt-2 -mb-3 rounded-xl bg-zinc-50 dark:bg-zinc-900/90 border border-zinc-300 dark:border-zinc-700 shadow-md dark:shadow-zinc-950/10 backdrop-blur-sm transition-all z-0 relative mx-2 overflow-visible">
|
<div className="w-auto max-w-[calc(100%-16px)] mx-auto flex items-center px-3 py-1 pt-2.5 pb-5 mt-2 -mb-3 rounded-xl bg-white/90 dark:bg-zinc-800/90 border border-zinc-300 dark:border-zinc-700 shadow-md dark:shadow-zinc-950/10 backdrop-blur-sm transition-all z-0 relative mx-2 overflow-visible">
|
||||||
{/* Left: context + status/ticker, flex-1, truncate as needed */}
|
{/* Left: context + status/ticker, flex-1, truncate as needed */}
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible">
|
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible">
|
||||||
{renderContext()}
|
{renderContext()}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export function ActionButton({
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
rightActions?: React.ReactNode;
|
rightActions?: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -47,6 +49,8 @@ interface PanelProps {
|
||||||
|
|
||||||
export function Panel({
|
export function Panel({
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
rightActions,
|
rightActions,
|
||||||
actions,
|
actions,
|
||||||
children,
|
children,
|
||||||
|
|
@ -65,8 +69,8 @@ export function Panel({
|
||||||
"flex flex-col rounded-xl border relative w-full",
|
"flex flex-col rounded-xl border relative w-full",
|
||||||
// Only apply overflow-hidden if no custom overflow is set (for backward compatibility)
|
// Only apply overflow-hidden if no custom overflow is set (for backward compatibility)
|
||||||
overflow ? undefined : "overflow-hidden",
|
overflow ? undefined : "overflow-hidden",
|
||||||
variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800",
|
variant === 'copilot' ? "border-transparent" : "border-zinc-200 dark:border-zinc-800",
|
||||||
"bg-white dark:bg-zinc-900",
|
variant === 'copilot' ? "bg-zinc-50 dark:bg-zinc-900" : "bg-white dark:bg-zinc-900",
|
||||||
maxHeight ? "max-h-(--panel-height)" : "h-full",
|
maxHeight ? "max-h-(--panel-height)" : "h-full",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
@ -93,7 +97,8 @@ export function Panel({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
|
"shrink-0 border-b relative",
|
||||||
|
variant === 'copilot' ? "border-zinc-300 dark:border-zinc-700" : "border-zinc-100 dark:border-zinc-800",
|
||||||
{
|
{
|
||||||
"flex flex-col gap-3 px-4 py-3": variant === 'projects',
|
"flex flex-col gap-3 px-4 py-3": variant === 'projects',
|
||||||
"flex items-center justify-between h-[53px] p-3": isEntityList,
|
"flex items-center justify-between h-[53px] p-3": isEntityList,
|
||||||
|
|
@ -113,8 +118,35 @@ export function Panel({
|
||||||
) : variant === 'copilot' ? (
|
) : variant === 'copilot' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && icon}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rightActions}
|
||||||
|
</>
|
||||||
|
) : variant === 'playground' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && icon}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{rightActions}
|
{rightActions}
|
||||||
</>
|
</>
|
||||||
) : isEntityList ? (
|
) : isEntityList ? (
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
||||||
}
|
}
|
||||||
}, [propValue, isEditing]);
|
}, [propValue, isEditing]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoResize) return;
|
if (!autoResize) return;
|
||||||
|
|
||||||
|
|
@ -69,7 +68,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
||||||
window.addEventListener('resize', adjustHeight);
|
window.addEventListener('resize', adjustHeight);
|
||||||
return () => window.removeEventListener('resize', adjustHeight);
|
return () => window.removeEventListener('resize', adjustHeight);
|
||||||
}, [localValue, autoResize, maxHeight, textareaRef]);
|
}, [localValue, autoResize, maxHeight, textareaRef]);
|
||||||
*/
|
|
||||||
|
|
||||||
const validateAndUpdate = (value: string) => {
|
const validateAndUpdate = (value: string) => {
|
||||||
if (validate) {
|
if (validate) {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@ import { ListRecurringJobRulesController } from "@/src/interface-adapters/contro
|
||||||
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
|
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
|
||||||
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
|
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
|
||||||
|
import { ListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case";
|
||||||
|
import { DeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case";
|
||||||
|
import { CreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller";
|
||||||
|
import { ListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller";
|
||||||
|
import { DeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller";
|
||||||
|
|
||||||
export const container = createContainer({
|
export const container = createContainer({
|
||||||
injectionMode: InjectionMode.PROXY,
|
injectionMode: InjectionMode.PROXY,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|
@ -96,6 +104,12 @@ container.register({
|
||||||
// api keys
|
// api keys
|
||||||
// ---
|
// ---
|
||||||
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
|
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
|
||||||
|
createApiKeyUseCase: asClass(CreateApiKeyUseCase).singleton(),
|
||||||
|
listApiKeysUseCase: asClass(ListApiKeysUseCase).singleton(),
|
||||||
|
deleteApiKeyUseCase: asClass(DeleteApiKeyUseCase).singleton(),
|
||||||
|
createApiKeyController: asClass(CreateApiKeyController).singleton(),
|
||||||
|
listApiKeysController: asClass(ListApiKeysController).singleton(),
|
||||||
|
deleteApiKeyController: asClass(DeleteApiKeyController).singleton(),
|
||||||
|
|
||||||
// jobs
|
// jobs
|
||||||
// ---
|
// ---
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,47 @@
|
||||||
|
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const CreateSchema = ApiKey.pick({
|
||||||
|
projectId: true,
|
||||||
|
key: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interface for repository operations related to API keys.
|
||||||
export interface IApiKeysRepository {
|
export interface IApiKeysRepository {
|
||||||
|
/**
|
||||||
|
* Creates a new API key for a given project.
|
||||||
|
* @param data - The data required to create an API key (projectId and key).
|
||||||
|
* @returns The created ApiKey object.
|
||||||
|
*/
|
||||||
|
create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all API keys for a given project.
|
||||||
|
* @param projectId - The ID of the project whose API keys are to be listed.
|
||||||
|
* @returns A list of ApiKey objects.
|
||||||
|
*/
|
||||||
|
listAll(projectId: string): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an API key by its ID for a given project.
|
||||||
|
* @param projectId - The ID of the project.
|
||||||
|
* @param id - The ID of the API key to delete.
|
||||||
|
* @returns True if the key was deleted, false if not found.
|
||||||
|
*/
|
||||||
|
delete(projectId: string, id: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all API keys for a given project.
|
||||||
|
* @param projectId - The ID of the project.
|
||||||
|
*/
|
||||||
|
deleteAll(projectId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an API key is valid for a project and consumes it (e.g., for rate limiting or one-time use).
|
||||||
|
* @param projectId - The ID of the project.
|
||||||
|
* @param apiKey - The API key to check and consume.
|
||||||
|
* @returns True if the key is valid and was consumed, false otherwise.
|
||||||
|
*/
|
||||||
checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
|
checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class MaxKeysReachedError extends BadRequestError {
|
||||||
|
constructor(message?: string, options?: ErrorOptions) {
|
||||||
|
super(message, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateApiKeyUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateApiKeyUseCase implements ICreateApiKeyUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const { caller, userId, apiKey, projectId } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// count existing keys
|
||||||
|
const keys = await this.apiKeysRepository.listAll(projectId);
|
||||||
|
if (keys.length >= 3) {
|
||||||
|
throw new MaxKeysReachedError("You can only have up to 3 API keys per project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random key using crypto
|
||||||
|
const key = crypto.randomBytes(32).toString('hex');
|
||||||
|
return await this.apiKeysRepository.create({ projectId, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IDeleteApiKeyUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteApiKeyUseCase implements IDeleteApiKeyUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||||
|
const { caller, userId, apiKey, projectId, id } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
return await this.apiKeysRepository.delete(projectId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IListApiKeysUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListApiKeysUseCase implements IListApiKeysUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const { caller, userId, apiKey, projectId } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
return await this.apiKeysRepository.listAll(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/rowboat/src/entities/models/api-key.ts
Normal file
9
apps/rowboat/src/entities/models/api-key.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ApiKey = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
lastUsedAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,63 @@
|
||||||
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
import { apiKeysCollection } from "@/app/lib/mongodb";
|
import { db } from "@/app/lib/mongodb";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import { CreateSchema } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
|
||||||
|
const DocSchema = ApiKey
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
});
|
||||||
|
|
||||||
export class MongoDBApiKeysRepository implements IApiKeysRepository {
|
export class MongoDBApiKeysRepository implements IApiKeysRepository {
|
||||||
|
private readonly collection = db.collection<z.infer<typeof DocSchema>>("api_keys");
|
||||||
|
|
||||||
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
|
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
|
||||||
const result = await apiKeysCollection.findOneAndUpdate(
|
const result = await this.collection.findOneAndUpdate(
|
||||||
{ projectId, key: apiKey },
|
{ projectId, key: apiKey },
|
||||||
{ $set: { lastUsedAt: new Date().toISOString() } }
|
{ $set: { lastUsedAt: new Date().toISOString() } }
|
||||||
);
|
);
|
||||||
return !!result;
|
return !!result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const _id = new ObjectId();
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.collection.insertOne({
|
||||||
|
_id,
|
||||||
|
...doc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
id: _id.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const results = await this.collection.find({ projectId }).sort({ createdAt: -1 }).toArray();
|
||||||
|
return results.map(doc => {
|
||||||
|
const { _id, ...rest } = doc;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
id: _id.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(projectId: string, id: string): Promise<boolean> {
|
||||||
|
const result = await this.collection.deleteOne({ projectId, _id: new ObjectId(id) });
|
||||||
|
return result.deletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAll(projectId: string): Promise<void> {
|
||||||
|
await this.collection.deleteMany({ projectId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { ICreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ICreateApiKeyController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateApiKeyController implements ICreateApiKeyController {
|
||||||
|
private readonly createApiKeyUseCase: ICreateApiKeyUseCase;
|
||||||
|
constructor({ createApiKeyUseCase }: { createApiKeyUseCase: ICreateApiKeyUseCase }) {
|
||||||
|
this.createApiKeyUseCase = createApiKeyUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.createApiKeyUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as createApiKeyInputSchema };
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { IDeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IDeleteApiKeyController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteApiKeyController implements IDeleteApiKeyController {
|
||||||
|
private readonly deleteApiKeyUseCase: IDeleteApiKeyUseCase;
|
||||||
|
constructor({ deleteApiKeyUseCase }: { deleteApiKeyUseCase: IDeleteApiKeyUseCase }) {
|
||||||
|
this.deleteApiKeyUseCase = deleteApiKeyUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.deleteApiKeyUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as deleteApiKeyInputSchema };
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IListApiKeysController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListApiKeysController implements IListApiKeysController {
|
||||||
|
private readonly listApiKeysUseCase: IListApiKeysUseCase;
|
||||||
|
constructor({ listApiKeysUseCase }: { listApiKeysUseCase: IListApiKeysUseCase }) {
|
||||||
|
this.listApiKeysUseCase = listApiKeysUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.listApiKeysUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as listApiKeysInputSchema };
|
||||||
Loading…
Add table
Add a link
Reference in a new issue