feat(admin): add prompt management page (#7611)

close AF-907

Supports online modification of prompt, but does not support custom ai key yet

![CleanShot 2024-07-29 at 22 12 39@2x](https://github.com/user-attachments/assets/c67ad0d0-3e5b-44ff-b7db-d07dd11c19e2)
This commit is contained in:
JimmFly 2024-08-13 05:45:00 +00:00 committed by forehalo
parent bf6e36de37
commit b214003968
No known key found for this signature in database
15 changed files with 658 additions and 36 deletions

View File

@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
Float,
ID,
InputType,
Mutation,
@ -205,16 +206,16 @@ class CopilotPromptConfigType {
@Field(() => Boolean, { nullable: true })
jsonMode!: boolean | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
frequencyPenalty!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
presencePenalty!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
temperature!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
topP!: number | null;
}
@ -238,8 +239,8 @@ class CopilotPromptType {
@Field(() => String)
name!: string;
@Field(() => AvailableModels)
model!: AvailableModels;
@Field(() => String)
model!: string;
@Field(() => String, { nullable: true })
action!: string | null;

View File

@ -61,19 +61,19 @@ enum CopilotModels {
}
input CopilotPromptConfigInput {
frequencyPenalty: Int
frequencyPenalty: Float
jsonMode: Boolean
presencePenalty: Int
temperature: Int
topP: Int
presencePenalty: Float
temperature: Float
topP: Float
}
type CopilotPromptConfigType {
frequencyPenalty: Int
frequencyPenalty: Float
jsonMode: Boolean
presencePenalty: Int
temperature: Int
topP: Int
presencePenalty: Float
temperature: Float
topP: Float
}
input CopilotPromptMessageInput {
@ -102,7 +102,7 @@ type CopilotPromptType {
action: String
config: CopilotPromptConfigType
messages: [CopilotPromptMessageType!]!
model: CopilotModels!
model: String!
name: String!
}

View File

@ -51,6 +51,10 @@ export const router = _createBrowserRouter(
path: '/admin/auth',
lazy: () => import('./modules/auth'),
},
{
path: '/admin/ai',
lazy: () => import('./modules/ai'),
},
{
path: '/admin/setup',
lazy: () => import('./modules/setup'),

View File

@ -0,0 +1,44 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
<DialogDescription className="leading-6">
Changes to this prompt will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex justify-end items-center w-full space-x-4">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,146 @@
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator';
import { Textarea } from '@affine/admin/components/ui/textarea';
import { CheckIcon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRightPanel } from '../layout';
import type { Prompt } from './prompts';
import { usePrompt } from './use-prompt';
export function EditPrompt({ item }: { item: Prompt }) {
const { closePanel } = useRightPanel();
const [messages, setMessages] = useState(item.messages);
const { updatePrompt } = usePrompt();
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>, index: number) => {
const newMessages = [...messages];
newMessages[index] = {
...newMessages[index],
content: e.target.value,
};
setMessages(newMessages);
},
[messages]
);
const handleClose = useCallback(() => {
setMessages(item.messages);
closePanel();
}, [closePanel, item.messages]);
const onConfirm = useCallback(() => {
updatePrompt({ name: item.name, messages });
handleClose();
}, [handleClose, item.name, messages, updatePrompt]);
const disableSave = useMemo(
() => JSON.stringify(messages) === JSON.stringify(item.messages),
[item.messages, messages]
);
useEffect(() => {
setMessages(item.messages);
}, [item.messages]);
return (
<div className="flex flex-col h-full gap-1">
<div className="flex-grow-0 flex-shrink-0 h-[56px] flex justify-between items-center py-[10px] px-6 ">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">Edit Prompt</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onConfirm}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<ScrollArea>
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">
<div className="flex flex-col">
<div className="text-sm font-medium">Name</div>
<div className="text-sm font-normal text-zinc-500">{item.name}</div>
</div>
{item.action ? (
<div className="flex flex-col">
<div className="text-sm font-medium">Action</div>
<div className="text-sm font-normal text-zinc-500">
{item.action}
</div>
</div>
) : null}
<div className="flex flex-col">
<div className="text-sm font-medium">Model</div>
<div className="text-sm font-normal text-zinc-500">
{item.model}
</div>
</div>
{item.config ? (
<div className="flex flex-col border rounded p-3">
<div className="text-sm font-medium">Config</div>
{Object.entries(item.config).map(([key, value], index) => (
<div key={key} className="flex flex-col">
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span className="text-sm font-normal text-zinc-500">
{value?.toString()}
</span>
</div>
))}
</div>
) : null}
</div>
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col">
<div className="text-sm font-medium">Messages</div>
{messages.map((message, index) => (
<div key={index} className="flex flex-col gap-3">
{index !== 0 && <Separator />}
<div>
<div className="text-sm font-normal">Role</div>
<div className="text-sm font-normal text-zinc-500">
{message.role}
</div>
</div>
{message.params ? (
<div>
<div className="text-sm font-medium">Params</div>
{Object.entries(message.params).map(([key, value], index) => (
<div key={key} className="flex flex-col">
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span className="text-sm font-normal text-zinc-500">
{value.toString()}
</span>
</div>
))}
</div>
) : null}
<div className="text-sm font-normal">Content</div>
<Textarea
className=" min-h-48"
value={message.content}
onChange={e => handleChange(e, index)}
/>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { cn } from '@affine/admin/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { Prompts } from './prompts';
export function Ai() {
return null;
// hide ai config in admin until it's ready
// return <Layout content={<AiPage />} />;
}
export function AiPage() {
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">AI</div>
</div>
<Separator />
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
<Prompts />
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'h-full w-2.5 border-l border-l-transparent p-[1px]'
)}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</div>
);
}
export { Ai as Component };

View File

@ -0,0 +1,69 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { useState } from 'react';
export function Keys() {
const [openAIKey, setOpenAIKey] = useState('');
const [falAIKey, setFalAIKey] = useState('');
const [unsplashKey, setUnsplashKey] = useState('');
return (
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
<div className="flex items-center">
<span className="text-xl font-semibold">Keys</span>
</div>
<div className="flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">OpenAI Key</Label>
<div className="flex items-center gap-2">
<Input
type="text"
className="py-2 px-3 text-base font-normal placeholder:opacity-50"
value={openAIKey}
placeholder="sk-xxxxxxxxxxxxx-xxxxxxxxxxxxxx"
onChange={e => setOpenAIKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Fal.AI Key</Label>
<div className="flex items-center gap-2">
<Input
type="email"
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
value={falAIKey}
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
onChange={e => setFalAIKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Unsplash Key</Label>
<div className="flex items-center gap-2">
<Input
type="password"
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
value={unsplashKey}
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
onChange={e => setUnsplashKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3 text-sm font-normal text-gray-500">
Custom API keys may not perform as expected. AFFiNE does not
guarantee results when using custom API keys.
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
import { Button } from '@affine/admin/components/ui/button';
import { Separator } from '@affine/admin/components/ui/separator';
import type { CopilotPromptMessageRole } from '@affine/graphql';
import { useCallback, useState } from 'react';
import { useRightPanel } from '../layout';
import { DiscardChanges } from './discard-changes';
import { EditPrompt } from './edit-prompt';
import { usePrompt } from './use-prompt';
export type Prompt = {
__typename?: 'CopilotPromptType';
name: string;
model: string;
action: string | null;
config: {
__typename?: 'CopilotPromptConfigType';
jsonMode: boolean | null;
frequencyPenalty: number | null;
presencePenalty: number | null;
temperature: number | null;
topP: number | null;
} | null;
messages: Array<{
__typename?: 'CopilotPromptMessageType';
role: CopilotPromptMessageRole;
content: string;
params: Record<string, string> | null;
}>;
};
export function Prompts() {
const { prompts: list } = usePrompt();
return (
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
<div className="flex items-center">
<span className="text-xl font-semibold">Prompts</span>
</div>
<div className="flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border w-full">
{list.map((item, index) => (
<PromptRow
key={item.name.concat(index.toString())}
item={item}
index={index}
/>
))}
</div>
</div>
</div>
);
}
export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
const [dialogOpen, setDialogOpen] = useState(false);
const handleDiscardChangesCancel = useCallback(() => {
setDialogOpen(false);
}, []);
const handleConfirm = useCallback(
(item: Prompt) => {
setRightPanelContent(<EditPrompt item={item} />);
if (dialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel();
}
},
[
dialogOpen,
handleDiscardChangesCancel,
isOpen,
openPanel,
setRightPanelContent,
]
);
const handleEdit = useCallback(
(item: Prompt) => {
if (isOpen) {
setDialogOpen(true);
} else {
handleConfirm(item);
}
},
[handleConfirm, isOpen]
);
return (
<div>
{index !== 0 && <Separator />}
<Button
variant="ghost"
className="flex flex-col gap-1 w-full items-start px-6 py-[14px] h-full "
onClick={() => handleEdit(item)}
>
<div>{item.name}</div>
<div className="text-left w-full opacity-50 overflow-hidden text-ellipsis whitespace-nowrap break-words text-nowrap">
{item.messages.flatMap(message => message.content).join(' ')}
</div>
</Button>
<DiscardChanges
open={dialogOpen}
onOpenChange={setDialogOpen}
onClose={handleDiscardChangesCancel}
onConfirm={() => handleConfirm(item)}
/>
</div>
);
};

View File

@ -0,0 +1,51 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
useMutateQueryResource,
useMutation,
} from '@affine/core/hooks/use-mutation';
import { useQuery } from '@affine/core/hooks/use-query';
import { getPromptsQuery, updatePromptMutation } from '@affine/graphql';
import { toast } from 'sonner';
import type { Prompt } from './prompts';
export const usePrompt = () => {
const { data } = useQuery({
query: getPromptsQuery,
});
const { trigger } = useMutation({
mutation: updatePromptMutation,
});
const revalidate = useMutateQueryResource();
const updatePrompt = useAsyncCallback(
async ({
name,
messages,
}: {
name: string;
messages: Prompt['messages'];
}) => {
await trigger({
name,
messages,
})
.then(async () => {
await revalidate(getPromptsQuery);
toast.success('Prompt updated successfully');
})
.catch(e => {
toast(e.message);
console.error(e);
});
},
[revalidate, trigger]
);
return {
prompts: data.listCopilotPrompts,
updatePrompt,
};
};

View File

@ -81,7 +81,7 @@ export function Layout({ content }: LayoutProps) {
const [open, setOpen] = useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null);
const [activeTab, setActiveTab] = useState('Accounts');
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('auth');
const [currentModule, setCurrentModule] = useState('auth');

View File

@ -7,12 +7,7 @@ import {
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import {
ClipboardListIcon,
CpuIcon,
SettingsIcon,
UsersIcon,
} from 'lucide-react';
import { ClipboardListIcon, SettingsIcon, UsersIcon } from 'lucide-react';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
@ -28,8 +23,6 @@ const TabsMap: { [key: string]: string } = {
settings: 'Settings',
};
const defaultTab = 'Accounts';
export function Nav() {
const { moduleList } = useGetServerRuntimeConfig();
const { activeTab, setActiveTab, setCurrentModule } = useNav();
@ -42,7 +35,6 @@ export function Nav() {
return;
}
}
setActiveTab(defaultTab);
}, [setActiveTab]);
return (
@ -64,7 +56,8 @@ export function Nav() {
<UsersIcon className="mr-2 h-4 w-4" />
Accounts
</Link>
<Link
{/* hide ai config in admin until it's ready */}
{/* <Link
to={'/admin/ai'}
className={cn(
buttonVariants({
@ -79,7 +72,7 @@ export function Nav() {
>
<CpuIcon className="mr-2 h-4 w-4" />
AI
</Link>
</Link> */}
<Link
to={'/admin/config'}
className={cn(

View File

@ -0,0 +1,19 @@
query getPrompts {
listCopilotPrompts {
name
model
action
config {
jsonMode
frequencyPenalty
presencePenalty
temperature
topP
}
messages {
role
content
params
}
}
}

View File

@ -496,6 +496,33 @@ query oauthProviders {
}`,
};
export const getPromptsQuery = {
id: 'getPromptsQuery' as const,
operationName: 'getPrompts',
definitionName: 'listCopilotPrompts',
containsFile: false,
query: `
query getPrompts {
listCopilotPrompts {
name
model
action
config {
jsonMode
frequencyPenalty
presencePenalty
temperature
topP
}
messages {
role
content
params
}
}
}`,
};
export const getServerRuntimeConfigQuery = {
id: 'getServerRuntimeConfigQuery' as const,
operationName: 'getServerRuntimeConfig',
@ -1057,6 +1084,33 @@ mutation updateAccount($id: String!, $input: ManageUserInput!) {
}`,
};
export const updatePromptMutation = {
id: 'updatePromptMutation' as const,
operationName: 'updatePrompt',
definitionName: 'updateCopilotPrompt',
containsFile: false,
query: `
mutation updatePrompt($name: String!, $messages: [CopilotPromptMessageInput!]!) {
updateCopilotPrompt(name: $name, messages: $messages) {
name
model
action
config {
jsonMode
frequencyPenalty
presencePenalty
temperature
topP
}
messages {
role
content
params
}
}
}`,
};
export const updateServerRuntimeConfigsMutation = {
id: 'updateServerRuntimeConfigsMutation' as const,
operationName: 'updateServerRuntimeConfigs',

View File

@ -0,0 +1,22 @@
mutation updatePrompt(
$name: String!
$messages: [CopilotPromptMessageInput!]!
) {
updateCopilotPrompt(name: $name, messages: $messages) {
name
model
action
config {
jsonMode
frequencyPenalty
presencePenalty
temperature
topP
}
messages {
role
content
params
}
}
}

View File

@ -104,20 +104,20 @@ export enum CopilotModels {
}
export interface CopilotPromptConfigInput {
frequencyPenalty: InputMaybe<Scalars['Int']['input']>;
frequencyPenalty: InputMaybe<Scalars['Float']['input']>;
jsonMode: InputMaybe<Scalars['Boolean']['input']>;
presencePenalty: InputMaybe<Scalars['Int']['input']>;
temperature: InputMaybe<Scalars['Int']['input']>;
topP: InputMaybe<Scalars['Int']['input']>;
presencePenalty: InputMaybe<Scalars['Float']['input']>;
temperature: InputMaybe<Scalars['Float']['input']>;
topP: InputMaybe<Scalars['Float']['input']>;
}
export interface CopilotPromptConfigType {
__typename?: 'CopilotPromptConfigType';
frequencyPenalty: Maybe<Scalars['Int']['output']>;
frequencyPenalty: Maybe<Scalars['Float']['output']>;
jsonMode: Maybe<Scalars['Boolean']['output']>;
presencePenalty: Maybe<Scalars['Int']['output']>;
temperature: Maybe<Scalars['Int']['output']>;
topP: Maybe<Scalars['Int']['output']>;
presencePenalty: Maybe<Scalars['Float']['output']>;
temperature: Maybe<Scalars['Float']['output']>;
topP: Maybe<Scalars['Float']['output']>;
}
export interface CopilotPromptMessageInput {
@ -149,7 +149,7 @@ export interface CopilotPromptType {
action: Maybe<Scalars['String']['output']>;
config: Maybe<CopilotPromptConfigType>;
messages: Array<CopilotPromptMessageType>;
model: CopilotModels;
model: Scalars['String']['output'];
name: Scalars['String']['output'];
}
@ -1684,6 +1684,32 @@ export type OauthProvidersQuery = {
};
};
export type GetPromptsQueryVariables = Exact<{ [key: string]: never }>;
export type GetPromptsQuery = {
__typename?: 'Query';
listCopilotPrompts: Array<{
__typename?: 'CopilotPromptType';
name: string;
model: string;
action: string | null;
config: {
__typename?: 'CopilotPromptConfigType';
jsonMode: boolean | null;
frequencyPenalty: number | null;
presencePenalty: number | null;
temperature: number | null;
topP: number | null;
} | null;
messages: Array<{
__typename?: 'CopilotPromptMessageType';
role: CopilotPromptMessageRole;
content: string;
params: Record<string, string> | null;
}>;
}>;
};
export type GetServerRuntimeConfigQueryVariables = Exact<{
[key: string]: never;
}>;
@ -2186,6 +2212,35 @@ export type UpdateAccountMutation = {
};
};
export type UpdatePromptMutationVariables = Exact<{
name: Scalars['String']['input'];
messages: Array<CopilotPromptMessageInput> | CopilotPromptMessageInput;
}>;
export type UpdatePromptMutation = {
__typename?: 'Mutation';
updateCopilotPrompt: {
__typename?: 'CopilotPromptType';
name: string;
model: string;
action: string | null;
config: {
__typename?: 'CopilotPromptConfigType';
jsonMode: boolean | null;
frequencyPenalty: number | null;
presencePenalty: number | null;
temperature: number | null;
topP: number | null;
} | null;
messages: Array<{
__typename?: 'CopilotPromptMessageType';
role: CopilotPromptMessageRole;
content: string;
params: Record<string, string> | null;
}>;
};
};
export type UpdateServerRuntimeConfigsMutationVariables = Exact<{
updates: Scalars['JSONObject']['input'];
}>;
@ -2432,6 +2487,11 @@ export type Queries =
variables: OauthProvidersQueryVariables;
response: OauthProvidersQuery;
}
| {
name: 'getPromptsQuery';
variables: GetPromptsQueryVariables;
response: GetPromptsQuery;
}
| {
name: 'getServerRuntimeConfigQuery';
variables: GetServerRuntimeConfigQueryVariables;
@ -2729,6 +2789,11 @@ export type Mutations =
variables: UpdateAccountMutationVariables;
response: UpdateAccountMutation;
}
| {
name: 'updatePromptMutation';
variables: UpdatePromptMutationVariables;
response: UpdatePromptMutation;
}
| {
name: 'updateServerRuntimeConfigsMutation';
variables: UpdateServerRuntimeConfigsMutationVariables;