From dea1b6a8512c4bf9b42b14417a0265877edf764a Mon Sep 17 00:00:00 2001 From: XiaoMo Date: Sun, 4 Jun 2023 15:55:58 +0800 Subject: [PATCH] first commit --- .dockerignore | 6 + .eslintignore | 7 + .eslintrc.js | 34 + .../ISSUE_TEMPLATE/bug_report_when_use.yml | 62 + .../bus_report_when_deploying.yml | 47 + .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/feature_request.yml | 32 + .github/ISSUE_TEMPLATE/typo.yml | 15 + .github/PULL_REQUEST_TEMPLATE.md | 18 + .github/workflows/build-docker.yml | 36 + .github/workflows/lint.yml | 31 + .github/workflows/release.yml | 30 + .github/workflows/sync.yml | 40 + .gitignore | 31 + .npmrc | 3 + .vscode/extensions.json | 4 + .vscode/launch.json | 11 + .vscode/settings.json | 17 + Dockerfile | 10 + LICENSE | 21 + README.md | 81 + README.zh-CN.md | 84 + SECURITY.md | 21 + astro.config.mjs | 74 + docker-compose.yml | 8 + netlify.toml | 12 + package.json | 82 + plugins/disableBlocks.ts | 22 + pnpm-lock.yaml | 10061 ++++++++++++++++ public/apple-touch-icon.png | Bin 0 -> 7761 bytes public/logo.svg | 10 + public/pwa-192.png | Bin 0 -> 3574 bytes public/pwa-512.png | Bin 0 -> 9374 bytes public/robots.txt | 2 + shims.d.ts | 16 + src/assets/emoji-picker.css | 15 + src/assets/prism.css | 62 + src/assets/transition.css | 56 + src/assets/zag-components.css | 115 + src/components/Main.astro | 18 + src/components/Markdown.tsx | 41 + src/components/ModalsLayer.tsx | 38 + src/components/Send.tsx | 219 + src/components/Share.astro | 11 + src/components/StreamableText.tsx | 54 + src/components/client-only/BuildStores.tsx | 11 + .../conversations/ConversationEdit.tsx | 84 + .../conversations/ConversationEditModal.tsx | 37 + .../conversations/ConversationSidebar.tsx | 36 + .../conversations/ConversationSidebarAdd.tsx | 20 + .../conversations/ConversationSidebarItem.tsx | 50 + .../header/ConversationHeaderInfo.tsx | 26 + .../header/ConversationHeaderShare.tsx | 38 + .../header/ConversationMessageClearButton.tsx | 34 + src/components/header/Header.tsx | 39 + src/components/main/Continuous.tsx | 67 + src/components/main/Conversation.tsx | 73 + src/components/main/ConversationEmpty.tsx | 29 + src/components/main/Image.tsx | 35 + src/components/main/MessageItem.tsx | 163 + src/components/main/Single.tsx | 45 + src/components/main/Welcome.tsx | 39 + .../settings/AppGeneralSettings.tsx | 59 + .../settings/ProviderGlobalSettings.tsx | 83 + src/components/settings/SettingsSidebar.tsx | 49 + .../settings/SettingsUIComponent.tsx | 69 + src/components/share/Conversation.tsx | 54 + src/components/share/banner.tsx | 59 + src/components/share/style.css | 17 + src/components/ui/BotSelect.tsx | 37 + src/components/ui/Button.tsx | 51 + src/components/ui/ConfirmModal.tsx | 29 + src/components/ui/EmojiPickerModal.tsx | 44 + src/components/ui/Modal.tsx | 58 + src/components/ui/SettingsApiKey.tsx | 72 + src/components/ui/SettingsInput.tsx | 32 + src/components/ui/SettingsNotDefined.tsx | 5 + src/components/ui/SettingsSelect.tsx | 30 + src/components/ui/SettingsSlider.tsx | 36 + src/components/ui/SettingsToggle.tsx | 25 + src/components/ui/Sidebar.tsx | 26 + src/components/ui/ThemeToggle.tsx | 33 + src/components/ui/base/DropdownMenu.tsx | 67 + src/components/ui/base/Select.tsx | 97 + src/components/ui/base/Slider.tsx | 56 + src/components/ui/base/Toggle.tsx | 37 + src/components/ui/base/Tooltip.tsx | 50 + src/components/ui/base/index.ts | 7 + src/env.d.ts | 16 + src/hooks/index.ts | 8 + src/hooks/useClickOutside.ts | 25 + src/hooks/useCopy.ts | 20 + src/hooks/useDark.ts | 29 + src/hooks/useDepGet.ts | 23 + src/hooks/useDisableTransition.ts | 32 + src/hooks/useI18n.ts | 41 + src/hooks/useLargeScreen.ts | 22 + src/hooks/useMobileScreen.ts | 22 + src/http/api.ts | 62 + src/layouts/Layout.astro | 46 + src/locale/index.ts | 18 + src/locale/lang/en.ts | 40 + src/locale/lang/index.ts | 2 + src/locale/lang/zh-cn.ts | 40 + src/logics/conversation.ts | 155 + src/logics/helper.ts | 28 + src/logics/stream.ts | 24 + src/pages/index.astro | 23 + src/pages/share.astro | 12 + src/providers/openai/api.ts | 32 + src/providers/openai/handler.ts | 85 + src/providers/openai/index.ts | 110 + src/providers/openai/parser.ts | 42 + src/providers/replicate/api.ts | 21 + src/providers/replicate/handler.ts | 70 + src/providers/replicate/index.ts | 36 + src/stores/conversation.ts | 84 + src/stores/messages.ts | 103 + src/stores/provider.ts | 52 + src/stores/settings.ts | 77 + src/stores/storage/conversation.ts | 45 + src/stores/storage/db.ts | 22 + src/stores/storage/message.ts | 45 + src/stores/storage/settings.ts | 45 + src/stores/streams.ts | 29 + src/stores/tests/conversation.mock.ts | 13 + src/stores/tests/message.mock.ts | 34 + src/stores/ui.ts | 21 + src/types.ts | 12 + src/types/app.ts | 20 + src/types/conversation.ts | 12 + src/types/message.ts | 21 + src/types/provider.ts | 80 + tsconfig.json | 12 + unocss.config.ts | 123 + vercel.json | 6 + 136 files changed, 15516 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .github/ISSUE_TEMPLATE/bug_report_when_use.yml create mode 100644 .github/ISSUE_TEMPLATE/bus_report_when_deploying.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/typo.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build-docker.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/sync.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 SECURITY.md create mode 100644 astro.config.mjs create mode 100644 docker-compose.yml create mode 100644 netlify.toml create mode 100644 package.json create mode 100644 plugins/disableBlocks.ts create mode 100644 pnpm-lock.yaml create mode 100644 public/apple-touch-icon.png create mode 100644 public/logo.svg create mode 100644 public/pwa-192.png create mode 100644 public/pwa-512.png create mode 100644 public/robots.txt create mode 100644 shims.d.ts create mode 100644 src/assets/emoji-picker.css create mode 100644 src/assets/prism.css create mode 100644 src/assets/transition.css create mode 100644 src/assets/zag-components.css create mode 100644 src/components/Main.astro create mode 100644 src/components/Markdown.tsx create mode 100644 src/components/ModalsLayer.tsx create mode 100644 src/components/Send.tsx create mode 100644 src/components/Share.astro create mode 100644 src/components/StreamableText.tsx create mode 100644 src/components/client-only/BuildStores.tsx create mode 100644 src/components/conversations/ConversationEdit.tsx create mode 100644 src/components/conversations/ConversationEditModal.tsx create mode 100644 src/components/conversations/ConversationSidebar.tsx create mode 100644 src/components/conversations/ConversationSidebarAdd.tsx create mode 100644 src/components/conversations/ConversationSidebarItem.tsx create mode 100644 src/components/header/ConversationHeaderInfo.tsx create mode 100644 src/components/header/ConversationHeaderShare.tsx create mode 100644 src/components/header/ConversationMessageClearButton.tsx create mode 100644 src/components/header/Header.tsx create mode 100644 src/components/main/Continuous.tsx create mode 100644 src/components/main/Conversation.tsx create mode 100644 src/components/main/ConversationEmpty.tsx create mode 100644 src/components/main/Image.tsx create mode 100644 src/components/main/MessageItem.tsx create mode 100644 src/components/main/Single.tsx create mode 100644 src/components/main/Welcome.tsx create mode 100644 src/components/settings/AppGeneralSettings.tsx create mode 100644 src/components/settings/ProviderGlobalSettings.tsx create mode 100644 src/components/settings/SettingsSidebar.tsx create mode 100644 src/components/settings/SettingsUIComponent.tsx create mode 100644 src/components/share/Conversation.tsx create mode 100644 src/components/share/banner.tsx create mode 100644 src/components/share/style.css create mode 100644 src/components/ui/BotSelect.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/ConfirmModal.tsx create mode 100644 src/components/ui/EmojiPickerModal.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/SettingsApiKey.tsx create mode 100644 src/components/ui/SettingsInput.tsx create mode 100644 src/components/ui/SettingsNotDefined.tsx create mode 100644 src/components/ui/SettingsSelect.tsx create mode 100644 src/components/ui/SettingsSlider.tsx create mode 100644 src/components/ui/SettingsToggle.tsx create mode 100644 src/components/ui/Sidebar.tsx create mode 100644 src/components/ui/ThemeToggle.tsx create mode 100644 src/components/ui/base/DropdownMenu.tsx create mode 100644 src/components/ui/base/Select.tsx create mode 100644 src/components/ui/base/Slider.tsx create mode 100644 src/components/ui/base/Toggle.tsx create mode 100644 src/components/ui/base/Tooltip.tsx create mode 100644 src/components/ui/base/index.ts create mode 100644 src/env.d.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useCopy.ts create mode 100644 src/hooks/useDark.ts create mode 100644 src/hooks/useDepGet.ts create mode 100644 src/hooks/useDisableTransition.ts create mode 100644 src/hooks/useI18n.ts create mode 100644 src/hooks/useLargeScreen.ts create mode 100644 src/hooks/useMobileScreen.ts create mode 100644 src/http/api.ts create mode 100644 src/layouts/Layout.astro create mode 100644 src/locale/index.ts create mode 100644 src/locale/lang/en.ts create mode 100644 src/locale/lang/index.ts create mode 100644 src/locale/lang/zh-cn.ts create mode 100644 src/logics/conversation.ts create mode 100644 src/logics/helper.ts create mode 100644 src/logics/stream.ts create mode 100644 src/pages/index.astro create mode 100644 src/pages/share.astro create mode 100644 src/providers/openai/api.ts create mode 100644 src/providers/openai/handler.ts create mode 100644 src/providers/openai/index.ts create mode 100644 src/providers/openai/parser.ts create mode 100644 src/providers/replicate/api.ts create mode 100644 src/providers/replicate/handler.ts create mode 100644 src/providers/replicate/index.ts create mode 100644 src/stores/conversation.ts create mode 100644 src/stores/messages.ts create mode 100644 src/stores/provider.ts create mode 100644 src/stores/settings.ts create mode 100644 src/stores/storage/conversation.ts create mode 100644 src/stores/storage/db.ts create mode 100644 src/stores/storage/message.ts create mode 100644 src/stores/storage/settings.ts create mode 100644 src/stores/streams.ts create mode 100644 src/stores/tests/conversation.mock.ts create mode 100644 src/stores/tests/message.mock.ts create mode 100644 src/stores/ui.ts create mode 100644 src/types.ts create mode 100644 src/types/app.ts create mode 100644 src/types/conversation.ts create mode 100644 src/types/message.ts create mode 100644 src/types/provider.ts create mode 100644 tsconfig.json create mode 100644 unocss.config.ts create mode 100644 vercel.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d16c317 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +*.md +Dockerfile +docker-compose.yml +LICENSE +netlify.toml +vercel.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..59a29c9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +dist +public +node_modules +.netlify +.vercel +.github +.changeset diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..cb63f0b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,34 @@ +module.exports = { + extends: ['@evan-yang', 'plugin:astro/recommended'], + rules: { + 'no-console': 'off', + 'react/display-name': 'off', + 'react-hooks/rules-of-hooks': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + 'react/jsx-key': 'off', + 'import/namespace': 'off', + }, + overrides: [ + { + files: ['*.astro'], + parser: 'astro-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + extraFileExtensions: ['.astro'], + }, + rules: { + 'no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], + }, + }, + { + // Define the configuration for ` diff --git a/src/locale/index.ts b/src/locale/index.ts new file mode 100644 index 0000000..1fe582c --- /dev/null +++ b/src/locale/index.ts @@ -0,0 +1,18 @@ +import * as Langs from './lang' +import type { SelectOptionType } from '@/types/provider' + +export type LanguageType = keyof typeof Langs + +export interface TranslatePair { + [key: string]: string | string[] | TranslatePair +} + +export interface language { + name: string + desc: string + locales: TranslatePair +} + +export const locales = Object.fromEntries(Object.entries(Langs).map(([key, value]) => [key, value.locales])) + +export const localesOptions: SelectOptionType[] = Object.entries(Langs).map(([key, value]) => ({ label: value.desc, value: key })) diff --git a/src/locale/lang/en.ts b/src/locale/lang/en.ts new file mode 100644 index 0000000..eacf4e8 --- /dev/null +++ b/src/locale/lang/en.ts @@ -0,0 +1,40 @@ +import type { language } from '..' + +export const en = { + name: 'en', + desc: 'English', + locales: { + settings: { + title: 'Settings', + save: 'Save', + general: { + title: 'General', + requestWithBackend: 'Request With Backend', + locale: 'Change system language', + }, + openai: { + title: 'OpenAI', + key: '', + }, + replicate: {}, + }, + conversations: { + title: 'Conversations', + add: 'New', + recent: 'Recents', + noRecent: 'No recents', + untitled: 'Untitled', + confirm: { + title: 'Delete all messages in this chat', + desc: 'This action cannot be undone.', + message: 'Delete this record', + btn: 'confirm', + cancel: 'cancel', + }, + }, + send: { + placeholder: 'Enter Something...', + button: 'Send', + }, + }, +} as language diff --git a/src/locale/lang/index.ts b/src/locale/lang/index.ts new file mode 100644 index 0000000..0ab03a6 --- /dev/null +++ b/src/locale/lang/index.ts @@ -0,0 +1,2 @@ +export * from './en' +export * from './zh-cn' diff --git a/src/locale/lang/zh-cn.ts b/src/locale/lang/zh-cn.ts new file mode 100644 index 0000000..3842a30 --- /dev/null +++ b/src/locale/lang/zh-cn.ts @@ -0,0 +1,40 @@ +import type { language } from '..' + +export const zhCN = { + name: 'zhCN', + desc: '简体中文', + locales: { + settings: { + title: '设置', + save: '保存', + general: { + title: '通用', + requestWithBackend: '请求代理后端', + locale: '切换语言', + }, + openai: { + title: 'OpenAI', + key: '', + }, + replicate: {}, + }, + conversations: { + title: '对话列表', + add: '新对话', + recent: '最近对话', + noRecent: '暂无最近对话', + untitled: '未命名对话', + confirm: { + title: '删除本会话的所有消息', + desc: '这将删除本会话的所有消息,且不可恢复', + message: '删除这条记录', + btn: '确认', + cancel: '取消', + }, + }, + send: { + placeholder: '输入内容...', + button: '发送', + }, + }, +} as language diff --git a/src/logics/conversation.ts b/src/logics/conversation.ts new file mode 100644 index 0000000..eda8efe --- /dev/null +++ b/src/logics/conversation.ts @@ -0,0 +1,155 @@ +import destr from 'destr' +import { getBotMetaById, getProviderById } from '@/stores/provider' +import { updateConversationById } from '@/stores/conversation' +import { clearMessagesByConversationId, getMessagesByConversationId, pushMessageByConversationId } from '@/stores/messages' +import { getGeneralSettings, getSettingsByProviderId } from '@/stores/settings' +import { setLoadingStateByConversationId, setStreamByConversationId } from '@/stores/streams' +import { currentErrorMessage } from '@/stores/ui' +import { generateRapidProviderPayload, promptHelper } from './helper' +import type { HandlerPayload, PromptResponse } from '@/types/provider' +import type { Conversation } from '@/types/conversation' +import type { ErrorMessage, Message } from '@/types/message' +import { baseUrl,getToken } from '../http/api' + +export const handlePrompt = async(conversation: Conversation, prompt?: string, signal?: AbortSignal) => { + const generalSettings = getGeneralSettings() + const bot = getBotMetaById(conversation.bot) + const [providerId, botId] = conversation.bot.split(':') + const provider = getProviderById(providerId) + if (!provider) return + // let callMethod = generalSettings.requestWithBackend ? 'backend' : 'frontend' as 'frontend' | 'backend' + let callMethod = 'backend' + if (provider.supportCallMethod === 'frontend' || provider.supportCallMethod === 'backend') + callMethod = provider.supportCallMethod + + if (bot.type !== 'chat_continuous') + clearMessagesByConversationId(conversation.id) + if (prompt) { + pushMessageByConversationId(conversation.id, { + id: `${conversation.id}:user:${Date.now()}`, + role: 'user', + content: prompt, + dateTime: new Date().getTime(), + }) + } + + setLoadingStateByConversationId(conversation.id, true) + let providerResponse: PromptResponse + const handlerPayload: HandlerPayload = { + conversationId: conversation.id, + conversationType: bot.type, + botId, + globalSettings: getSettingsByProviderId(provider.id), + botSettings: {}, + prompt, + messages: [ + ...(conversation.systemInfo ? [{ role: 'system', content: conversation.systemInfo }] : []) as Message[], + ...(destr(conversation.mockMessages) || []) as Message[], + ...getMessagesByConversationId(conversation.id).map(message => ({ + role: message.role, + content: message.content, + })), + ], + } + try { + providerResponse = await getProviderResponse(provider.id, handlerPayload, { + caller: callMethod, + signal, + }) + } catch (e) { + const error = e as Error + const cause = error?.cause as ErrorMessage + setLoadingStateByConversationId(conversation.id, false) + if (error.name !== 'AbortError') { + currentErrorMessage.set({ + url: cause?.url || '', + code: cause?.code || 'provider_error', + message: cause?.message || error.message || 'Unknown error', + }) + } + } + + if (providerResponse) { + const messageId = `${conversation.id}:assistant:${Date.now()}` + if (providerResponse instanceof ReadableStream) { + setStreamByConversationId(conversation.id, { + messageId, + stream: providerResponse, + }) + } + pushMessageByConversationId(conversation.id, { + id: messageId, + role: 'assistant', + content: typeof providerResponse === 'string' ? providerResponse : '', + stream: providerResponse instanceof ReadableStream, + dateTime: new Date().getTime(), + }) + } + setLoadingStateByConversationId(conversation.id, false) + + // Update conversation title + if (providerResponse && bot.type === 'chat_continuous' && !conversation.name) { + const inputText = conversation.systemInfo || prompt! + const rapidPayload = generateRapidProviderPayload(promptHelper.summarizeText(inputText), provider.id) + const generatedTitle = await getProviderResponse(provider.id, rapidPayload).catch(() => {}) as string || inputText + updateConversationById(conversation.id, { + name: generatedTitle.replace(/^['"\s]+|['"\s]+$/g, ''), + }) + } +} + +const getProviderResponse = async(providerId: string, payload: HandlerPayload, options?: { + caller: 'frontend' | 'backend' + signal?: AbortSignal +}) => { + if (options?.caller === 'frontend') { + return callProviderHandler(providerId, payload, options.signal) + } else { + + const backendResponse = await fetch(baseUrl()+`/chatgptApi/handle?providerId=${providerId}`, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + "Authorization": getToken() + }, + signal: options?.signal, + }) + + if (!backendResponse.ok) { + const error = await backendResponse.json() + throw new Error('Request failed', { + cause: error?.error, + }) + } + + if (backendResponse.headers.get('content-type')?.includes('application/json')){ + const data = await backendResponse.json() + throw new Error('Request failed', { + cause: data, + }) + return false; + } + + if (backendResponse.headers.get('content-type')?.includes('text/plain')) + return backendResponse.text() + else + return backendResponse.body + } +} + +// Called by both client and server +export const callProviderHandler = async(providerId: string, payload: HandlerPayload, signal?: AbortSignal) => { + console.log('callProviderHandler', payload) + + const provider = getProviderById(providerId) + if (!provider) return + + let response: PromptResponse + if (payload.botId === 'temp') + response = await provider.handleRapidPrompt?.(payload.prompt!, payload.globalSettings) + else + response = await provider.handlePrompt?.(payload, signal) + + return response +} diff --git a/src/logics/helper.ts b/src/logics/helper.ts new file mode 100644 index 0000000..2450138 --- /dev/null +++ b/src/logics/helper.ts @@ -0,0 +1,28 @@ +import { getSettingsByProviderId } from '@/stores/settings' +import type { HandlerPayload } from '@/types/provider' + +export const generateRapidProviderPayload = (prompt: string, providerId: string) => { + const payload = { + conversationId: 'temp', + conversationType: 'chat_single', + botId: 'temp', + globalSettings: getSettingsByProviderId(providerId), + botSettings: {}, + prompt, + messages: [], + } as HandlerPayload + return payload +} + +export const promptHelper = { + summarizeText: (text: string) => { + return [ + 'Summarize a short and relevant title of input with no more than 5 words.', + 'Rules:', + '1. Must use the same language as input.', + '2. Output the title directly, do not add any other content.', + 'The input is:', + text, + ].join('\n') + }, +} diff --git a/src/logics/stream.ts b/src/logics/stream.ts new file mode 100644 index 0000000..50485a0 --- /dev/null +++ b/src/logics/stream.ts @@ -0,0 +1,24 @@ +import type { Setter } from 'solid-js' + +export const convertReadableStreamToAccessor = async(stream: ReadableStream, setter: Setter) => { + let text = '' + try { + const reader = stream.getReader() + const decoder = new TextDecoder('utf-8') + let done = false + while (!done) { + const { value, done: readerDone } = await reader.read() + if (value) { + const char = decoder.decode(value) + if (char) { + text += char + setter(text) + } + } + done = readerDone + } + return text + } catch (error) { + return text + } +} diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..af9a3ee --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,23 @@ +--- +import Layout from '@/layouts/Layout.astro' +import Main from '@/components/Main.astro' +import ConversationSidebar from '@/components/conversations/ConversationSidebar' +import Settings from '@/components/settings/SettingsSidebar' +import ModalsLayer from '@/components/ModalsLayer' +import Sidebar from '@/components/ui/Sidebar' +import BuildStores from '@/components/client-only/BuildStores' +--- + + +
+ +
+ +
+ + +
diff --git a/src/pages/share.astro b/src/pages/share.astro new file mode 100644 index 0000000..58d48d6 --- /dev/null +++ b/src/pages/share.astro @@ -0,0 +1,12 @@ +--- +import Layout from '@/layouts/Layout.astro' +import BuildStores from '@/components/client-only/BuildStores' +import Share from '@/components/Share.astro' +--- + +
+ +
+ +
+ diff --git a/src/providers/openai/api.ts b/src/providers/openai/api.ts new file mode 100644 index 0000000..205ffdd --- /dev/null +++ b/src/providers/openai/api.ts @@ -0,0 +1,32 @@ +export interface OpenAIFetchPayload { + apiKey: string + baseUrl: string + body: Record + signal?: AbortSignal +} + +export const fetchChatCompletion = async(payload: OpenAIFetchPayload) => { + const initOptions = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${payload.apiKey}`, + }, + method: 'POST', + body: JSON.stringify(payload.body), + signal: payload.signal, + } + return fetch(`${payload.baseUrl}/v1/chat/completions`, initOptions) +} + +export const fetchImageGeneration = async(payload: OpenAIFetchPayload) => { + const initOptions = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${payload.apiKey}`, + }, + method: 'POST', + body: JSON.stringify(payload.body), + signal: payload.signal, + } + return fetch(`${payload.baseUrl}/v1/images/generations`, initOptions) +} diff --git a/src/providers/openai/handler.ts b/src/providers/openai/handler.ts new file mode 100644 index 0000000..ba7a216 --- /dev/null +++ b/src/providers/openai/handler.ts @@ -0,0 +1,85 @@ +import { fetchChatCompletion, fetchImageGeneration } from './api' +import { parseStream } from './parser' +import type { HandlerPayload, Provider } from '@/types/provider' + +export const handlePrompt: Provider['handlePrompt'] = async(payload, signal?: AbortSignal) => { + if (payload.botId === 'chat_continuous') + return handleChatCompletion(payload, signal) + if (payload.botId === 'chat_single') + return handleChatCompletion(payload, signal) + if (payload.botId === 'image_generation') + return handleImageGeneration(payload) +} + +export const handleRapidPrompt: Provider['handleRapidPrompt'] = async(prompt, globalSettings) => { + const rapidPromptPayload = { + conversationId: 'temp', + conversationType: 'chat_single', + botId: 'temp', + globalSettings: { + ...globalSettings, + model: 'gpt-3.5-turbo', + temperature: 0.4, + maxTokens: 2048, + top_p: 1, + stream: false, + }, + botSettings: {}, + prompt, + messages: [{ role: 'user', content: prompt }], + } as HandlerPayload + const result = await handleChatCompletion(rapidPromptPayload) + if (typeof result === 'string') + return result + return '' +} + +const handleChatCompletion = async(payload: HandlerPayload, signal?: AbortSignal) => { + const response = await fetchChatCompletion({ + apiKey: payload.globalSettings.apiKey as string, + baseUrl: (payload.globalSettings.baseUrl as string).trim().replace(/\/$/, ''), + body: { + model: payload.globalSettings.model as string, + messages: payload.messages, + temperature: payload.globalSettings.temperature as number, + max_tokens: payload.globalSettings.maxTokens as number, + top_p: payload.globalSettings.topP as number, + stream: payload.globalSettings.stream as boolean ?? true, + }, + signal, + }) + if (!response.ok) { + const responseJson = await response.json() + console.log('responseJson', responseJson) + const errMessage = responseJson.error?.message || response.statusText || 'Unknown error' + throw new Error(errMessage, { cause: responseJson.error }) + } + const isStream = response.headers.get('content-type')?.includes('text/event-stream') + if (isStream) { + return parseStream(response) + } else { + const resJson = await response.json() + return resJson.choices[0].message.content as string + } +} + +const handleImageGeneration = async(payload: HandlerPayload) => { + const prompt = payload.prompt + const response = await fetchImageGeneration({ + apiKey: payload.globalSettings.apiKey as string, + baseUrl: (payload.globalSettings.baseUrl as string).trim().replace(/\/$/, ''), + body: { + prompt, + n: 1, + size: '512x512', + response_format: 'url', // TODO: support 'b64_json' + }, + }) + if (!response.ok) { + const responseJson = await response.json() + const errMessage = responseJson.error?.message || response.statusText || 'Unknown error' + throw new Error(errMessage) + } + const resJson = await response.json() + return resJson.data[0].url +} diff --git a/src/providers/openai/index.ts b/src/providers/openai/index.ts new file mode 100644 index 0000000..bf47a59 --- /dev/null +++ b/src/providers/openai/index.ts @@ -0,0 +1,110 @@ +import { + handlePrompt, + handleRapidPrompt, +} from './handler' +import type { Provider } from '@/types/provider' + +const providerOpenAI = () => { + const provider: Provider = { + id: 'provider-openai', + icon: 'i-simple-icons-openai', // @unocss-include + name: 'OpenAI', + globalSettings: [ + // { + // key: 'apiKey', + // name: 'API Key', + // // type: 'api-key', + // default: '8b78c6d7027810c0b1aa04b2b81a5ef2', + // }, + // { + // key: 'baseUrl', + // name: 'Base URL', + // description: 'Custom base url for OpenAI API.', + // // type: 'input', + // default: 'https://mchat.mbmzone.com/api/openai', + // }, + { + key: 'serviceprovider', + name: '服务商选择', + description: '你可以随时切换支持服务商.', + type: 'select', + options: [ + { value: 'openai', label: 'openai' }, + { value: 'mbm-gpt', label: 'mbm-gpt' }, + { value: 'microsoft', label: 'microsoft' }, + ], + default: 'openai', + }, + { + key: 'model', + name: 'OpenAI model', + description: 'Custom gpt model for OpenAI API.', + type: 'select', + options: [ + { value: 'gpt-3.5-turbo', label: 'gpt-3.5-turbo' }, + { value: 'gpt-4', label: 'gpt-4' }, + { value: 'gpt-4-0314', label: 'gpt-4-0314' }, + { value: 'gpt-4-32k', label: 'gpt-4-32k' }, + { value: 'gpt-4-32k-0314', label: 'gpt-4-32k-0314' }, + { value: 'gpt-3.5-turbo-0301', label: 'gpt-3.5-turbo-0301' }, + ], + default: 'gpt-3.5-turbo', + }, + { + key: 'maxTokens', + name: 'Max Tokens', + description: 'The maximum number of tokens to generate in the completion.', + type: 'slider', + min: 0, + max: 32768, + default: 2048, + step: 1, + }, + { + key: 'temperature', + name: 'Temperature', + type: 'slider', + description: 'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.', + min: 0, + max: 2, + default: 0.7, + step: 0.01, + }, + { + key: 'top_p', + name: 'Top P', + description: 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + type: 'slider', + min: 0, + max: 1, + default: 1, + step: 0.01, + }, + ], + bots: [ + { + id: 'chat_continuous', + type: 'chat_continuous', + name: 'Continuous Chat', + settings: [], + }, + { + id: 'chat_single', + type: 'chat_single', + name: 'Single Chat', + settings: [], + }, + { + id: 'image_generation', + type: 'image_generation', + name: 'DALL·E', + settings: [], + }, + ], + handlePrompt, + handleRapidPrompt, + } + return provider +} + +export default providerOpenAI diff --git a/src/providers/openai/parser.ts b/src/providers/openai/parser.ts new file mode 100644 index 0000000..79be4ee --- /dev/null +++ b/src/providers/openai/parser.ts @@ -0,0 +1,42 @@ +import { createParser } from 'eventsource-parser' +import type { ParsedEvent, ReconnectInterval } from 'eventsource-parser' + +export const parseStream = (rawResponse: Response) => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const rb = rawResponse.body as ReadableStream + + return new ReadableStream({ + async start(controller) { + const streamParser = (event: ParsedEvent | ReconnectInterval) => { + if (event.type === 'event') { + const data = event.data + if (data === '[DONE]') { + controller.close() + return + } + try { + const json = JSON.parse(data) + const text = json.choices[0].delta?.content || '' + const queue = encoder.encode(text) + controller.enqueue(queue) + } catch (e) { + controller.error(e) + } + } + } + const reader = rb.getReader() + const parser = createParser(streamParser) + let done = false + while (!done) { + const { done: isDone, value } = await reader.read() + if (isDone) { + done = true + controller.close() + return + } + parser.feed(decoder.decode(value)) + } + }, + }) +} diff --git a/src/providers/replicate/api.ts b/src/providers/replicate/api.ts new file mode 100644 index 0000000..a789928 --- /dev/null +++ b/src/providers/replicate/api.ts @@ -0,0 +1,21 @@ +interface FetchPayload { + method?: 'POST' | 'GET' + token: string + predictionId?: string + body?: Record +} + +export const fetchImageGeneration = async(payload: FetchPayload) => { + const initOptions = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Token ${payload.token}`, + }, + method: payload.method || 'GET', + body: payload.method === 'POST' ? JSON.stringify(payload.body || {}) : undefined, + } + let fetchUrl = 'https://api.replicate.com/v1/predictions' + if (payload.predictionId) + fetchUrl += `/${payload.predictionId}` + return fetch(fetchUrl, initOptions) +} diff --git a/src/providers/replicate/handler.ts b/src/providers/replicate/handler.ts new file mode 100644 index 0000000..308f9c7 --- /dev/null +++ b/src/providers/replicate/handler.ts @@ -0,0 +1,70 @@ +import { fetchImageGeneration } from './api' +import type { HandlerPayload, Provider } from '@/types/provider' +import type { Message } from '@/types/message' + +export const handlePrompt: Provider['handlePrompt'] = async(payload, signal?: AbortSignal) => { + if (payload.botId === 'stable-diffusion') + return handleReplicateGenerate('db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf', payload) + if (payload.botId === 'waifu-diffusion') + return handleReplicateGenerate('25d2f75ecda0c0bed34c806b7b70319a53a1bccad3ade1a7496524f013f48983', payload) +} + +const handleReplicateGenerate = async(modelVersion: string, payload: HandlerPayload) => { + const prompt = payload.prompt + const response = await fetchImageGeneration({ + token: payload.globalSettings.token as string, + method: 'POST', + body: { + version: modelVersion, + input: { + prompt, + }, + }, + }) + if (!response.ok) { + const responseJson = await response.json() + const errMessage = responseJson.detail || response.statusText || 'Unknown error' + throw new Error(errMessage, { + cause: { + code: response.status, + message: errMessage, + }, + }) + } + const resJson = await response.json() + + return waitImageWithPrediction(resJson, payload.globalSettings.token as string) +} + +interface Prediction { + id: string + input: { + prompt: string + } + output: string[] | null + status: 'starting' | 'succeeded' | 'failed' +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const waitImageWithPrediction = async(prediction: Prediction, token: string) => { + let currentPrediction = prediction + while (currentPrediction.status !== 'succeeded' && currentPrediction.status !== 'failed') { + await sleep(1000) + const response = await fetchImageGeneration({ + predictionId: currentPrediction.id, + token, + }) + if (!response.ok) { + const responseJson = await response.json() + const errMessage = responseJson.error?.message || 'Unknown error' + throw new Error(errMessage) + } + prediction = await response.json() + currentPrediction = prediction + // console.log('currentPrediction', prediction) + } + if (!currentPrediction.output || currentPrediction.output.length === 0) + throw new Error('No output') + return currentPrediction.output[0] +} diff --git a/src/providers/replicate/index.ts b/src/providers/replicate/index.ts new file mode 100644 index 0000000..fb02322 --- /dev/null +++ b/src/providers/replicate/index.ts @@ -0,0 +1,36 @@ +import { handlePrompt } from './handler' +import type { Provider } from '@/types/provider' + +const providerReplicate = () => { + const provider: Provider = { + id: 'provider-replicate', + icon: 'i-carbon-replicate', // @unocss-include + name: 'Replicate', + globalSettings: [ + { + key: 'token', + name: 'Replicate API token', + type: 'api-key', + }, + ], + bots: [ + { + id: 'stable-diffusion', + type: 'image_generation', + name: 'Stable Diffusion', + settings: [], + }, + { + id: 'waifu-diffusion', + type: 'image_generation', + name: 'Waifu Diffusion', + settings: [], + }, + ], + supportCallMethod: 'backend', + handlePrompt, + } + return provider +} + +export default providerReplicate diff --git a/src/stores/conversation.ts b/src/stores/conversation.ts new file mode 100644 index 0000000..6193d6c --- /dev/null +++ b/src/stores/conversation.ts @@ -0,0 +1,84 @@ +import { action, atom, computed, map } from 'nanostores' +import { botMetaList } from './provider' +import { clearMessagesByConversationId } from './messages' +import { conversationMapData } from './tests/conversation.mock' +import { db } from './storage/conversation' +import type { Conversation } from '@/types/conversation' + +export const conversationMap = map>({}) +export const currentConversationId = atom('') +export const currentConversation = computed(currentConversationId, (id) => { + return id ? conversationMap.get()[id] as Conversation : null +}) +export const currentEditingConversationId = atom('') +export const currentEditingConversation = computed(currentEditingConversationId, (id) => { + return id ? conversationMap.get()[id] as Conversation : null +}) +export const conversationMapSortList = computed(conversationMap, (map) => { + return Object.values(map).sort((a, b) => b.lastUseTime - a.lastUseTime) +}) + +const migrateConversationStoreIfNeeded = () => { + const rawData = conversationMap.get() + Object.values(rawData).forEach((conversation) => { + // @ts-expect-error migrate old data + if (conversation.providerId && conversation.conversationType) { + const typeDict = { + single: 'chat_single', + continuous: 'chat_continuous', + image: 'image_generation', + } + const providerDict = { + 'provider-stable-diffusion': 'provider-replicate', + } + const newConversationData = { + id: conversation.id, + // @ts-expect-error migrate old data + bot: `${providerDict[conversation.providerId] || conversation.providerId}:${typeDict[conversation.conversationType] || 'chat_single'}`, + name: conversation.name, + icon: '', + systemInfo: conversation.systemInfo, + mockMessages: conversation.mockMessages, + lastUseTime: conversation.lastUseTime, + } + conversationMap.setKey(conversation.id, newConversationData) + db.setItem(conversation.id, newConversationData) + } + }) +} + +export const rebuildConversationStore = async() => { + const data = await db.exportData() || {} + conversationMap.set(data) + // conversationMap.set(conversationMapData) + migrateConversationStoreIfNeeded() +} + +export const addConversation = action(conversationMap, 'addConversation', (map, instance?: Partial) => { + const instanceId = instance?.id || `id_${Date.now()}` + const conversation: Conversation = { + id: instanceId, + bot: botMetaList[0]?.value, + name: instance?.name || '', + icon: instance?.icon || '', + lastUseTime: Date.now(), + } + map.setKey(instanceId, conversation) + db.setItem(instanceId, conversation) + currentConversationId.set(instanceId) +}) + +export const updateConversationById = action(conversationMap, 'updateConversationById', (map, id: string, payload: Partial) => { + const conversation = { + ...map.get()[id], + ...payload, + } + map.setKey(id, conversation) + db.updateItem(id, conversation) +}) + +export const deleteConversationById = action(conversationMap, 'deleteConversationById', (map, id: string) => { + map.set(Object.fromEntries(Object.entries(map.get()).filter(([key]) => key !== id))) + db.deleteItem(id) + clearMessagesByConversationId(id, true) +}) diff --git a/src/stores/messages.ts b/src/stores/messages.ts new file mode 100644 index 0000000..418b5cf --- /dev/null +++ b/src/stores/messages.ts @@ -0,0 +1,103 @@ +import { action, map } from 'nanostores' +import { conversationMessagesMapData } from './tests/message.mock' +import { db } from './storage/message' +import { updateConversationById } from './conversation' +import type { MessageInstance } from '@/types/message' + +export const conversationMessagesMap = map>({}) + +export const rebuildMessagesStore = async() => { + const data = await db.exportData() || {} + conversationMessagesMap.set(data) + // conversationMessagesMap.set(conversationMessagesMapData) +} + +export const getMessagesByConversationId = (id: string) => { + return conversationMessagesMap.get()[id] || [] +} + +export const updateMessage = action( + conversationMessagesMap, + 'updateMessage', + (map, conversationId: string, id: string, payload: Partial) => { + const oldMessages = map.get()[conversationId] || [] + const newMessages = oldMessages.map((message) => { + if (message.id === id) { + return { + ...message, + ...payload, + } + } + return message + }) + map.setKey(conversationId, newMessages) + db.setItem(conversationId, newMessages) + }, +) + +export const pushMessageByConversationId = action( + conversationMessagesMap, + 'pushMessageByConversationId', + (map, id: string, payload: MessageInstance) => { + const oldMessages = map.get()[id] || [] + if (oldMessages[oldMessages.length - 1]?.id === payload.id) return + map.setKey(id, [...oldMessages, payload]) + db.setItem(id, [...oldMessages, payload]) + updateConversationById(id, { + lastUseTime: Date.now(), + }) + }, +) + +export const clearMessagesByConversationId = action( + conversationMessagesMap, + 'clearMessagesByConversationId', + (map, id: string, deleteChat?: boolean) => { + map.setKey(id, []) + db.deleteItem(id) + !deleteChat && updateConversationById(id, { + lastUseTime: Date.now(), + }) + }, +) + +export const deleteMessageByConversationId = action( + conversationMessagesMap, + 'deleteMessageByConversationId', + (map, id: string, payload: MessageInstance) => { + const oldMessages = map.get()[id] || [] + map.setKey(id, [...oldMessages.filter(message => message.id !== payload.id)]) + db.setItem(id, [...oldMessages.filter(message => message.id !== payload.id)]) + updateConversationById(id, { + lastUseTime: Date.now(), + }) + }, +) + +export const spliceMessageByConversationId = action( + conversationMessagesMap, + 'spliceMessagesByConversationId', + (map, id: string, payload: MessageInstance) => { + const oldMessages = map.get()[id] || [] + const currentIndex = oldMessages.findIndex(message => message.id === payload.id) + map.setKey(id, [...oldMessages.slice(0, currentIndex + 1)]) + db.setItem(id, [...oldMessages.slice(0, currentIndex + 1)]) + updateConversationById(id, { + lastUseTime: Date.now(), + }) + }, +) + +export const spliceUpdateMessageByConversationId = action( + conversationMessagesMap, + 'spliceMessagesByConversationId', + (map, id: string, payload: MessageInstance) => { + const oldMessages = map.get()[id] || [] + const currentIndex = oldMessages.findIndex(message => message.id === payload.id) + map.setKey(id, [...oldMessages.slice(0, currentIndex), payload]) + db.setItem(id, [...oldMessages.slice(0, currentIndex), payload]) + updateConversationById(id, { + lastUseTime: Date.now(), + }) + }, +) diff --git a/src/stores/provider.ts b/src/stores/provider.ts new file mode 100644 index 0000000..55d04ab --- /dev/null +++ b/src/stores/provider.ts @@ -0,0 +1,52 @@ +import providerOpenAI from '@/providers/openai' +import providerReplicate from '@/providers/replicate' +import { allConversationTypes } from '@/types/conversation' +import type { BotMeta } from '@/types/app' + +export const providerList = [ + providerOpenAI(), + // providerReplicate(), +] + +export const providerMetaList = providerList.map(provider => ({ + id: provider.id, + name: provider.name, + icon: provider.icon, + bots: provider.bots, +})) + +export const platformSettingsUIList = providerList.map(provider => ({ + id: provider.id, + icon: provider.icon, + name: provider.name, + settingsUI: provider.globalSettings, +})) + +const botMetaMap = providerMetaList.reduce((acc, provider) => { + provider.bots.forEach((bot) => { + if (allConversationTypes.includes(bot.type)) { + acc[`${provider.id}:${bot.id}`] = { + value: `${provider.id}:${bot.id}`, + type: bot.type, + label: bot.name, + provider: { + id: provider.id, + name: provider.name, + icon: provider.icon, + }, + settingsUI: bot.settings, + } + } + }) + return acc +}, {} as Record) + +export const botMetaList = Object.values(botMetaMap) + +export const getProviderById = (id: string) => { + return providerList.find(provider => provider.id === id) +} + +export const getBotMetaById = (id: string) => { + return botMetaMap[id] || null +} diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..fbb542e --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,77 @@ +import { action, atom, map } from 'nanostores' +import { db } from './storage/settings' +import { getProviderById, providerMetaList } from './provider' +import type { SettingsPayload } from '@/types/provider' +import type { GeneralSettings } from '@/types/app' + +export const providerSettingsMap = map>({}) +export const globalAbortController = atom(null) + +export const rebuildSettingsStore = async() => { + const exportData = await db.exportData() + const defaultData = defaultSettingsStore() + const data: Record = {} + providerMetaList.forEach((provider) => { + data[provider.id] = { + ...defaultData[provider.id] || {}, + ...exportData?.[provider.id] || {}, + } + }) + data.general = exportData?.general || {} + providerSettingsMap.set(data) +} + +export const getSettingsByProviderId = (id: string) => { + return providerSettingsMap.get()[id] || {} +} + +export const setSettingsByProviderId = action( + providerSettingsMap, + 'setSettingsByProviderId', + (map, id: string, payload: SettingsPayload) => { + const mergedSettings = { + ...defaultSettingsByProviderId(id), + ...payload, + } + map.setKey(id, mergedSettings) + db.setItem(id, mergedSettings) + }, +) + +export const getGeneralSettings = () => { + return (providerSettingsMap.get().general || {}) as unknown as GeneralSettings +} + +export const updateGeneralSettings = action( + providerSettingsMap, + 'setSettingsByProviderId', + (map, payload: Partial) => { + const mergedSettings = { + ...map.get().general || {}, + ...payload, + } + map.setKey('general', mergedSettings) + db.setItem('general', mergedSettings) + }, +) + +export const defaultSettingsStore = () => { + const defaultSettings: Record = {} + providerMetaList.forEach((provider) => { + defaultSettings[provider.id] = defaultSettingsByProviderId(provider.id) + }) + return defaultSettings +} + +export const defaultSettingsByProviderId = (id: string) => { + const provider = getProviderById(id) + if (!provider || !provider.globalSettings) + return {} + const globalSettings = provider.globalSettings + const defaultSettings: SettingsPayload = {} + globalSettings.forEach((setting) => { + if (setting.default) + defaultSettings[setting.key] = setting.default + }) + return defaultSettings +} diff --git a/src/stores/storage/conversation.ts b/src/stores/storage/conversation.ts new file mode 100644 index 0000000..6687dba --- /dev/null +++ b/src/stores/storage/conversation.ts @@ -0,0 +1,45 @@ +import { del, entries, get, set, update } from 'idb-keyval' +import { conversations } from './db' +import type { Conversation } from '@/types/conversation' + +const setItem = async(key: string, item: Conversation) => { + const store = conversations.get() + if (store) + await set(key, item, store) +} + +const getItem = async(key: string) => { + const store = conversations.get() + if (store) + return get(key, store) + return null +} + +const updateItem = async(key: string, item: Conversation) => { + const store = conversations.get() + if (store) + await update(key, () => item, store) +} + +const deleteItem = async(key: string) => { + const store = conversations.get() + if (store) + await del(key, store) +} + +const exportData = async() => { + const store = conversations.get() + if (store) { + const entriesData = await entries(store) + return Object.fromEntries(entriesData) as Record + } + return null +} + +export const db = { + setItem, + getItem, + updateItem, + deleteItem, + exportData, +} diff --git a/src/stores/storage/db.ts b/src/stores/storage/db.ts new file mode 100644 index 0000000..51576dc --- /dev/null +++ b/src/stores/storage/db.ts @@ -0,0 +1,22 @@ +import { atom } from 'nanostores' +import { createStore } from 'idb-keyval' +import { rebuildConversationStore } from '../conversation' +import { rebuildMessagesStore } from '../messages' +import { rebuildSettingsStore } from '../settings' +import type { UseStore } from 'idb-keyval' + +export const conversations = atom(null) +export const messages = atom(null) +export const settings = atom(null) + +export const createStores = () => { + conversations.set(createStore('conversations', 'keyval')) + messages.set(createStore('messages', 'keyval')) + settings.set(createStore('settings', 'keyval')) +} + +export const rebuildStores = async() => { + await rebuildConversationStore() + await rebuildMessagesStore() + await rebuildSettingsStore() +} diff --git a/src/stores/storage/message.ts b/src/stores/storage/message.ts new file mode 100644 index 0000000..dfd89c2 --- /dev/null +++ b/src/stores/storage/message.ts @@ -0,0 +1,45 @@ +import { del, entries, get, set, update } from 'idb-keyval' +import { messages } from './db' +import type { MessageInstance } from '@/types/message' + +const setItem = async(key: string, item: MessageInstance[]) => { + const store = messages.get() + if (store) + await set(key, item, store) +} + +const getItem = async(key: string) => { + const store = messages.get() + if (store) + return get(key, store) + return null +} + +const updateItem = async(key: string, item: MessageInstance[]) => { + const store = messages.get() + if (store) + await update(key, () => item, store) +} + +const deleteItem = async(key: string) => { + const store = messages.get() + if (store) + await del(key, store) +} + +const exportData = async() => { + const store = messages.get() + if (store) { + const entriesData = await entries(store) + return Object.fromEntries(entriesData) as Record + } + return null +} + +export const db = { + setItem, + getItem, + updateItem, + deleteItem, + exportData, +} diff --git a/src/stores/storage/settings.ts b/src/stores/storage/settings.ts new file mode 100644 index 0000000..ba3019a --- /dev/null +++ b/src/stores/storage/settings.ts @@ -0,0 +1,45 @@ +import { del, entries, get, set, update } from 'idb-keyval' +import { settings } from './db' +import type { SettingsPayload } from '@/types/provider' + +const setItem = async(key: string, item: SettingsPayload) => { + const store = settings.get() + if (store) + await set(key, item, store) +} + +const getItem = async(key: string) => { + const store = settings.get() + if (store) + return get(key, store) + return null +} + +const updateItem = async(key: string, item: SettingsPayload) => { + const store = settings.get() + if (store) + await update(key, () => item, store) +} + +const deleteItem = async(key: string) => { + const store = settings.get() + if (store) + await del(key, store) +} + +const exportData = async() => { + const store = settings.get() + if (store) { + const entriesData = await entries(store) + return Object.fromEntries(entriesData) as Record + } + return null +} + +export const db = { + setItem, + getItem, + updateItem, + deleteItem, + exportData, +} diff --git a/src/stores/streams.ts b/src/stores/streams.ts new file mode 100644 index 0000000..95d261e --- /dev/null +++ b/src/stores/streams.ts @@ -0,0 +1,29 @@ +import { action, map } from 'nanostores' +import type { StreamInstance } from '@/types/message' + +export const streamsMap = map>({}) +export const loadingStateMap = map>({}) + +export const getStreamByConversationId = (id: string) => { + return streamsMap.get()[id] || null +} + +export const setStreamByConversationId = action( + streamsMap, + 'setStreamByConversationId', + (map, id: string, payload: StreamInstance) => { + map.setKey(id, payload) + }, +) + +export const deleteStreamById = action(streamsMap, 'deleteStreamById', (map, id: string) => { + map.set(Object.fromEntries(Object.entries(map.get()).filter(([key]) => key !== id))) +}) + +export const setLoadingStateByConversationId = action( + loadingStateMap, + 'setLoadingStateByConversationId', + (map, id: string, loading: boolean) => { + map.setKey(id, loading) + }, +) diff --git a/src/stores/tests/conversation.mock.ts b/src/stores/tests/conversation.mock.ts new file mode 100644 index 0000000..8717842 --- /dev/null +++ b/src/stores/tests/conversation.mock.ts @@ -0,0 +1,13 @@ +import type { Conversation } from '@/types/conversation' + +const testMarkdown: Conversation = { + id: 'test_markdown', + providerId: 'provider-openai', + conversationType: 'continuous', + name: 'Test Markdown', + icon: '', +} + +export const conversationMapData = { + test_markdown: testMarkdown, +} diff --git a/src/stores/tests/message.mock.ts b/src/stores/tests/message.mock.ts new file mode 100644 index 0000000..dac1937 --- /dev/null +++ b/src/stores/tests/message.mock.ts @@ -0,0 +1,34 @@ + +import type { MessageInstance } from '@/types/message' + +const testMarkdown: MessageInstance[] = [ + { role: 'user', id: '0', content: 'Headings' }, + { role: 'assistant', id: '0', content: '# Heading level 1\n## Heading level 2\n### Heading level 3\n#### Heading level 4\n##### Heading level 5\n###### Heading level 6\ncontent' }, + { role: 'user', id: '0', content: 'Paragraphs' }, + { role: 'assistant', id: '0', content: 'I really like using Markdown.\n\nI think I\'ll use it to format all of my documents from now on.' }, + { role: 'user', id: '0', content: 'Emphasis' }, + { role: 'assistant', id: '0', content: 'This is *emphasized* text.\nThis is _emphasized_ text.\n\nThis is **strong** text.\nThis is __strong__ text.\n\nThis is ***emphasized and strong*** text.\nThis is ___emphasized and strong___ text.\n\nThis is ~~strikethrough~~ text.' }, + { role: 'assistant', id: '0', content: 'This is **bold** and _italic_ text.\nThis is __bold__ and *italic* text.\n\nThis is ***bold and italic*** text.\nThis is ___bold and italic___ text.' }, + { role: 'user', id: '0', content: 'Blockquotes' }, + { role: 'assistant', id: '0', content: '> Dorothy followed her through many of the beautiful rooms in her castle.\n>\n> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.' }, + { role: 'assistant', id: '0', content: '> Dorothy followed her through many of the beautiful rooms in her castle.\n>\n>> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.' }, + { role: 'assistant', id: '0', content: '> #### The quarterly results look great!\n>\n> - Revenue was off the chart.\n> - Profits were higher than ever.\n>\n> *Everything* is going according to **plan**.' }, + { role: 'user', id: '0', content: 'Lists' }, + { role: 'assistant', id: '0', content: '1. First item\n2. Second item\n3. Third item\n - Indented item\n - Indented item\n4. Fourth item' }, + { role: 'user', id: '0', content: 'Code' }, + { role: 'assistant', id: '0', content: 'At the command prompt, type `nano`.' }, + { role: 'assistant', id: '0', content: '```\nfunction test() {\n console.log("notice the blank line before this function?");\n}\n```' }, + { role: 'user', id: '0', content: 'Links' }, + { role: 'assistant', id: '0', content: 'This is [an example](http://example.com/ "Title") inline link.\n\n[This link](http://example.net/) has no title attribute.' }, + { role: 'assistant', id: '0', content: 'I love supporting the **[Site](http://example.com)**.\nThis is the *[Site](http://example.com)*.\nSee the section on [`code`](#code).' }, + { role: 'user', id: '0', content: 'Tables' }, + { role: 'assistant', id: '0', content: 'First Header | Second Header\n------------ | -------------\nContent from cell 1 | Content from cell 2\nContent in the first column | Content in the second column' }, + { role: 'assistant', id: '0', content: '| Syntax | Description |\n| ----------- | ----------- |\n| Header | Title |\n| Paragraph | Text |' }, + { role: 'assistant', id: '0', content: '| Syntax | Description | Test Text |\n| :--- | :----: | ---: |\n| Header | Title | Here\'s this |\n| Paragraph | Text | And more |' }, + { role: 'user', id: '0', content: 'GFM Features' }, + { role: 'assistant', id: '0', content: '## Autolink literals\n\nwww.example.com, https://example.com, and contact@example.com.\n\n## Footnote\n\nA note[^1]\n\n[^1]: Big note.\n\n## Strikethrough\n\n~one~ or ~~two~~ tildes.\n\n## Table\n\n| a | b | c | d |\n| - | :- | -: | :-: |\n\n## Tag filter\n\n\n\n## Tasklist\n\n* [ ] to do\n* [x] done' }, +] + +export const conversationMessagesMapData = { + test_markdown: testMarkdown, +} diff --git a/src/stores/ui.ts b/src/stores/ui.ts new file mode 100644 index 0000000..7990c17 --- /dev/null +++ b/src/stores/ui.ts @@ -0,0 +1,21 @@ +import { atom } from 'nanostores' +import type { ErrorMessage } from '@/types/message' + +export const showConversationSidebar = atom(false) +export const showSettingsSidebar = atom(false) +export const showConversationEditModal = atom(false) +export const showEmojiPickerModal = atom(false) +export const showConfirmModal = atom(false) + +export const isSendBoxFocus = atom(false) +export const currentErrorMessage = atom<ErrorMessage | null>(null) +export const emojiPickerCurrentPick = atom<string | undefined>() + +export const scrollController = () => { + const elementList = () => Array.from(document.getElementsByClassName('scroll-list')) + return { + scrollToTop: () => elementList().forEach(element => element.scrollTo({ top: 0, behavior: 'smooth' })), + scrollToBottom: () => elementList().forEach(element => element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' })), + instantToBottom: () => elementList().forEach(element => element.scrollTo({ top: element.scrollHeight })), + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9f66533 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,12 @@ +// TODO: Delete this file +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface ErrorMessage { + code: string + message: string +} + +export type ChatType = 'single' | 'continuous' | 'image' diff --git a/src/types/app.ts b/src/types/app.ts new file mode 100644 index 0000000..edb7cb4 --- /dev/null +++ b/src/types/app.ts @@ -0,0 +1,20 @@ +import type { ConversationType } from './conversation' +import type { SettingsUI } from './provider' + +export interface GeneralSettings { + /** Default request directly, can choose to request via proxy */ + requestWithBackend: boolean + locale: string +} + +export interface BotMeta { + value: string + type: ConversationType + label: string + provider: { + id: string + name: string + icon: string + } + settingsUI: SettingsUI[] +} diff --git a/src/types/conversation.ts b/src/types/conversation.ts new file mode 100644 index 0000000..bbd2ecd --- /dev/null +++ b/src/types/conversation.ts @@ -0,0 +1,12 @@ +export const allConversationTypes = ['chat_single', 'chat_continuous', 'image_generation'] as const +export type ConversationType = typeof allConversationTypes[number] + +export interface Conversation { + id: string + bot: string + name: string + icon: string + systemInfo?: string + mockMessages?: string + lastUseTime: number +} diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 0000000..b91a579 --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,21 @@ +export interface Message { + role: 'system' | 'user' | 'assistant' + content: string +} + +/** Used in app */ +export interface MessageInstance extends Message { + id: string + stream?: boolean + dateTime?: number +} + +export interface ErrorMessage { + code: string + message: string +} + +export interface StreamInstance { + messageId: string + stream: ReadableStream +} diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..dbabea9 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,80 @@ +import type { ConversationType } from './conversation' +import type { Message } from './message' + +export interface Provider { + id: string + /** Icon of provider. Only support `@unocss/preset-icons` class name for now. */ + icon: string + /** Name of provider. */ + name: string + /** Global settings of the provider. */ + globalSettings?: SettingsUI[] + /** Bots list. Each bot provides a list of presets including conversation types, settings items, etc. */ + bots: Bot[] + /** Whether the Provider can accept frontend or backend calls, or both. */ + supportCallMethod?: 'both' | 'frontend' | 'backend' + // Handle a prompt in conversation + handlePrompt: (payload: HandlerPayload, signal?: AbortSignal) => Promise<PromptResponse> + /** Handle a temporary, rapidly prompt, used for interface display like conversation title's generation. */ + handleRapidPrompt?: (prompt: string, globalSettings: SettingsPayload) => Promise<string> +} + +export interface Bot { + id: string + type: ConversationType + name: string + settings: SettingsUI[] +} + +export type SettingsPayload = Record<string, string | number | boolean> + +export interface HandlerPayload { + conversationId: string + conversationType: ConversationType + botId: string + globalSettings: SettingsPayload + botSettings: SettingsPayload + prompt?: string + messages: Message[] +} + +export type PromptResponse = string | ReadableStream | null | undefined + +interface SettingsUIBase { + key: string + name: string + description?: string + default?: string | number | boolean +} + +export interface SelectOptionType { + value: any + label: string + icon?: string +} + +export interface SettingsApiKey extends SettingsUIBase { + type: 'api-key' +} + +export interface SettingsUIInput extends SettingsUIBase { + type: 'input' +} + +export interface SettingsUISelect extends SettingsUIBase { + type: 'select' + options: SelectOptionType[] +} + +export interface SettingsUISlider extends SettingsUIBase { + type: 'slider' + min: number + max: number + step: number +} + +export interface SettingsUIToggle extends SettingsUIBase { + type: 'toggle' +} + +export type SettingsUI = SettingsApiKey | SettingsUIInput | SettingsUISelect | SettingsUISlider | SettingsUIToggle diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..47c026d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite-plugin-pwa/info"], + "paths": { + "@/*": ["src/*"], + }, + } +} diff --git a/unocss.config.ts b/unocss.config.ts new file mode 100644 index 0000000..892554b --- /dev/null +++ b/unocss.config.ts @@ -0,0 +1,123 @@ +import { + defineConfig, + presetAttributify, + presetIcons, + presetTypography, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno({ + dark: 'class', + }), + presetAttributify(), + presetIcons(), + presetTypography({ + cssExtend: { + '*:first-child': { + 'margin-top': 0, + }, + '*:last-child': { + 'margin-bottom': 0, + }, + 'h1': { + 'font-size': '1.25em', + 'margin': '1rem 0', + }, + 'h2': { + 'font-size': '1.16em', + 'margin': '1rem 0', + }, + 'h3': { + 'font-size': '1.1em', + 'margin': '1rem 0', + }, + 'h4, h5, h6': { + 'font-size': '1em', + 'margin': '1rem 0', + }, + ':not(pre) > code': { + 'font-weight': 400, + 'padding': '0 0.2em', + 'color': 'var(--prism-keyword)', + }, + 'pre': { + 'background-color': 'var(--prism-background) !important', + }, + }, + }), + ], + transformers: [transformerVariantGroup(), transformerDirectives()], + shortcuts: [{ + 'bg-base': 'bg-white dark:bg-[#101010]', + 'bg-base-100': 'bg-light-200/50 dark:bg-[#181818]', + 'bg-base-200': 'bg-light-400 dark:bg-[#202020]', + 'bg-blur': 'bg-light-200/85 dark:bg-[#101010]/85 backdrop-blur-xl backdrop-saturate-150', + 'bg-sidebar': 'bg-white dark:bg-[#101010]', + 'bg-modal': 'bg-white dark:bg-[#181818]', + 'bg-darker': 'bg-black/4 dark:bg-white/4', + 'fg-base': 'text-dark dark:text-[#dadada]', + 'border-base': 'border-light-700 dark:border-[#2a2a2a]', + 'border-b-base': 'border-b-light-700 dark:border-b-[#2a2a2a]', + 'border-base-100': 'border-light-900 dark:border-[#404040]', + 'hv-base': 'transition-colors cursor-pointer hover:bg-darker', + 'hv-foreground': 'transition-opacity cursor-pointer op-70 hover:op-100', + 'input-base': 'bg-transparent placeholder:op-50 dark:placeholder:op-20 focus:(ring-0 outline-none) resize-none', + 'button': 'mt-4 px-3 py-2 text-xs border border-base rounded-lg hv-base hover:border-base-100', + 'emerald-button': 'mt-4 px-3 py-2 text-xs border rounded-lg text-light-400 border-emerald-600 bg-emerald-600 hover-bg-emerald-700 hover-border-emerald-700', + 'max-w-base': 'max-w-3xl mx-auto', + 'text-error': 'text-red-700 dark:text-red-400/80', + 'border-error': 'border border-red-700 dark:border-red-400/80', + 'text-info': 'text-gray-400 dark:text-gray-200', + 'menu-icon': 'cursor-pointer text-base fg-base hover-text-emerald-600', + 'fc': 'flex justify-center', + 'fi': 'flex items-center', + 'fcc': 'fc items-center', + 'fb': 'flex justify-between', + }], + preflights: [{ + layer: 'base', + getCSS: () => ` + :root { + --c-scroll: #d9d9d9; + --c-scroll-hover: #bbbbbb; + --c-shadow: #00000008; + } + + html.dark { + --c-scroll: #333333; + --c-scroll-hover: #555555; + --c-shadow: #ffffff08; + } + + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--c-scroll); + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--c-scroll-hover); + } + + ::selection { + background: rgba(0, 0, 0, 0.12); + } + + .dark ::selection { + background: rgba(255, 255, 255, 0.12); + } + + button,select,input,option { + outline: none; + -webkit-appearance: none + } + `, + }], +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..b08ad30 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "buildCommand": "OUTPUT=vercel astro build", + "github": { + "silent": true + } +} \ No newline at end of file