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';
|
||||
import { redirect } from "next/navigation";
|
||||
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 crypto from 'crypto';
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { templates } from "../lib/project_templates";
|
||||
import { authCheck } from "./auth_actions";
|
||||
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 { USE_AUTH } from "../lib/feature_flags";
|
||||
import { authorizeUserAction } from "./billing_actions";
|
||||
import { Workflow } from "../lib/types/workflow_types";
|
||||
import { container } from "@/di/container";
|
||||
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 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() {
|
||||
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>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// count existing keys
|
||||
const count = await apiKeysCollection.countDocuments({ projectId });
|
||||
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> = {
|
||||
export async function createApiKey(projectId: string): Promise<z.infer<typeof ApiKey>> {
|
||||
const user = await authCheck();
|
||||
return await createApiKeyController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
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) {
|
||||
await projectAuthCheck(projectId);
|
||||
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) });
|
||||
const user = await authCheck();
|
||||
return await deleteApiKeyController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
const keys = await apiKeysCollection.find({ projectId }).toArray();
|
||||
return keys.map(k => ({ ...k, _id: k._id.toString() }));
|
||||
export async function listApiKeys(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
|
||||
const user = await authCheck();
|
||||
return await listApiKeysController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProjectName(projectId: string, name: string) {
|
||||
|
|
@ -227,9 +230,7 @@ export async function deleteProject(projectId: string) {
|
|||
await projectAuthCheck(projectId);
|
||||
|
||||
// delete api keys
|
||||
await apiKeysCollection.deleteMany({
|
||||
projectId,
|
||||
});
|
||||
await apiKeysRepository.deleteAll(projectId);
|
||||
|
||||
// delete embeddings
|
||||
const sources = await dataSourcesCollection.find({
|
||||
|
|
@ -243,9 +244,6 @@ export async function deleteProject(projectId: string) {
|
|||
const ids = sources.map(s => s._id);
|
||||
|
||||
// delete data sources
|
||||
await embeddingsCollection.deleteMany({
|
||||
sourceId: { $in: ids.map(i => i.toString()) },
|
||||
});
|
||||
await dataSourcesCollection.deleteMany({
|
||||
_id: {
|
||||
$in: ids,
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ function TextInputField({
|
|||
|
||||
{/* Input field */}
|
||||
{mentions ? (
|
||||
<div className="w-full">
|
||||
<div className="w-full min-h-[300px]">
|
||||
<MentionsEditor
|
||||
atValues={mentionsAtValues}
|
||||
value={value}
|
||||
|
|
@ -264,7 +264,7 @@ function TextInputField({
|
|||
placeholder={placeholder}
|
||||
variant="bordered"
|
||||
size={getInputSize()}
|
||||
minRows={3}
|
||||
minRows={12}
|
||||
maxRows={20}
|
||||
isInvalid={!isValid}
|
||||
errorMessage={validationResult?.errorMessage}
|
||||
|
|
@ -321,11 +321,13 @@ function TextInputField({
|
|||
{/* Content display */}
|
||||
<div
|
||||
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-not-allowed opacity-60": locked || disabled,
|
||||
"border-0 bg-transparent p-0": inline,
|
||||
"min-h-[300px]": multiline,
|
||||
"min-h-[40px]": !multiline,
|
||||
}
|
||||
)}
|
||||
onClick={() => !locked && !disabled && setIsEditing(true)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { MongoClient } from "mongodb";
|
||||
import { User, Webpage } from "./types/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 { Project } from "./types/project_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 dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
|
||||
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 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 apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
|
||||
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 twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
||||
|
|
|
|||
|
|
@ -42,10 +42,3 @@ export const ProjectMember = z.object({
|
|||
createdAt: 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 { Button } from "@/components/ui/button";
|
||||
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 { InputField } from "../../../lib/components/input-field";
|
||||
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 { FormSection } from "../../../lib/components/form-section";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
|
|
@ -108,156 +104,6 @@ export function BasicSettingsSection({
|
|||
</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({
|
||||
projectId,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProjec
|
|||
import { CopyButton } from "../../../../../components/common/copy-button";
|
||||
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
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 { RelativeTime } from "@primer/react";
|
||||
import { Label } from "../../../../lib/components/label";
|
||||
|
|
@ -224,7 +224,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v
|
|||
}
|
||||
|
||||
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 [message, setMessage] = useState<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
|
|
@ -270,7 +270,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
|||
try {
|
||||
setLoading(true);
|
||||
await deleteApiKey(projectId, id);
|
||||
setKeys(keys.filter((k) => k._id !== id));
|
||||
setKeys(keys.filter((k) => k.id !== id));
|
||||
setMessage({
|
||||
type: 'info',
|
||||
text: 'API key deleted successfully',
|
||||
|
|
@ -325,11 +325,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
|||
)}
|
||||
|
||||
{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">
|
||||
<ApiKeyDisplay
|
||||
apiKey={key.key}
|
||||
onDelete={() => handleDeleteKey(key._id)}
|
||||
onDelete={() => handleDeleteKey(key.id)}
|
||||
/>
|
||||
</div>
|
||||
<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 { DataSource } from "@/app/lib/types/datasource_types";
|
||||
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 { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
|
||||
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 { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
|
|
@ -225,7 +225,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-1 pb-6">
|
||||
<div className="shrink-0 px-1">
|
||||
{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">
|
||||
<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"
|
||||
tourTarget="copilot"
|
||||
showWelcome={messages.length === 0}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
|
||||
title="Skipper"
|
||||
subtitle="Build your assistant"
|
||||
rightActions={
|
||||
<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
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
|
@ -342,10 +337,6 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
|||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
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 { Workflow} from "@/app/lib/types/workflow_types";
|
||||
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.
|
||||
|
|
@ -194,26 +185,24 @@ function AssistantMessage({
|
|||
// Remove autoApplyEnabled and useEffect for auto-apply
|
||||
|
||||
// parse actions from parts
|
||||
let parsed: z.infer<typeof CopilotResponsePart>[] = [];
|
||||
const parsed = useMemo(() => {
|
||||
const result: z.infer<typeof CopilotResponsePart>[] = [];
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
parsed.push({
|
||||
result.push({
|
||||
type: 'text',
|
||||
content: block.content,
|
||||
});
|
||||
} else {
|
||||
parsed.push(enrich(block.content));
|
||||
result.push(enrich(block.content));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [blocks]);
|
||||
|
||||
// Only render text outside the panel
|
||||
const textBlocks = parsed.filter(part => part.type === 'text');
|
||||
// All cards (action and streaming_action) go inside the panel
|
||||
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;
|
||||
// Count action cards for tracking
|
||||
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
|
||||
const totalActions = parsed.filter(part => part.type === 'action').length;
|
||||
const appliedCount = Array.from(appliedActions).length;
|
||||
const pendingCount = Math.max(0, totalActions - appliedCount);
|
||||
const allApplied = pendingCount === 0 && totalActions > 0;
|
||||
|
|
@ -307,9 +296,14 @@ function AssistantMessage({
|
|||
// Memoized handleApplyAll for useEffect dependencies
|
||||
const handleApplyAll = useCallback(() => {
|
||||
// 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))
|
||||
.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
|
||||
unapplied.forEach(({ action, actionIndex }) => {
|
||||
|
|
@ -322,7 +316,7 @@ function AssistantMessage({
|
|||
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
|
||||
return next;
|
||||
});
|
||||
}, [cardBlocks, appliedActions, setAppliedActions, applyAction]);
|
||||
}, [parsed, appliedActions, setAppliedActions, applyAction]);
|
||||
|
||||
// Manual single apply (from card)
|
||||
const handleSingleApply = (action: any, actionIndex: number) => {
|
||||
|
|
@ -343,37 +337,20 @@ function AssistantMessage({
|
|||
// Removed useEffect for auto-apply
|
||||
|
||||
// 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 = '';
|
||||
if (streamingBlock && streamingBlock.part.type === 'streaming_action' && streamingBlock.part.action && streamingBlock.part.action.name) {
|
||||
streamingLine = `Generating ${streamingBlock.part.action.name}...`;
|
||||
if (streamingPart && streamingPart.type === 'streaming_action' && streamingPart.action && streamingPart.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
|
||||
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
|
||||
let completedSummary = '';
|
||||
if (allCardsLoaded && totalActions > 0) {
|
||||
// Count how many are create vs edit
|
||||
const createCount = cardBlocks.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 createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
|
||||
const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
|
||||
const parts = [];
|
||||
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
|
||||
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
|
||||
const hasPanelWarning = cardBlocks.some(
|
||||
({ part }) =>
|
||||
const hasPanelWarning = parsed.some(
|
||||
part =>
|
||||
part.type === 'action' &&
|
||||
part.action &&
|
||||
(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
|
||||
function isNonDividerMarkdown(content: string) {
|
||||
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
|
||||
// Track the latest status bar info
|
||||
const latestStatusBar = useRef<any>(null);
|
||||
|
|
|
|||
|
|
@ -363,11 +363,12 @@ export function AgentConfig({
|
|||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
placeholder="Type agent instructions..."
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
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>
|
||||
{/* Examples Section */}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Chat } from "./components/chat";
|
|||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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({
|
||||
hidden = false,
|
||||
|
|
@ -56,16 +56,11 @@ export function App({
|
|||
className={`${hidden ? 'hidden' : 'block'}`}
|
||||
variant="playground"
|
||||
tourTarget="playground"
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
icon={<MessageCircle className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
|
||||
title="Playground"
|
||||
subtitle="Chat with your assistant"
|
||||
rightActions={
|
||||
<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
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
|
@ -90,10 +85,6 @@ export function App({
|
|||
<BugOffIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -22,13 +22,17 @@ interface SelectComposioToolkitProps {
|
|||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onSelectToolkit: (toolkit: ToolkitType) => void;
|
||||
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({
|
||||
projectId,
|
||||
tools,
|
||||
onSelectToolkit,
|
||||
initialToolkitSlug
|
||||
initialToolkitSlug,
|
||||
filterByTriggers = false,
|
||||
filterByTools = false
|
||||
}: SelectComposioToolkitProps) {
|
||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||
|
|
@ -59,17 +63,16 @@ export function SelectComposioToolkit({
|
|||
cursor = response.next_cursor;
|
||||
} while (cursor !== null);
|
||||
|
||||
// // Only show those toolkits that
|
||||
// // - either do not require authentication, OR
|
||||
// // - have oauth2 managed by Composio
|
||||
// const filteredToolkits = allToolkits.filter(toolkit => {
|
||||
// const noAuth = toolkit.no_auth;
|
||||
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2');
|
||||
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2');
|
||||
// return noAuth || hasOAuth2;
|
||||
// });
|
||||
// Filter toolkits based on the filter props
|
||||
let finalToolkits = allToolkits;
|
||||
if (filterByTriggers) {
|
||||
finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.triggers_count > 0);
|
||||
}
|
||||
if (filterByTools) {
|
||||
finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.tools_count > 0);
|
||||
}
|
||||
|
||||
setToolkits(allToolkits);
|
||||
setToolkits(finalToolkits);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
|
||||
|
|
@ -78,7 +81,7 @@ export function SelectComposioToolkit({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, filterByTriggers, filterByTools]);
|
||||
|
||||
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
||||
onSelectToolkit(toolkit);
|
||||
|
|
@ -125,7 +128,14 @@ export function SelectComposioToolkit({
|
|||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -161,7 +171,13 @@ export function SelectComposioToolkit({
|
|||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search toolkits..."
|
||||
placeholder={
|
||||
filterByTriggers
|
||||
? "Search toolkits with triggers..."
|
||||
: filterByTools
|
||||
? "Search toolkits with tools..."
|
||||
: "Search toolkits..."
|
||||
}
|
||||
value={searchQuery}
|
||||
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
|
||||
|
|
@ -173,6 +189,8 @@ export function SelectComposioToolkit({
|
|||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
|
||||
{filterByTriggers && ' with triggers'}
|
||||
{filterByTools && ' with tools'}
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
|
@ -204,6 +222,7 @@ export function SelectComposioToolkit({
|
|||
isConnected={isConnected}
|
||||
workflowTools={tools}
|
||||
onSelectToolkit={() => handleSelectToolkit(toolkit)}
|
||||
showTriggerCounts={filterByTriggers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -212,7 +231,14 @@ export function SelectComposioToolkit({
|
|||
{filteredToolkits.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface ToolkitCardProps {
|
|||
isConnected: boolean;
|
||||
onSelectToolkit: () => void;
|
||||
workflowTools: z.infer<typeof Workflow.shape.tools>;
|
||||
showTriggerCounts?: boolean; // New prop to show trigger counts instead of tool counts
|
||||
}
|
||||
|
||||
export function ToolkitCard({
|
||||
|
|
@ -37,6 +38,7 @@ export function ToolkitCard({
|
|||
isConnected,
|
||||
onSelectToolkit,
|
||||
workflowTools,
|
||||
showTriggerCounts = false,
|
||||
}: ToolkitCardProps) {
|
||||
const handleCardClick = useCallback(() => {
|
||||
onSelectToolkit();
|
||||
|
|
@ -69,7 +71,9 @@ export function ToolkitCard({
|
|||
variant="faded"
|
||||
size="sm"
|
||||
>
|
||||
{selectedToolsCount > 0
|
||||
{showTriggerCounts
|
||||
? `${toolkit.meta.triggers_count} triggers`
|
||||
: selectedToolsCount > 0
|
||||
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
|
||||
: `${toolkit.meta.tools_count} tools`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function ToolsConfig({
|
|||
tools={tools}
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={initialToolkitSlug}
|
||||
filterByTools={true}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={null}
|
||||
filterByTriggers={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { InputField } from "@/app/lib/components/input-field";
|
|||
import { VoiceSection } from "../config/components/voice";
|
||||
import { ChatWidgetSection } from "../config/components/project";
|
||||
import { TriggersModal } from "./components/TriggersModal";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
|
||||
enablePatches();
|
||||
|
||||
|
|
@ -1221,161 +1222,31 @@ export function WorkflowEditor({
|
|||
onSelectTool: handleSelectTool,
|
||||
onSelectPrompt: handleSelectPrompt,
|
||||
}}>
|
||||
<div className="flex flex-col h-full relative">
|
||||
<div className="shrink-0 flex justify-between items-center pb-6">
|
||||
<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">
|
||||
<InputField
|
||||
type="text"
|
||||
value={localProjectName}
|
||||
onChange={handleProjectNameChange}
|
||||
error={projectNameError}
|
||||
placeholder="Project name..."
|
||||
className="text-lg font-semibold !min-h-[24px] !py-0.5 !border !border-gray-200 dark:!border-gray-700 !rounded-lg"
|
||||
inline={true}
|
||||
<div className="h-full flex flex-col gap-5">
|
||||
{/* Top Bar - Isolated like sidebar */}
|
||||
<TopBar
|
||||
localProjectName={localProjectName}
|
||||
projectNameError={projectNameError}
|
||||
onProjectNameChange={handleProjectNameChange}
|
||||
publishing={state.present.publishing}
|
||||
isLive={isLive}
|
||||
showCopySuccess={showCopySuccess}
|
||||
canUndo={state.currentIndex > 0}
|
||||
canRedo={state.currentIndex < state.patches.length}
|
||||
showCopilot={showCopilot}
|
||||
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>
|
||||
|
||||
<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">
|
||||
{/* Content Area */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
|
||||
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
|
||||
<div className="flex flex-col h-full">
|
||||
<EntityList
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
|||
return (
|
||||
<div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900">
|
||||
{/* 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
|
||||
projectId={projectId ?? undefined}
|
||||
useAuth={useAuth}
|
||||
|
|
@ -53,8 +53,8 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
{billingPastDue && <div className="shrink-0">
|
||||
<main className="flex-1 h-full overflow-auto">
|
||||
{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">
|
||||
<span>Your subscription is past due. Please update your payment information to avoid losing access to your projects.</span>
|
||||
<Button
|
||||
|
|
@ -68,9 +68,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
|
|||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@
|
|||
.ql-editor {
|
||||
font-size: 1rem !important; /* Increase base font size */
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -100,13 +100,12 @@ export function ComposeBoxCopilot({
|
|||
disabled={loading}
|
||||
placeholder="Type a message..."
|
||||
autoResize={true}
|
||||
maxHeight={120}
|
||||
maxHeight={200}
|
||||
className={`
|
||||
min-h-0!
|
||||
min-h-6
|
||||
border-0! shadow-none! ring-0!
|
||||
bg-transparent
|
||||
resize-none
|
||||
overflow-y-auto
|
||||
[&::-webkit-scrollbar]:w-1
|
||||
[&::-webkit-scrollbar-track]:bg-transparent
|
||||
[&::-webkit-scrollbar-thumb]:bg-gray-300
|
||||
|
|
@ -294,7 +293,7 @@ function CopilotStatusBar({
|
|||
);
|
||||
};
|
||||
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 */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible">
|
||||
{renderContext()}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export function ActionButton({
|
|||
|
||||
interface PanelProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: string;
|
||||
icon?: React.ReactNode;
|
||||
rightActions?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
|
|
@ -47,6 +49,8 @@ interface PanelProps {
|
|||
|
||||
export function Panel({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
rightActions,
|
||||
actions,
|
||||
children,
|
||||
|
|
@ -65,8 +69,8 @@ export function Panel({
|
|||
"flex flex-col rounded-xl border relative w-full",
|
||||
// Only apply overflow-hidden if no custom overflow is set (for backward compatibility)
|
||||
overflow ? undefined : "overflow-hidden",
|
||||
variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800",
|
||||
"bg-white dark:bg-zinc-900",
|
||||
variant === 'copilot' ? "border-transparent" : "border-zinc-200 dark:border-zinc-800",
|
||||
variant === 'copilot' ? "bg-zinc-50 dark:bg-zinc-900" : "bg-white dark:bg-zinc-900",
|
||||
maxHeight ? "max-h-(--panel-height)" : "h-full",
|
||||
className
|
||||
)}
|
||||
|
|
@ -93,7 +97,8 @@ export function Panel({
|
|||
)}
|
||||
<div
|
||||
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 items-center justify-between h-[53px] p-3": isEntityList,
|
||||
|
|
@ -113,8 +118,35 @@ export function Panel({
|
|||
) : variant === 'copilot' ? (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
) : 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}
|
||||
</>
|
||||
) : isEntityList ? (
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
|||
}
|
||||
}, [propValue, isEditing]);
|
||||
|
||||
/*
|
||||
useEffect(() => {
|
||||
if (!autoResize) return;
|
||||
|
||||
|
|
@ -69,7 +68,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
|||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [localValue, autoResize, maxHeight, textareaRef]);
|
||||
*/
|
||||
|
||||
const validateAndUpdate = (value: string) => {
|
||||
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 { 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({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
strict: true,
|
||||
|
|
@ -96,6 +104,12 @@ container.register({
|
|||
// api keys
|
||||
// ---
|
||||
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
|
||||
// ---
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
|
@ -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 { 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 {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("api_keys");
|
||||
|
||||
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
|
||||
const result = await apiKeysCollection.findOneAndUpdate(
|
||||
const result = await this.collection.findOneAndUpdate(
|
||||
{ projectId, key: apiKey },
|
||||
{ $set: { lastUsedAt: new Date().toISOString() } }
|
||||
);
|
||||
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