feat: improve prompt management (#7853)

This commit is contained in:
darkskygit 2024-08-14 08:38:36 +00:00
parent cd3924b8fc
commit 339c39c1ec
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
13 changed files with 161 additions and 105 deletions

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "ai_prompts_metadata" ADD COLUMN "modified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -367,6 +367,9 @@ model AiPrompt {
model String @db.VarChar
config Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
// whether the prompt is modified by the admin panel
modified Boolean @default(false)
messages AiPromptMessage[]
sessions AiSession[]

View File

@ -33,7 +33,10 @@ export class ChatPrompt {
private readonly templateParams: PromptParams = {};
static createFromPrompt(
options: Omit<AiPrompt, 'id' | 'createdAt' | 'config'> & {
options: Omit<
AiPrompt,
'id' | 'createdAt' | 'updatedAt' | 'modified' | 'config'
> & {
messages: PromptMessage[];
config: PromptConfig | undefined;
}

View File

@ -1,8 +1,12 @@
import { Logger } from '@nestjs/common';
import { AiPrompt, PrismaClient } from '@prisma/client';
import { PromptConfig, PromptMessage } from '../types';
type Prompt = Omit<AiPrompt, 'id' | 'createdAt' | 'action' | 'config'> & {
type Prompt = Omit<
AiPrompt,
'id' | 'createdAt' | 'updatedAt' | 'modified' | 'action' | 'config'
> & {
action?: string;
messages: PromptMessage[];
config?: PromptConfig;
@ -830,7 +834,7 @@ const chat: Prompt[] = [
],
},
{
name: 'chat:gpt4',
name: 'Chat With AFFiNE AI',
model: 'gpt-4o',
messages: [
{
@ -845,7 +849,20 @@ const chat: Prompt[] = [
export const prompts: Prompt[] = [...actions, ...chat, ...workflows];
export async function refreshPrompts(db: PrismaClient) {
const needToSkip = await db.aiPrompt
.findMany({
where: { modified: true },
select: { name: true },
})
.then(p => p.map(p => p.name));
for (const prompt of prompts) {
// skip prompt update if already modified by admin panel
if (needToSkip.includes(prompt.name)) {
new Logger('CopilotPrompt').warn(`Skip modified prompt: ${prompt.name}`);
return;
}
await db.aiPrompt.upsert({
create: {
name: prompt.name,
@ -865,6 +882,7 @@ export async function refreshPrompts(db: PrismaClient) {
update: {
action: prompt.action,
model: prompt.model,
updatedAt: new Date(),
messages: {
deleteMany: {},
create: prompt.messages.map((message, idx) => ({

View File

@ -38,16 +38,11 @@ export class PromptService implements OnModuleInit {
model: true,
config: true,
messages: {
select: {
role: true,
content: true,
params: true,
},
orderBy: {
idx: 'asc',
},
select: { role: true, content: true, params: true },
orderBy: { idx: 'asc' },
},
},
orderBy: { action: { sort: 'asc', nulls: 'first' } },
});
}
@ -121,11 +116,18 @@ export class PromptService implements OnModuleInit {
.then(ret => ret.id);
}
async update(name: string, messages: PromptMessage[], config?: PromptConfig) {
async update(
name: string,
messages: PromptMessage[],
modifyByApi: boolean = false,
config?: PromptConfig
) {
const { id } = await this.db.aiPrompt.update({
where: { name },
data: {
config: config || undefined,
updatedAt: new Date(),
modified: modifyByApi,
messages: {
// cleanup old messages
deleteMany: {},

View File

@ -517,7 +517,16 @@ export class PromptsManagementResolver {
description: 'List all copilot prompts',
})
async listCopilotPrompts() {
return this.promptService.list();
const prompts = await this.promptService.list();
return prompts.filter(
p =>
p.messages.length > 0 &&
// ignore internal prompts
!p.name.startsWith('workflow:') &&
!p.name.startsWith('debug:') &&
!p.name.startsWith('chat:') &&
!p.name.startsWith('action:')
);
}
@Mutation(() => CopilotPromptType, {
@ -544,7 +553,7 @@ export class PromptsManagementResolver {
@Args('messages', { type: () => [CopilotPromptMessageType] })
messages: CopilotPromptMessageType[]
) {
await this.promptService.update(name, messages);
await this.promptService.update(name, messages, true);
return this.promptService.get(name);
}
}

View File

@ -81,10 +81,10 @@ export const router = _createBrowserRouter(
path: 'accounts',
lazy: () => import('./modules/accounts'),
},
// {
// path: 'ai',
// lazy: () => import('./modules/ai'),
// },
{
path: 'ai',
lazy: () => import('./modules/ai'),
},
{
path: 'config',
lazy: () => import('./modules/config'),

View File

@ -9,12 +9,23 @@ import { useRightPanel } from '../layout';
import type { Prompt } from './prompts';
import { usePrompt } from './use-prompt';
export function EditPrompt({ item }: { item: Prompt }) {
export function EditPrompt({
item,
setCanSave,
}: {
item: Prompt;
setCanSave: (changed: boolean) => void;
}) {
const { closePanel } = useRightPanel();
const [messages, setMessages] = useState(item.messages);
const { updatePrompt } = usePrompt();
const disableSave = useMemo(
() => JSON.stringify(messages) === JSON.stringify(item.messages),
[item.messages, messages]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>, index: number) => {
const newMessages = [...messages];
@ -23,8 +34,9 @@ export function EditPrompt({ item }: { item: Prompt }) {
content: e.target.value,
};
setMessages(newMessages);
setCanSave(!disableSave);
},
[messages]
[disableSave, messages, setCanSave]
);
const handleClose = useCallback(() => {
setMessages(item.messages);
@ -32,14 +44,11 @@ export function EditPrompt({ item }: { item: Prompt }) {
}, [closePanel, item.messages]);
const onConfirm = useCallback(() => {
updatePrompt({ name: item.name, messages });
if (!disableSave) {
updatePrompt({ name: item.name, messages });
}
handleClose();
}, [handleClose, item.name, messages, updatePrompt]);
const disableSave = useMemo(
() => JSON.stringify(messages) === JSON.stringify(item.messages),
[item.messages, messages]
);
}, [disableSave, handleClose, item.name, messages, updatePrompt]);
useEffect(() => {
setMessages(item.messages);
@ -71,74 +80,83 @@ export function EditPrompt({ item }: { item: Prompt }) {
</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="grid">
<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">Action</div>
<div className="text-sm font-medium">Name</div>
<div className="text-sm font-normal text-zinc-500">
{item.action}
{item.name}
</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>
{item.action ? (
<div className="flex flex-col">
<div className="text-sm font-medium">Action</div>
<div className="text-sm font-normal text-zinc-500">
{message.role}
{item.action}
</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)}
/>
) : 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"
style={{ overflowWrap: 'break-word' }}
>
{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>
</div>
</ScrollArea>
</div>

View File

@ -4,14 +4,7 @@ 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 <AiPage />;
}
export function AiPage() {
function AiPage() {
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
@ -38,4 +31,5 @@ export function AiPage() {
</div>
);
}
export { Ai as Component };
export { AiPage as Component };

View File

@ -54,14 +54,16 @@ export function Prompts() {
export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
const [dialogOpen, setDialogOpen] = useState(false);
const [canSave, setCanSave] = useState(false);
const handleDiscardChangesCancel = useCallback(() => {
setDialogOpen(false);
setCanSave(false);
}, []);
const handleConfirm = useCallback(
(item: Prompt) => {
setRightPanelContent(<EditPrompt item={item} />);
setRightPanelContent(<EditPrompt item={item} setCanSave={setCanSave} />);
if (dialogOpen) {
handleDiscardChangesCancel();
}
@ -81,13 +83,13 @@ export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const handleEdit = useCallback(
(item: Prompt) => {
if (isOpen) {
if (isOpen && canSave) {
setDialogOpen(true);
} else {
handleConfirm(item);
}
},
[handleConfirm, isOpen]
[canSave, handleConfirm, isOpen]
);
return (
<div>

View File

@ -7,7 +7,12 @@ 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, SettingsIcon, UsersIcon } from 'lucide-react';
import {
ClipboardListIcon,
CpuIcon,
SettingsIcon,
UsersIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
@ -56,8 +61,7 @@ export function Nav() {
<UsersIcon className="mr-2 h-4 w-4" />
Accounts
</Link>
{/* hide ai config in admin until it's ready */}
{/* <Link
<Link
to={'/admin/ai'}
className={cn(
buttonVariants({
@ -72,7 +76,7 @@ export function Nav() {
>
<CpuIcon className="mr-2 h-4 w-4" />
AI
</Link> */}
</Link>
<Link
to={'/admin/config'}
className={cn(

View File

@ -7,7 +7,7 @@ export const promptKeys = [
'debug:action:fal-upscaler',
'debug:action:fal-remove-bg',
'debug:action:fal-face-to-sticker',
'chat:gpt4',
'Chat With AFFiNE AI',
'Summary',
'Generate a caption',
'Summary the webpage',

View File

@ -42,7 +42,7 @@ export function createChatSession({
return client.createSession({
workspaceId,
docId,
promptName: 'chat:gpt4',
promptName: 'Chat With AFFiNE AI',
});
}