Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add system role #294

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/bots/abstract-bot.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Prompt } from '~services/prompts'
import { ChatError, ErrorCode } from '~utils/errors'

export type Event =
Expand All @@ -17,6 +18,7 @@ export type Event =

export interface SendMessageParams {
prompt: string
role: Prompt['role']
onEvent: (event: Event) => void
signal?: AbortSignal
}
Expand Down
10 changes: 8 additions & 2 deletions src/app/bots/chatgpt-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export class ChatGPTApiBot extends AbstractBot {
private conversationContext?: ConversationContext

buildMessages(): ChatMessage[] {
return [SYSTEM_MESSAGE, ...this.conversationContext!.messages.slice(-(CONTEXT_SIZE + 1))]
let systemMessage = SYSTEM_MESSAGE
let otherMessages = this.conversationContext!.messages
if (this.conversationContext!.messages[0].role === 'system') {
systemMessage = this.conversationContext!.messages[0]
otherMessages = this.conversationContext!.messages.slice(1)
}
return [systemMessage, ...otherMessages.slice(-(CONTEXT_SIZE + 1))]
}

async doSendMessage(params: SendMessageParams) {
Expand All @@ -27,7 +33,7 @@ export class ChatGPTApiBot extends AbstractBot {
if (!this.conversationContext) {
this.conversationContext = { messages: [] }
}
this.conversationContext.messages.push({ role: 'user', content: params.prompt })
this.conversationContext.messages.push({ role: params.role, content: params.prompt })

const resp = await fetch(`${openaiApiHost}/v1/chat/completions`, {
method: 'POST',
Expand Down
20 changes: 14 additions & 6 deletions src/app/components/Chat/ChatMessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import cx from 'classnames'
import { FC, memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { GoBook } from 'react-icons/go'
import { trackEvent } from '~app/plausible'
import { Prompt } from '~services/prompts'
import Button from '../Button'
import PromptLibraryDialog from '../PromptLibrary/Dialog'
import TextInput from './TextInput'

export interface Message {
text: string
role: Prompt['role']
}

interface Props {
mode: 'full' | 'compact'
onSubmit: (value: string) => void
onSubmit: (message: Message) => void
className?: string
disabled?: boolean
placeholder?: string
Expand All @@ -21,6 +27,7 @@ const ChatMessageInput: FC<Props> = (props) => {
const formRef = useRef<HTMLFormElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isPromptLibraryDialogOpen, setIsPromptLibraryDialogOpen] = useState(false)
const role = useRef<Prompt['role']>('user')

useEffect(() => {
if (!props.disabled && props.autoFocus) {
Expand All @@ -32,19 +39,20 @@ const ChatMessageInput: FC<Props> = (props) => {
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (value.trim()) {
props.onSubmit(value)
props.onSubmit({ text: value, role: role.current })
}
setValue('')
},
[props, value],
)

const insertTextAtCursor = useCallback(
(text: string) => {
const insertPromptAtCursor = useCallback(
(prompt: Prompt) => {
const cursorPosition = inputRef.current?.selectionStart || 0
const textBeforeCursor = value.slice(0, cursorPosition)
const textAfterCursor = value.slice(cursorPosition)
setValue(`${textBeforeCursor}${text}${textAfterCursor}`)
setValue(`${textBeforeCursor}${prompt.prompt}${textAfterCursor}`)
role.current = prompt.role
setIsPromptLibraryDialogOpen(false)
inputRef.current?.focus()
},
Expand All @@ -65,7 +73,7 @@ const ChatMessageInput: FC<Props> = (props) => {
<PromptLibraryDialog
isOpen={true}
onClose={() => setIsPromptLibraryDialogOpen(false)}
insertPrompt={insertTextAtCursor}
insertPrompt={insertPromptAtCursor}
/>
)}
</>
Expand Down
12 changes: 6 additions & 6 deletions src/app/components/Chat/ConversationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import cx from 'classnames'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import clearIcon from '~/assets/icons/clear.svg'
import historyIcon from '~/assets/icons/history.svg'
import shareIcon from '~/assets/icons/share.svg'
import { CHATBOTS } from '~app/consts'
import { ConversationContext, ConversationContextValue } from '~app/context'
import { trackEvent } from '~app/plausible'
import ShareDialog from '../Share/Dialog'
import { ChatMessageModel } from '~types'
import { BotId } from '../../bots'
import Button from '../Button'
import HistoryDialog from '../History/Dialog'
import ShareDialog from '../Share/Dialog'
import SwitchBotDropdown from '../SwitchBotDropdown'
import ChatMessageInput from './ChatMessageInput'
import ChatMessageInput, { Message } from './ChatMessageInput'
import ChatMessageList from './ChatMessageList'
import { useTranslation } from 'react-i18next'

interface Props {
botId: BotId
messages: ChatMessageModel[]
onUserSendMessage: (input: string, botId: BotId) => void
onUserSendMessage: (message: Message, botId: BotId) => void
resetConversation: () => void
generating: boolean
stopGenerating: () => void
Expand All @@ -42,8 +42,8 @@ const ConversationPanel: FC<Props> = (props) => {
}, [props.resetConversation])

const onSubmit = useCallback(
async (input: string) => {
props.onUserSendMessage(input as string, props.botId)
async (message: Message) => {
props.onUserSendMessage(message, props.botId)
},
[props],
)
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/PromptLibrary/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import PromptLibrary from './Library'
import Dialog from '../Dialog'
import { Prompt } from '~services/prompts'

interface Props {
isOpen: boolean
onClose: () => void
insertPrompt: (text: string) => void
insertPrompt: (prompt: Prompt) => void
}

const PromptLibraryDialog = (props: Props) => {
Expand Down
49 changes: 37 additions & 12 deletions src/app/components/PromptLibrary/Library.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Suspense, useCallback, useMemo, useState } from 'react'
import { Suspense, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BeatLoader } from 'react-spinners'
import useSWR from 'swr'
import closeIcon from '~/assets/icons/close.svg'
import { ChatMessage } from '~app/bots/chatgpt-api/consts'
import { trackEvent } from '~app/plausible'
import { Prompt, loadLocalPrompts, loadRemotePrompts, removeLocalPrompt, saveLocalPrompt } from '~services/prompts'
import { uuid } from '~utils'
import Button from '../Button'
import { Input, Textarea } from '../Input'
import Select from '../Select'
import Tabs, { Tab } from '../Tabs'

const ActionButton = (props: { text: string; onClick: () => void }) => {
Expand All @@ -23,11 +25,11 @@ const ActionButton = (props: { text: string; onClick: () => void }) => {

const PromptItem = (props: {
title: string
prompt: string
prompt: Prompt
edit?: () => void
remove?: () => void
copyToLocal?: () => void
insertPrompt: (text: string) => void
insertPrompt: (prompt: Prompt) => void
}) => {
const { t } = useTranslation()
const [saved, setSaved] = useState(false)
Expand All @@ -42,7 +44,12 @@ const PromptItem = (props: {
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary-text">{props.title}</p>
</div>
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1 items-center">
{props.prompt.role === 'system' && (
<div className="h-6 w-6 rounded-full bg-secondary flex items-center justify-center">
<span className="text-sm text-secondary-text">$</span>
</div>
)}
{props.edit && <ActionButton text={t('Edit')} onClick={props.edit} />}
{props.copyToLocal && <ActionButton text={t(saved ? 'Saved' : 'Save')} onClick={copyToLocal} />}
<ActionButton text={t('Use')} onClick={() => props.insertPrompt(props.prompt)} />
Expand All @@ -58,8 +65,14 @@ const PromptItem = (props: {
)
}

const PROMPT_ROLE_OPTIONS = [
{ value: 'user', name: 'User' },
{ value: 'system', name: 'System' },
]

function PromptForm(props: { initialData: Prompt; onSubmit: (data: Prompt) => void }) {
const { t } = useTranslation()
const roleRef = useRef<HTMLInputElement>(null)
const onSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
Expand All @@ -71,6 +84,7 @@ function PromptForm(props: { initialData: Prompt; onSubmit: (data: Prompt) => vo
id: props.initialData.id,
title: json.title as string,
prompt: json.prompt as string,
role: json.role as ChatMessage['role'],
})
}
},
Expand All @@ -82,6 +96,17 @@ function PromptForm(props: { initialData: Prompt; onSubmit: (data: Prompt) => vo
<span className="text-sm font-semibold block mb-1 text-primary-text">Prompt {t('Title')}</span>
<Input className="w-full" name="title" defaultValue={props.initialData.title} />
</div>
<div className="w-full">
<span className="text-sm font-semibold block mb-1 text-primary-text">
Prompt {t('Role')} ({t('PromptRoleWarning')})
</span>
<input name="role" hidden ref={roleRef} value={props.initialData.role ?? 'user'} />
<Select
options={PROMPT_ROLE_OPTIONS}
value={props.initialData.role ?? 'user'}
onChange={(value) => roleRef.current && (roleRef.current.value = value)}
></Select>
</div>
<div className="w-full">
<span className="text-sm font-semibold block mb-1 text-primary-text">Prompt {t('Content')}</span>
<Textarea className="w-full" name="prompt" defaultValue={props.initialData.prompt} />
Expand All @@ -91,7 +116,7 @@ function PromptForm(props: { initialData: Prompt; onSubmit: (data: Prompt) => vo
)
}

function LocalPrompts(props: { insertPrompt: (text: string) => void }) {
function LocalPrompts(props: { insertPrompt: (prompt: Prompt) => void }) {
const { t } = useTranslation()
const [formData, setFormData] = useState<Prompt | null>(null)
const localPromptsQuery = useSWR('local-prompts', () => loadLocalPrompts(), { suspense: true })
Expand All @@ -116,7 +141,7 @@ function LocalPrompts(props: { insertPrompt: (text: string) => void }) {
)

const create = useCallback(() => {
setFormData({ id: uuid(), title: '', prompt: '' })
setFormData({ id: uuid(), title: '', prompt: '', role: 'user' })
}, [])

return (
Expand All @@ -127,7 +152,7 @@ function LocalPrompts(props: { insertPrompt: (text: string) => void }) {
<PromptItem
key={prompt.id}
title={prompt.title}
prompt={prompt.prompt}
prompt={prompt}
edit={() => setFormData(prompt)}
remove={() => removePrompt(prompt.id)}
insertPrompt={props.insertPrompt}
Expand All @@ -150,7 +175,7 @@ function LocalPrompts(props: { insertPrompt: (text: string) => void }) {
)
}

function CommunityPrompts(props: { insertPrompt: (text: string) => void }) {
function CommunityPrompts(props: { insertPrompt: (prompt: Prompt) => void }) {
const promptsQuery = useSWR('community-prompts', () => loadRemotePrompts(), { suspense: true })

const copyToLocal = useCallback(async (prompt: Prompt) => {
Expand All @@ -164,7 +189,7 @@ function CommunityPrompts(props: { insertPrompt: (text: string) => void }) {
<PromptItem
key={index}
title={prompt.title}
prompt={prompt.prompt}
prompt={prompt}
insertPrompt={props.insertPrompt}
copyToLocal={() => copyToLocal(prompt)}
/>
Expand All @@ -189,10 +214,10 @@ function CommunityPrompts(props: { insertPrompt: (text: string) => void }) {
)
}

const PromptLibrary = (props: { insertPrompt: (text: string) => void }) => {
const PromptLibrary = (props: { insertPrompt: (prompt: Prompt) => void }) => {
const insertPrompt = useCallback(
(text: string) => {
props.insertPrompt(text)
(prompt: Prompt) => {
props.insertPrompt(prompt)
trackEvent('use_prompt')
},
[props],
Expand Down
8 changes: 5 additions & 3 deletions src/app/hooks/use-chat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useAtom } from 'jotai'
import { useCallback, useEffect, useMemo } from 'react'
import { Message } from '~app/components/Chat/ChatMessageInput'
import { trackEvent } from '~app/plausible'
import { chatFamily } from '~app/state'
import { setConversationMessages } from '~services/chat-history'
Expand All @@ -24,19 +25,20 @@ export function useChat(botId: BotId, page = 'singleton') {
)

const sendMessage = useCallback(
async (input: string) => {
async ({ text, role }: Message) => {
trackEvent('send_message', { botId })
const botMessageId = uuid()
setChatState((draft) => {
draft.messages.push({ id: uuid(), text: input, author: 'user' }, { id: botMessageId, text: '', author: botId })
draft.messages.push({ id: uuid(), text, author: 'user' }, { id: botMessageId, text: '', author: botId })
})
const abortController = new AbortController()
setChatState((draft) => {
draft.generatingMessageId = botMessageId
draft.abortController = abortController
})
await chatState.bot.sendMessage({
prompt: input,
prompt: text,
role,
signal: abortController.signal,
onEvent(event) {
if (event.type === 'UPDATE_ANSWER') {
Expand Down
2 changes: 2 additions & 0 deletions src/app/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const resources = {
Stop: 'Stop',
Title: 'Title',
Cotnent: 'Content',
PromptRoleWarning: 'Only available for ChatGPT using API',
},
},
de: {
Expand Down Expand Up @@ -53,6 +54,7 @@ const resources = {
'Export/Import All Data': 'Exportar/Importar todos los datos',
'Data includes all your settings, chat histories, and local prompts':
'Los datos incluyen todas tus configuraciones, historiales de chat y promociones locales',
PromptRoleWarning: 'Solo disponible para ChatGPT usando API',
},
},
fr: {
Expand Down
10 changes: 5 additions & 5 deletions src/app/pages/MultiBotChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useAtomValue } from 'jotai'
import { uniqBy } from 'lodash-es'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '~app/components/Button'
import ChatMessageInput from '~app/components/Chat/ChatMessageInput'
import ChatMessageInput, { Message } from '~app/components/Chat/ChatMessageInput'
import { useChat } from '~app/hooks/use-chat'
import { compareBotsAtom } from '~app/state'
import { BotId } from '../bots'
import ConversationPanel from '../components/Chat/ConversationPanel'
import { useTranslation } from 'react-i18next'

const MultiBotChatPanel: FC = () => {
const { t } = useTranslation()
Expand All @@ -20,12 +20,12 @@ const MultiBotChatPanel: FC = () => {
const generating = useMemo(() => chats.some((c) => c.generating), [chats])

const onUserSendMessage = useCallback(
(input: string, botId?: BotId) => {
(message: Message, botId?: BotId) => {
if (botId) {
const chat = chats.find((c) => c.botId === botId)
chat?.sendMessage(input)
chat?.sendMessage(message)
} else {
uniqBy(chats, (c) => c.botId).forEach((c) => c.sendMessage(input))
uniqBy(chats, (c) => c.botId).forEach((c) => c.sendMessage(message))
}
},
[chats],
Expand Down
5 changes: 4 additions & 1 deletion src/services/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import Browser from 'webextension-polyfill'
import { ofetch } from 'ofetch'
import Browser from 'webextension-polyfill'
import { ChatMessage } from '~app/bots/chatgpt-api/consts'

export interface Prompt {
id: string
title: string
prompt: string
role: ChatMessage['role']
}

export async function loadLocalPrompts() {
Expand All @@ -19,6 +21,7 @@ export async function saveLocalPrompt(prompt: Prompt) {
if (p.id === prompt.id) {
p.title = prompt.title
p.prompt = prompt.prompt
p.role = prompt.role
existed = true
break
}
Expand Down