first commit

This commit is contained in:
2023-06-04 15:55:58 +08:00
commit dea1b6a851
136 changed files with 15516 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
.emoji-section-title {
@apply block text-xs mx-1 my-2 uppercase op-30;
}
.emoji-button {
@apply hv-base;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 26px;
font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols;
border-radius: 4px;
}

62
src/assets/prism.css Normal file
View File

@@ -0,0 +1,62 @@
@import "prism-theme-vars/base.css";
html:not(.dark) {
--prism-foreground: #333333;
--prism-background: #f6f6f6;
--prism-comment: #0000004f;
--prism-string: #377961;
--prism-literal: #6b588e;
--prism-keyword: #c05386;
--prism-function: #668f9a;
--prism-deleted: #cc6262;
--prism-class: #b5855c;
--prism-builtin: #c05386;
--prism-property: #6b588e;
--prism-namespace: #377961;
--prism-punctuation: #0000005f;
--prism-decorator: #668f9a;
--prism-operator: var(--prism-keyword);
--prism-number: #c7792b;
--prism-boolean: #c7792b;
--prism-constant: #c7792b;
--prism-selector: #377961;
--prism-regex: #6b588e;
--prism-json-property: var(--prism-literal);
--prism-line-number: #aaaaaa;
--prism-line-highlight-background: #f2f2f2;
--prism-block-padding-x: 1.25rem;
--prism-block-padding-y: 1.5rem;
--prism-block-radius: .375rem;
}
html.dark {
--prism-scheme: dark;
--prism-foreground: #dddddd;
--prism-background: #222222;
--prism-comment: #ffffff4f;
--prism-string: #74ccaa;
--prism-literal: #a0a5d6;
--prism-keyword: #ed9cc2;
--prism-function: #5fb5be;
--prism-deleted: #ff8787;
--prism-class: #f3a580;
--prism-builtin: #ed9cc2;
--prism-property: #a0a5d6;
--prism-namespace: #74ccaa;
--prism-punctuation: #ffffff5f;
--prism-decorator: #5fb5be;
--prism-operator: var(--prism-keyword);
--prism-number: #f6c177;
--prism-boolean: #f6c177;
--prism-constant: #f6c177;
--prism-selector: #74ccaa;
--prism-regex: #a0a5d6;
--prism-json-property: var(--prism-literal);
--prism-line-number: #666666;
--prism-line-number-gutter: #eeeeee;
--prism-line-highlight-background: #333333;
--prism-selection-background: #444444;
--prism-block-padding-x: 1.25rem;
--prism-block-padding-y: 1.5rem;
--prism-block-radius: .375rem;
}

56
src/assets/transition.css Normal file
View File

@@ -0,0 +1,56 @@
.slide-top-enter-active, .slide-top-exit-active,
.slide-bottom-enter-active, .slide-bottom-exit-active,
.slide-left-enter-active, .slide-left-exit-active,
.slide-right-enter-active, .slide-right-exit-active {
transition: opacity 0.3s, transform 0.36s;
}
.slide-top-enter, .slide-top-exit-to {
@apply -translate-y-20 opacity-0 sm:translate-y-2;
}
.slide-top-enter-to {
@apply translate-y-0;
}
.slide-bottom-enter, .slide-bottom-exit-to {
@apply translate-y-20 opacity-0 sm:translate-y-2;
}
.slide-bottom-enter-to {
@apply translate-y-0;
}
.slide-left-enter, .slide-left-exit-to {
@apply -translate-x-full opacity-0;
}
.slide-left-enter-to {
@apply translate-x-0;
}
.slide-right-enter, .slide-right-exit-to {
@apply translate-x-full opacity-0;
}
.slide-right-enter-to {
@apply translate-x-0;
}
.loading-anim::before {
content: ' ';
background-image: linear-gradient(90deg, #ffffff00 0%, var(--c-shadow) 35%, var(--c-shadow) 65%, #ffffff00 100%);
position: absolute;
top: 0;
bottom: 0;
/* height: 1px; */
width: 60%;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-name: progress-bar-loop;
}
@keyframes progress-bar-loop {
from {
left: -60%;
}
to {
left: 110%;
}
}

View File

@@ -0,0 +1,115 @@
/* -----------------------------------------------------------------------------
* Slider
* -----------------------------------------------------------------------------*/
[data-scope='slider'][data-part='root'] {
@apply w-full flex flex-col
}
[data-scope='slider'][data-part='root'][data-orientation='vertical'] {
@apply h-60
}
[data-scope='slider'][data-part='control'] {
--slider-thumb-size: 14px;
--slider-track-height: 4px;
@apply relative fcc cursor-pointer
}
[data-scope='slider'][data-part='control'][data-orientation='horizontal'] {
@apply h-[var(--slider-thumb-size)];
}
[data-scope='slider'][data-part='control'][data-orientation='vertical'] {
@apply w-[var(--slider-thumb-size)];
}
[data-scope='slider'][data-part='thumb'] {
all: unset;
@apply bg-gray-200 dark:bg-gray-500 w-[var(--slider-thumb-size)] h-[var(--slider-thumb-size)] rounded-full b-#c5c5d2 b-2
}
[data-scope='slider'][data-part='thumb'][data-disabled] {
@apply w-0
}
[data-scope='slider'] .control-area {
@apply flex mt-12px
}
.slider [data-orientation='horizontal'] .control-area {
flex-direction: column;
width: 100%;
}
.slider [data-orientation='vertical'] .control-area {
flex-direction: row;
height: 100%;
}
[data-scope='slider'][data-part='track'] {
@apply rounded-full bg-gray-200 dark:bg-neutral-700
}
[data-scope='slider'][data-part='track'][data-orientation='horizontal'] {
@apply h-[var(--slider-track-height)] w-full;
}
[data-scope='slider'][data-part='track'][data-orientation='vertical'] {
@apply h-full w-[var(--slider-track-height)];
}
[data-scope='slider'][data-part='range'] {
@apply bg-neutral-300 dark:bg-gray-700
}
[data-scope='slider'][data-part='range'][data-disabled] {
@apply bg-neutral-300 dark:bg-gray-600
}
[data-scope='slider'][data-part='range'][data-orientation='horizontal'] {
@apply h-full;
}
[data-scope='slider'][data-part='range'][data-orientation='vertical'] {
@apply w-full;
}
[data-scope='slider'][data-part='output'] {
margin-inline-start: 12px;
}
[data-scope='slider'][data-part='marker'] {
color: lightgray;
}
/* -----------------------------------------------------------------------------
* Select
* -----------------------------------------------------------------------------*/
[data-scope='select'][data-part='content'] {
@apply border border-base-100
}
[data-scope='select'][data-part='trigger'][data-expanded] {
@apply border border-base-100
}
/* -----------------------------------------------------------------------------
* Switch
* -----------------------------------------------------------------------------*/
[data-scope='switch'][data-part='root'] {
@apply relative mt-1 inline-block cursor-pointer
}
[data-scope='switch'][data-part='control'] {
@apply relative w-10 h-6 rounded-full shadow-inner bg-gray-400 transition-colors
}
[data-scope='switch'][data-part='control'][data-checked] {
@apply bg-emerald-600
}
[data-scope='switch'][data-part='control'][data-focus] {
/* box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6); */
/* @apply shadow-lg shadow-cyan-500/50; */
@apply ring-1 ring-black/50 dark:ring-white/50
}
[data-scope='switch'][data-part='thumb'] {
@apply absolute inset-y-0 left-0 w-4 h-4 m-1 rounded-full bg-light dark:bg-dark transition-transform
}
[data-scope='switch'][data-part='thumb'][data-checked] {
@apply translate-x-full
}

18
src/components/Main.astro Normal file
View File

@@ -0,0 +1,18 @@
---
import Header from './header/Header'
import Send from './Send'
import '@/assets/prism.css'
import '@/assets/transition.css'
import Conversation from './main/Conversation'
---
<main class="relative h-full flex-1 flex flex-col overflow-hidden bg-base">
<Header client:only />
<main class="flex-1 mt-14 flex flex-col overflow-hidden">
<div class="flex-1 relative overflow-hidden">
<Conversation client:only />
</div>
</main>
<Send client:load />
</main>

View File

@@ -0,0 +1,41 @@
import { Show } from 'solid-js'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeStringify from 'rehype-stringify'
import rehypePrism from '@mapbox/rehype-prism'
import 'katex/dist/katex.min.css'
interface Props {
class?: string
text: string
showRawCode?: boolean
}
const parseMarkdown = (raw: string) => {
const file = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypePrism, {
ignoreMissing: true,
})
.use(rehypeKatex)
.use(rehypeStringify)
.processSync(raw)
return String(file)
}
export default (props: Props) => {
const htmlString = () => props.showRawCode ? props.text : parseMarkdown(props.text)
return (
<Show when={props.showRawCode} fallback={<div class={props.class ?? ''} innerHTML={htmlString()} />}>
<div class={`${props.class ?? ''} whitespace-pre-wrap overflow-auto my-0`} innerText={htmlString()} />
</Show>
)
}

View File

@@ -0,0 +1,38 @@
import {
showConversationEditModal,
showConversationSidebar,
showEmojiPickerModal,
showSettingsSidebar,
} from '@/stores/ui'
import ConversationSidebar from './conversations/ConversationSidebar'
import SettingsSidebar from './settings/SettingsSidebar'
import ConversationEditModal from './conversations/ConversationEditModal'
import EmojiPickerModal from './ui/EmojiPickerModal'
import Modal from './ui/Modal'
export default () => {
return (
<>
<Modal bindValue={showConversationSidebar} direction="left" closeBtnClass="hidden">
<div class="w-[70vw] max-w-[300px] h-full">
<ConversationSidebar />
</div>
</Modal>
<Modal bindValue={showSettingsSidebar} direction="right">
<div class="w-screen sm:w-[70vw] sm:max-w-[300px] h-full">
<SettingsSidebar />
</div>
</Modal>
<Modal bindValue={showConversationEditModal} direction="bottom" closeBtnClass="top-6 right-6">
<div class="max-h-[70vh] w-full">
<ConversationEditModal />
</div>
</Modal>
<Modal bindValue={showEmojiPickerModal} direction="bottom" closeBtnClass="top-6 right-6">
<div class="max-h-[70vh] w-full">
<EmojiPickerModal />
</div>
</Modal>
</>
)
}

219
src/components/Send.tsx Normal file
View File

@@ -0,0 +1,219 @@
import { Match, Switch, createSignal, onMount } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { createShortcut } from '@solid-primitives/keyboard'
import { currentErrorMessage, isSendBoxFocus, scrollController } from '@/stores/ui'
import { addConversation, conversationMap, currentConversationId } from '@/stores/conversation'
import { loadingStateMap, streamsMap } from '@/stores/streams'
import { handlePrompt } from '@/logics/conversation'
import { globalAbortController } from '@/stores/settings'
import { useI18n, useMobileScreen } from '@/hooks'
import Button from './ui/Button'
import { fetchData } from '../http/api'
export default () => {
const { t } = useI18n()
let inputRef: HTMLTextAreaElement
const $conversationMap = useStore(conversationMap)
const $currentConversationId = useStore(currentConversationId)
const $isSendBoxFocus = useStore(isSendBoxFocus)
const $currentErrorMessage = useStore(currentErrorMessage)
const $streamsMap = useStore(streamsMap)
const $loadingStateMap = useStore(loadingStateMap)
const $globalAbortController = useStore(globalAbortController)
const [inputPrompt, setInputPrompt] = createSignal('')
const [footerClass, setFooterClass] = createSignal('')
const isEditing = () => inputPrompt() || $isSendBoxFocus()
const currentConversation = () => {
return $conversationMap()[$currentConversationId()]
}
const isStreaming = () => !!$streamsMap()[$currentConversationId()]
const isLoading = () => !!$loadingStateMap()[$currentConversationId()]
onMount(() => {
fetchData({}, function(data) {
if(data.code==201 || data.code==401){
currentErrorMessage.set(data)
return ;
}
// 登录后的一些操作
}, '/chatgptApi', 'POST');
createShortcut(['Control', 'Enter'], () => {
$isSendBoxFocus() && handleSend()
})
useMobileScreen(() => {
setFooterClass('sticky bottom-0 left-0 right-0 overflow-hidden')
})
})
const stateType = () => {
if ($currentErrorMessage())
return $currentErrorMessage().code==401 ? 'login' : 'error'
else if (isLoading() || isStreaming())
return 'loading'
else if (isEditing())
return 'editing'
else
return 'normal'
}
const EmptyState = () => (
<div
class="max-w-base h-full fi flex-row gap-2"
onClick={() => {
isSendBoxFocus.set(true)
inputRef.focus()
}}
>
<div class="flex-1 op-30 text-sm">{t('send.placeholder')}</div>
</div>
)
const EditState = () => (
<div class="h-full flex flex-col">
<div class="flex-1 relative">
<textarea
ref={inputRef!}
placeholder={t('send.placeholder')}
autocomplete="off"
onBlur={() => { isSendBoxFocus.set(false) }}
onInput={() => { setInputPrompt(inputRef.value) }}
onKeyDown={(e) => {
e.key === 'Enter' && !e.isComposing && !e.shiftKey && handleSend()
}}
class="h-full w-full absolute inset-0 py-4 px-[calc(max(1.5rem,(100%-48rem)/2))] scroll-pa-4 input-base text-sm"
/>
</div>
<div class="fi justify-between gap-2 h-14 px-[calc(max(1.5rem,(100%-48rem)/2)-0.5rem)] border-t border-base">
<div>
{/*<Button
icon="i-carbon-plug"
onClick={() => {}}
/>*/}
</div>
<Button
icon="i-carbon-send"
onClick={handleSend}
variant={inputPrompt() ? 'primary' : 'normal'}
prefix={t('send.button')}
/>
</div>
</div>
)
const LoginState = () => (
<div class="max-w-base h-full flex items-end flex-col justify-between gap-8 sm:(flex-row items-center) py-4 text-error text-sm" style="padding: 1rem;">
<div class="flex-1 w-full">
<div class="fi gap-0.5 mb-1">
<span i-carbon-warning />
<span class="font-semibold">{$currentErrorMessage()?.code}</span>
</div>
<div>{$currentErrorMessage()?.message}</div>
</div>
<div class="border border-error px-2 py-1 rounded-md hv-base hover:bg-white" onClick={() => { window.location.href = $currentErrorMessage()?.url }} >
</div>
</div>
)
const ErrorState = () => (
<div class="max-w-base h-full flex items-end flex-col justify-between gap-8 sm:(flex-row items-center) py-4 text-error text-sm" style="padding: 1rem;">
<div class="flex-1 w-full">
<div class="fi gap-0.5 mb-1">
<span i-carbon-warning />
<span class="font-semibold">{$currentErrorMessage()?.code}</span>
</div>
<div>{$currentErrorMessage()?.message}</div>
</div>
<div class="border border-error px-2 py-1 rounded-md hv-base hover:bg-white" onClick={() => { currentErrorMessage.set(null) }} >
Dismiss
</div>
</div>
)
const clearPrompt = () => {
setInputPrompt('')
isSendBoxFocus.set(false)
}
const handleAbortFetch = () => {
$globalAbortController()?.abort()
clearPrompt()
}
const LoadingState = () => (
<div class="max-w-base h-full fi flex-row gap-2">
<div class="flex-1 op-50">Thinking...</div>
<div
class="border border-base-100 px-2 py-1 rounded-md text-sm op-40 hv-base hover:bg-white"
onClick={() => { handleAbortFetch() }}
>
Abort
</div>
</div>
)
const handleSend = () => {
if (!inputRef.value)
return
if (!currentConversation())
addConversation()
const controller = new AbortController()
globalAbortController.set(controller)
handlePrompt(currentConversation(), inputRef.value, controller.signal)
clearPrompt()
scrollController().scrollToBottom()
}
const stateRootClass = () => {
if (stateType() === 'normal')
return 'hv-base'
else if (stateType() === 'error')
return 'bg-red/8'
else if (stateType() === 'loading')
return 'loading-anim bg-base-100'
else if (stateType() === 'editing')
return 'bg-base-100'
return ''
}
const stateHeightClass = () => {
if (stateType() === 'normal')
return 'px-6 h-14'
else if (stateType() === 'error')
return 'px-6'
else if (stateType() === 'loading')
return 'px-6 h-14'
else if (stateType() === 'editing')
return 'h-54'
return ''
}
return (
<div class={`relative shrink-0 border-t border-base pb-[env(safe-area-inset-bottom)] transition transition-colors duration-300 ${stateRootClass()} ${footerClass()}`}>
<div class={`relative transition transition-height duration-240 ${stateHeightClass()}`}>
<Switch fallback={<EmptyState />}>
<Match when={stateType() === 'login'}>
<LoginState />
</Match>
<Match when={stateType() === 'error'}>
<ErrorState />
</Match>
<Match when={stateType() === 'loading'}>
<LoadingState />
</Match>
<Match when={stateType() === 'editing'}>
<EditState />
</Match>
</Switch>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
---
import Conversation from '@/components/share/Conversation'
---
<main class="relative h-full flex-1 flex flex-col overflow-hidden bg-base">
<main class="flex-1 flex flex-col overflow-hidden">
<div class="flex-1 relative overflow-hidden">
<Conversation client:only />
</div>
</main>
</main>

View File

@@ -0,0 +1,54 @@
import { createEffect, createSignal, on } from 'solid-js'
import { convertReadableStreamToAccessor } from '@/logics/stream'
import { updateMessage } from '@/stores/messages'
import { deleteStreamById, getStreamByConversationId } from '@/stores/streams'
import Markdown from './Markdown'
interface Props {
class?: string
text: string
showRawCode?: boolean
streamInfo?: () => {
conversationId: string
messageId: string
handleStreaming?: () => void
}
}
export default (props: Props) => {
const [localText, setLocalText] = createSignal('')
createEffect(on(localText, () => {
if (props.streamInfo && props.streamInfo()?.handleStreaming)
props.streamInfo().handleStreaming!()
}, { defer: true }))
createEffect(async() => {
const text = props.text
if (props.text) {
setLocalText(text)
} else if (props.streamInfo) {
const streamInfo = props.streamInfo()
const streamInstance = getStreamByConversationId(streamInfo.conversationId)
if (streamInfo.messageId && streamInstance?.messageId === streamInfo.messageId) {
const finalText = await convertReadableStreamToAccessor(streamInstance.stream, setLocalText)
setLocalText(finalText)
updateMessage(streamInfo.conversationId, streamInfo.messageId, {
content: finalText,
stream: false,
})
}
deleteStreamById(streamInfo.conversationId)
} else {
setLocalText('')
}
})
return (
<Markdown
class={`prose prose-neutral dark:prose-invert fg-base! max-w-3xl ${props.class ?? ''}`}
text={localText()}
showRawCode={props.showRawCode}
/>
)
}

View File

@@ -0,0 +1,11 @@
import { createStores, rebuildStores } from '@/stores/storage/db'
const buildStores = async() => {
await createStores()
await rebuildStores()
}
export default () => {
buildStores()
return null
}

View File

@@ -0,0 +1,84 @@
import { Show, createSignal, onMount } from 'solid-js'
import { useStore } from '@nanostores/solid'
import BotSelect from '@/components/ui/BotSelect'
import { getBotMetaById } from '@/stores/provider'
import { emojiPickerCurrentPick, showEmojiPickerModal } from '@/stores/ui'
import { useI18n } from '@/hooks'
import type { Conversation } from '@/types/conversation'
interface Props {
conversation: Conversation
handleChange: (payload: Partial<Conversation>) => void
}
export default (props: Props) => {
const { t } = useI18n()
const [providerBot, setProviderBot] = createSignal(props.conversation.bot || '')
const $emojiPickerCurrentPick = useStore(emojiPickerCurrentPick)
const botMeta = () => getBotMetaById(providerBot()) || null
onMount(() => {
emojiPickerCurrentPick.set(undefined)
})
const handleProviderBotChange = (e: string) => {
setProviderBot(e)
const payload: Partial<Conversation> = { bot: e }
if (botMeta()?.type === 'image_generation') {
payload.systemInfo = undefined
payload.mockMessages = undefined
}
props.handleChange(payload)
}
const handleOpenIconSelector = () => {
showEmojiPickerModal.set(true)
emojiPickerCurrentPick.listen((emoji) => {
props.handleChange({ icon: emoji })
})
}
const handleOpenMockMessages = () => {
// TODO
}
return (
<div class="flex flex-col gap-4">
<div
class="fcc w-16 h-16 text-10 border border-base rounded-xl border-dashed hv-base"
onClick={handleOpenIconSelector}
>
{$emojiPickerCurrentPick() || props.conversation.icon}
</div>
<input
type="text"
class="font-semibold mr-12 mb-3 px-1 truncate outline-0 bg-transparent placeholder:op-40"
placeholder={t('conversations.untitled')}
value={props.conversation.name}
onBlur={e => props.handleChange({ name: e.currentTarget.value })}
/>
<BotSelect value={props.conversation.bot} onChange={handleProviderBotChange} />
<Show when={botMeta()?.type !== 'image_generation'}>
<div class="py-1 border border-base rounded-lg text-sm">
<div class="px-4 py-2">
<h3 class="op-80 shrink-0">System Info</h3>
<textarea
value={props.conversation.systemInfo || ''}
rows="4"
class="input-base mt-2 w-full"
placeholder="You are a helpful assistant, answer as concisely as possible..."
onBlur={e => props.handleChange({ systemInfo: e.currentTarget.value })}
/>
</div>
{/* <div class="fi justify-between gap-10 pl-4 pr-2 h-10">
<h3 class="op-80 shrink-0">Mock Messages</h3>
<div class="flex-1 fi justify-end overflow-hidden px-2 py-1 cursor-pointer" onClick={handleOpenMockMessages}>
<p class="text-xs op-50 truncate">2 messages</p>
<div i-carbon-chevron-right class="shrink-0" />
</div>
</div> */}
</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useStore } from '@nanostores/solid'
import { useI18n } from '@/hooks'
import { currentConversation, updateConversationById } from '@/stores/conversation'
import { showConversationEditModal } from '@/stores/ui'
import ConversationEdit from './ConversationEdit'
import type { Conversation } from '@/types/conversation'
export default () => {
const { t } = useI18n()
const $currentConversation = useStore(currentConversation)
let modifiedConversationPayload: Partial<Conversation> = {}
const handleButtonClick = () => {
if (Object.keys(modifiedConversationPayload).length)
updateConversationById($currentConversation()!.id, modifiedConversationPayload)
showConversationEditModal.set(false)
}
const handleChange = (payload: Partial<Conversation>) => {
modifiedConversationPayload = {
...modifiedConversationPayload,
...payload,
}
}
return (
<div class="p-6">
<main class="flex flex-col gap-3 mt-3">
<ConversationEdit
conversation={$currentConversation()!}
handleChange={handleChange}
/>
</main>
<div class="fcc px-2 py-2 bg-darker border border-base mt-4 hv-base hover:border-base-100" onClick={handleButtonClick}>{t('settings.save')}</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { For } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { useI18n } from '@/hooks'
import { conversationMapSortList } from '@/stores/conversation'
import ConversationSidebarItem from './ConversationSidebarItem'
import ConversationSidebarAdd from './ConversationSidebarAdd'
export default () => {
const { t } = useI18n()
const $conversationMapSortList = useStore(conversationMapSortList)
return (
<div class="h-full flex flex-col bg-sidebar">
<header class="h-14 fi justify-between px-4 text-xs uppercase">
<p class="px-2">{t('conversations.title')}</p>
<div class="fi gap-1">
{/* <Button
icon="i-carbon-search"
onClick={() => {}}
size="sm"
/> */}
<ConversationSidebarAdd />
</div>
</header>
<div class="flex-1 overflow-auto">
<div class="px-2">
<For each={$conversationMapSortList()}>
{instance => (
<ConversationSidebarItem instance={instance} />
)}
</For>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { useI18n } from '@/hooks'
import { addConversation } from '@/stores/conversation'
import Button from '../ui/Button'
export default () => {
const { t } = useI18n()
const handleAdd = () => {
addConversation()
}
return (
<Button
icon="i-carbon-add"
onClick={handleAdd}
size="sm"
>
{t('conversations.add')}
</Button>
)
}

View File

@@ -0,0 +1,50 @@
import { useStore } from '@nanostores/solid'
import { currentConversationId, deleteConversationById } from '@/stores/conversation'
import { showConversationSidebar } from '@/stores/ui'
import { useI18n } from '@/hooks'
import type { Conversation } from '@/types/conversation'
interface Props {
instance: Omit<Conversation, 'messages'> & {
current?: boolean
}
}
export default ({ instance }: Props) => {
const { t } = useI18n()
const $currentConversationId = useStore(currentConversationId)
const isTouchDevice = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
const handleClick = () => {
currentConversationId.set(instance.id)
showConversationSidebar.set(false)
}
const handleDelete = (e: MouseEvent, conversationId: string) => {
e.stopPropagation()
currentConversationId.set('')
deleteConversationById(conversationId)
}
return (
<div
class={[
'group fi h-10 my-0.5 px-2 gap-2 hv-base rounded-md',
instance.id === $currentConversationId() ? 'bg-base-200' : '',
].join(' ')}
onClick={handleClick}
>
<div class="fcc w-8 h-8 rounded-full text-xl shrink-0">
{instance.icon ? instance.icon : <div class="text-base i-carbon-chat" />}
</div>
<div class="flex-1 truncate text-sm">{ instance.name || t('conversations.untitled') }</div>
<div class={isTouchDevice ? '' : 'hidden group-hover:block'}>
<div
class="inline-flex p-2 items-center gap-1 rounded-md hv-base"
onClick={e => handleDelete(e, instance.id)}
>
<div class="i-carbon-close" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Show } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { conversationMap, currentConversationId } from '@/stores/conversation'
import { useI18n } from '@/hooks'
export default () => {
const { t } = useI18n()
const $conversationMap = useStore(conversationMap)
const $currentConversationId = useStore(currentConversationId)
const currentConversation = () => {
return $conversationMap()[$currentConversationId()]
}
return (
<div class="fi gap-1 max-w-40vw px-2 overflow-hidden text-sm">
<Show when={currentConversation()}>
<Show when={currentConversation().icon}>
<div class="fcc -ml-2 w-8 h-8 rounded-full text-xl shrink-0 hidden md:flex">{currentConversation().icon}</div>
</Show>
<div class="truncate">
{currentConversation() ? (currentConversation().name || t('conversations.untitled')) : ''}
</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { useStore } from '@nanostores/solid'
import { currentConversationId, conversationMap } from '@/stores/conversation'
import type { Conversation } from '@/types/conversation'
import { fetchData } from '../../http/api'
export default () => {
const $currentConversationId = useStore(currentConversationId)
const $conversationMap = useStore(conversationMap)
const currentConversation = () => {
return $conversationMap()[$currentConversationId()]
}
const handleShareMessage = async (conversation: Conversation) => {
var conversation = currentConversation();
fetchData({id:conversation.id, title: conversation.name }, function(data) {
if(data.code==200){
// window.location.href = data.url;
window.open(data.url)
}else{
alert(data.message);
}
}, '/chatgptApi/createShare', 'POST');
}
return (
<>
{ $currentConversationId() && (
<div class="fcc p-2 rounded-md text-xl hv-foreground" onClick={() => { handleShareMessage(true) }} >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,34 @@
import { useStore } from '@nanostores/solid'
import { currentConversationId } from '@/stores/conversation'
import {
scrollController,
showConfirmModal,
} from '@/stores/ui'
import { clearMessagesByConversationId } from '@/stores/messages'
import { useI18n } from '@/hooks'
import ConfirmModal from '../ui/ConfirmModal'
export default () => {
const $currentConversationId = useStore(currentConversationId)
const { t } = useI18n()
const handleClearMessage = () => {
clearMessagesByConversationId($currentConversationId())
scrollController().scrollToBottom()
showConfirmModal.set(false)
}
return (
<>
{$currentConversationId() && (
<div
class="fcc p-2 rounded-md text-xl hv-foreground"
onClick={() => { showConfirmModal.set(true) }}
>
<div i-carbon-clean />
</div>
)}
<ConfirmModal title={t('conversations.confirm.title')} description={t('conversations.confirm.desc')} onConfirm={handleClearMessage} onCancel={() => { showConfirmModal.set(false) }} />
</>
)
}

View File

@@ -0,0 +1,39 @@
import { onMount } from 'solid-js'
import { scrollController, showConversationSidebar, showSettingsSidebar } from '@/stores/ui'
import { useLargeScreen } from '@/hooks'
import ConversationHeaderInfo from './ConversationHeaderInfo'
import ConversationMessageClearButton from './ConversationMessageClearButton'
import ConversationHeaderShare from './ConversationHeaderShare'
export default () => {
onMount(() => {
useLargeScreen(() => {
// bug: when click the setting btn, toggle moible or PC mode, the sidebar will not close
showConversationSidebar.set(false)
showSettingsSidebar.set(false)
})
})
return (
<header onDblClick={scrollController().scrollToTop} class="shrink-0 absolute top-0 left-0 right-0 fi justify-between border-b border-base h-14 px-4">
<div class="fi overflow-hidden">
<div
class="fcc p-2 rounded-md text-xl hv-foreground md:hidden"
onClick={() => showConversationSidebar.set(true)}
>
<div i-carbon-menu />
</div>
<ConversationHeaderInfo />
</div>
<div class="fi gap-1 overflow-hidden">
<ConversationHeaderShare />
<ConversationMessageClearButton />
<div
class="fcc p-2 rounded-md text-xl hv-foreground lg:hidden"
onClick={() => showSettingsSidebar.set(true)}
>
<div i-carbon-settings />
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,67 @@
import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { createScrollPosition } from '@solid-primitives/scroll'
import { leading, throttle } from '@solid-primitives/scheduled'
import { isSendBoxFocus } from '@/stores/ui'
import MessageItem from './MessageItem'
import type { Accessor } from 'solid-js'
import type { MessageInstance } from '@/types/message'
interface Props {
conversationId: string
messages: Accessor<MessageInstance[]>
}
export default (props: Props) => {
let scrollRef: HTMLDivElement
const $isSendBoxFocus = useStore(isSendBoxFocus)
const [isScrollBottom, setIsScrollBottom] = createSignal(false)
const scroll = createScrollPosition(() => scrollRef)
createEffect(() => {
setIsScrollBottom(scroll.y + scrollRef.clientHeight >= scrollRef.scrollHeight - 100)
})
createEffect(on(() => props.conversationId, () => {
setTimeout(() => {
instantScrollToBottomThrottle(scrollRef)
}, 0)
}))
const instantScrollToBottomThrottle = leading(throttle, (element: HTMLDivElement) => {
isScrollBottom() && element.scrollTo({ top: element.scrollHeight })
}, 250)
const handleStreamableTextUpdate = () => {
instantScrollToBottomThrottle(scrollRef)
}
return (
<>
<div class="scroll-list relative flex flex-col h-full overflow-y-scroll" ref={scrollRef!}>
<For each={props.messages()}>
{(message, index) => (
<div class="border-b border-base">
<MessageItem
conversationId={props.conversationId}
message={message}
handleStreaming={handleStreamableTextUpdate}
index={index()}
/>
</div>
)}
</For>
</div>
<Show when={!isScrollBottom() && !$isSendBoxFocus()}>
<div
class="absolute bottom-0 left-0 right-0 border-t border-base bg-blur hv-base"
onClick={() => scrollRef!.scrollTo({ top: scrollRef.scrollHeight, behavior: 'smooth' })}
>
<div class="fcc h-8 max-w-base text-xs op-50 gap-1">
<div>Scroll to bottom</div>
<div i-carbon-arrow-down />
</div>
</div>
</Show>
</>
)
}

View File

@@ -0,0 +1,73 @@
import { Match, Switch, createEffect,onMount } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { conversationMap, currentConversationId } from '@/stores/conversation'
import { conversationMessagesMap } from '@/stores/messages'
import { loadingStateMap, streamsMap } from '@/stores/streams'
import { getBotMetaById } from '@/stores/provider'
import { useI18n } from '@/hooks'
import ConversationEmpty from './ConversationEmpty'
import Welcome from './Welcome'
import Continuous from './Continuous'
import Single from './Single'
import Image from './Image'
export default () => {
const { t } = useI18n()
const $conversationMap = useStore(conversationMap)
const $conversationMessagesMap = useStore(conversationMessagesMap)
const $currentConversationId = useStore(currentConversationId)
const $streamsMap = useStore(streamsMap)
const $loadingStateMap = useStore(loadingStateMap)
const currentConversation = () => {
return $conversationMap()[$currentConversationId()]
}
const currentBot = () => {
return getBotMetaById(currentConversation()?.bot)
}
const currentConversationMessages = () => {
return $conversationMessagesMap()[$currentConversationId()] || []
}
// const isStreaming = () => !!$streamsMap()[$currentConversationId()]
// const isLoading = () => !!$loadingStateMap()[$currentConversationId()]
createEffect(() => {
const conversation = currentConversation()
document.title = conversation ? `${(conversation.name || t('conversations.untitled'))} - Ansnid` : 'Ansnid'
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement
if (link) {
const conversationIcon = conversation?.icon ? `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${conversation.icon}</text></svg>` : null
link.setAttribute('href', conversationIcon || '/logo.svg')
}
})
return (
<Switch
fallback={(
<Welcome />
)}
>
<Match when={$currentConversationId() && !currentConversationMessages().length}>
<ConversationEmpty conversation={currentConversation()} />
</Match>
<Match when={currentBot()?.type === 'chat_continuous'}>
<Continuous
conversationId={$currentConversationId()}
messages={currentConversationMessages}
/>
</Match>
<Match when={currentBot()?.type === 'chat_single'}>
<Single
conversationId={$currentConversationId()}
messages={currentConversationMessages}
/>
</Match>
<Match when={currentBot()?.type === 'image_generation'}>
<Image
// conversationId={$currentConversationId()}
messages={currentConversationMessages}
// fetching={isLoading() || !isStreaming()}
/>
</Match>
</Switch>
)
}

View File

@@ -0,0 +1,29 @@
import { showConversationEditModal } from '@/stores/ui'
import { getBotMetaById } from '@/stores/provider'
import Button from '../ui/Button'
import type { Conversation } from '@/types/conversation'
interface Props {
conversation: Conversation
}
export default (props: Props) => {
const botMeta = () => getBotMetaById(props.conversation.bot) || null
return (
<div class="fi flex-col h-full px-6 py-8 overflow-auto">
<Button
icon="i-carbon-settings-adjust text-sm"
onClick={() => showConversationEditModal.set(true)}
size="sm"
variant="ghost"
>
<div class="inline-flex items-center gap-1">
{botMeta().provider.name} / {botMeta().label}
{props.conversation.systemInfo && (
<div class="text-xs px-1 border border-base-100 rounded-md op-40">System Info</div>
)}
</div>
</Button>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Show } from 'solid-js'
import StreamableText from '../StreamableText'
import type { Accessor } from 'solid-js'
import type { MessageInstance } from '@/types/message'
interface Props {
// conversationId: string
messages: Accessor<MessageInstance[]>
// fetching: boolean
}
export default (props: Props) => {
const messageInput = () => props.messages().length > 0 ? props.messages()[0] : null
const messageOutput = () => props.messages().length > 1 ? props.messages()[1] : null
return (
<div class="flex flex-col h-full">
<div class="min-h-16 max-h-40 fi px-6 py-4 border-b border-base break-words overflow-y-scroll">
<StreamableText
class="w-full"
text={messageInput()?.content || ''}
/>
</div>
<div class="flex-1 fcc overflow-y-auto px-6">
<Show when={messageOutput()?.content}>
<img
class="w-full max-w-[400px] aspect-1"
src={messageOutput()?.content}
alt={messageInput()?.content || ''}
onError={e => e.currentTarget.classList.add('hidden')}
/>
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { For, Show } from 'solid-js/web'
import { createSignal } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { useClipboardCopy } from '@/hooks'
import { deleteMessageByConversationId, spliceMessageByConversationId, spliceUpdateMessageByConversationId } from '@/stores/messages'
import { conversationMap } from '@/stores/conversation'
import { handlePrompt } from '@/logics/conversation'
import { scrollController } from '@/stores/ui'
import { globalAbortController } from '@/stores/settings'
import StreamableText from '../StreamableText'
import { DropDownMenu, Tooltip } from '../ui/base'
import Button from '../ui/Button'
import type { MenuItem } from '../ui/base'
import type { MessageInstance } from '@/types/message'
interface Props {
conversationId: string
message: MessageInstance
index: number
handleStreaming?: () => void
}
export default (props: Props) => {
let inputRef: HTMLTextAreaElement
const $conversationMap = useStore(conversationMap)
const [showRawCode, setShowRawCode] = createSignal(false)
const [copied, setCopied] = createSignal(false)
const [isEditing, setIsEditing] = createSignal(false)
const [inputPrompt, setInputPrompt] = createSignal(props.message.content)
const currentConversation = () => {
return $conversationMap()[props.conversationId]
}
const handleCopyMessageItem = () => {
const [Iscopied, copy] = useClipboardCopy(props.message.content)
copy()
setCopied(Iscopied())
setTimeout(() => setCopied(false), 1000)
}
const handleDeleteMessageItem = () => {
deleteMessageByConversationId(props.conversationId, props.message)
}
const handleRetryMessageItem = () => {
const controller = new AbortController()
globalAbortController.set(controller)
spliceMessageByConversationId(props.conversationId, props.message)
handlePrompt(currentConversation(), '', controller.signal)
// TODO: scrollController seems not working
scrollController().scrollToBottom()
}
const handleEditMessageItem = () => {
setIsEditing(true)
inputRef.focus()
}
const handleSend = () => {
if (!inputRef.value)
return
const controller = new AbortController()
const currentMessage: MessageInstance = {
...props.message,
content: inputPrompt(),
}
globalAbortController.set(controller)
spliceUpdateMessageByConversationId(props.conversationId, currentMessage)
setIsEditing(false)
handlePrompt(currentConversation(), '', controller.signal)
scrollController().scrollToBottom()
}
const [menuList, setMenuList] = createSignal<MenuItem[]>([
{ id: 'copy', label: 'Copy message', icon: 'i-carbon-copy', role: 'all', action: handleCopyMessageItem },
{ id: 'retry', label: 'Retry send', icon: 'i-carbon:restart', role: 'all', action: handleRetryMessageItem },
// TODO: Share message
// { id: 'share', label: 'Share message', icon: 'i-carbon:share' },
{ id: 'edit', label: 'Edit message', icon: 'i-carbon:edit', role: 'user', action: handleEditMessageItem },
{ id: 'delete', label: 'Delete message', icon: 'i-carbon-trash-can', role: 'all', action: handleDeleteMessageItem },
{ id: 'raw', label: 'Show raw code', icon: 'i-carbon-code', role: 'system', action: () => setShowRawCode(!showRawCode()) },
])
if (props.message.role === 'user')
setMenuList(menuList().filter(item => ['all', 'user'].includes(item.role!)))
else
setMenuList(menuList().filter(item => ['all', 'system'].includes(item.role!)))
const roleClass = {
system: 'bg-gradient-to-b from-gray-300 via-gray-200 to-gray-300',
user: 'bg-gradient-to-b from-gray-300 via-gray-200 to-gray-300',
assistant: 'bg-gradient-to-b from-[#fccb90] to-[#d57eeb]',
}
return (
<div
class="p-6 break-words group relative"
classList={{
'bg-base-100': props.message.role === 'user',
}}
>
<div class="max-w-base flex gap-4 overflow-hidden">
<div class={`shrink-0 w-7 h-7 rounded-md op-80 ${roleClass[props.message.role]}`} />
<div id="menuList-wrapper" class={`sm:hidden block absolute bottom-2 right-4 z-10 op-70 cursor-pointer ${isEditing() && '!hidden'}`}>
<DropDownMenu menuList={menuList()}>
<div class="text-xl i-carbon:overflow-menu-horizontal" />
</DropDownMenu>
</div>
<div class={`hidden sm:block absolute right-6 -top-4 ${!props.index && 'top-0'} ${isEditing() && '!hidden'}`}>
<div class="op-0 group-hover:op-80 fcc space-x-2 !bg-base px-2 py-1 rounded-md border border-base transition-opacity">
<For each={menuList()}>
{item => (
<Tooltip tip={item.label} id={item.id} handleChildClick={item.action}>
{
item.id === 'copy'
? <div class={`menu-icon ${copied() ? 'i-carbon-checkmark !text-emerald-400' : 'i-carbon-copy'}`} />
: <div class={`${item.icon} menu-icon`} />
}
</Tooltip>)}
</For>
</div>
</div>
<div class="flex-1 min-w-0">
<Show when={isEditing()} >
<textarea
ref={inputRef!}
value={inputPrompt()}
autocomplete="off"
onInput={() => { setInputPrompt(inputRef.value) }}
onKeyDown={(e) => {
e.key === 'Enter' && !e.isComposing && !e.shiftKey && handleSend()
}}
class="op-70 bg-darker py-4 px-[calc(max(1.5rem,(100%-48rem)/2))] w-full inset-0 scroll-pa-4 input-base rounded-md"
/>
<div class="flex justify-end space-x-2 mt-1">
<Button size="sm" onClick={() => setIsEditing(false)}>Cancel</Button>
<Button size="sm" onClick={() => handleSend()}>Submit</Button>
</div>
</Show>
<Show when={!isEditing()}>
<StreamableText
text={props.message.content}
streamInfo={props.message.stream
? () => ({
conversationId: props.conversationId,
messageId: props.message.id || '',
handleStreaming: props.handleStreaming,
})
: undefined}
showRawCode={showRawCode()}
/>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { leading, throttle } from '@solid-primitives/scheduled'
import StreamableText from '../StreamableText'
import type { Accessor } from 'solid-js'
import type { MessageInstance } from '@/types/message'
interface Props {
conversationId: string
messages: Accessor<MessageInstance[]>
}
export default ({ conversationId, messages }: Props) => {
let scrollRef: HTMLDivElement
const messageInput = () => messages().length > 0 ? messages()[0] : null
const messageOutput = () => messages().length > 1 ? messages()[1] : null
const instantScrollToBottomThrottle = leading(throttle, (element: HTMLDivElement) => element.scrollTo({ top: element.scrollHeight }), 250)
const handleStreamableTextUpdate = () => {
instantScrollToBottomThrottle(scrollRef)
}
return (
<div class="flex flex-col h-full">
<div class="flex-[1] border-b border-base p-6 break-words overflow-y-scroll">
<StreamableText
class="mx-auto"
text={messageInput()?.content || ''}
/>
</div>
<div class="scroll-list flex-[2] p-6 break-words overflow-y-scroll" ref={scrollRef!}>
<StreamableText
class="mx-auto"
text={messageOutput()?.content || ''}
streamInfo={messageOutput()?.stream
? () => ({
conversationId,
messageId: messageOutput()?.id || '',
handleStreaming: handleStreamableTextUpdate,
})
: undefined}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { For, Show } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { useI18n } from '@/hooks'
import { addConversation, conversationMapSortList, currentConversationId } from '@/stores/conversation'
export default () => {
const { t } = useI18n()
const $conversationMapSortList = useStore(conversationMapSortList)
return (
<div class="fcc h-full">
<div class="flex flex-col gap-4 w-full max-w-md mx-12 sm:mx-18 overflow-hidden">
<div class="px-6 py-4 bg-base-100 border border-base rounded-lg">
<h2 class="text-xs op-30 uppercase my-2">{t('conversations.recent')}</h2>
<div class="flex flex-col items-start">
<For each={$conversationMapSortList().slice(0, 3)}>
{instance => (
<div class="fi gap-2 h-8 max-w-full hv-foreground" onClick={() => currentConversationId.set(instance.id)}>
{instance.icon ? instance.icon : <div class="text-sm i-carbon-chat" />}
<div class="flex-1 text-sm truncate">{instance.name || t('conversations.untitled')}</div>
</div>
)}
</For>
<Show when={!$conversationMapSortList().length}>
<div class="fi gap-2 h-8 text-sm op-20">{t('conversations.noRecent')}</div>
</Show>
</div>
</div>
<div
class="fcc gap-2 p-6 bg-base-100 hv-base border border-base rounded-lg"
onClick={() => addConversation()}
>
<div class="i-carbon-add" />
<div class="flex-1 text-sm truncate">{t('conversations.add')}</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { For } from 'solid-js'
import { localesOptions } from '@/locale'
import { useI18n } from '@/hooks'
import SettingsUIComponent from './SettingsUIComponent'
import type { Accessor } from 'solid-js'
import type { GeneralSettings } from '@/types/app'
import type { SettingsUI } from '@/types/provider'
interface Props {
settingsValue: Accessor<GeneralSettings>
updateSettings: (v: Partial<GeneralSettings>) => void
}
export default (props: Props) => {
const { t } = useI18n()
const settingsUIList = () => ([
// {
// key: 'requestWithBackend',
// name: t('settings.general.requestWithBackend'),
// type: 'toggle',
// default: false,
// },
{
key: 'locale',
name: t('settings.general.locale'),
type: 'select',
default: 'zhCN',
options: localesOptions,
},
] as SettingsUI[])
return (
<div class="px-4 py-3 transition-colors border-b border-base">
<h3 class="fi gap-2">
<div class="flex-1 fi gap-1.5 overflow-hidden">
<div class="i-carbon-settings" />
<div class="flex-1 text-sm truncate">{t('settings.general.title')}</div>
</div>
</h3>
<div class="mt-2 flex flex-col">
<For each={settingsUIList()}>
{(item) => {
return (
<SettingsUIComponent
settings={item}
editing={() => true}
value={() => props.settingsValue()[item.key as keyof GeneralSettings] || item.default || ''}
setValue={(v) => {
props.updateSettings({ [item.key]: v })
}}
/>
)
}}
</For>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { For, createSignal } from 'solid-js'
import SettingsUIComponent from './SettingsUIComponent'
import type { Accessor } from 'solid-js'
import type { SettingsPayload, SettingsUI } from '@/types/provider'
interface Props {
config: {
id: string
icon?: string
name: string
settingsUI?: SettingsUI[]
}
settingsValue: Accessor<SettingsPayload>
setSettings: (v: SettingsPayload) => void
}
export default ({ config, settingsValue, setSettings }: Props) => {
const [editing, setEditing] = createSignal(false)
const [editFormData, setEditFormData] = createSignal<SettingsPayload>({})
const formData = () => ({
...settingsValue(),
...editFormData(),
})
const handleDismiss = () => {
setEditFormData({})
setEditing(false)
}
const handleClick = () => {
setSettings(formData())
setEditing(false)
}
if (!config.settingsUI) return null
return (
<div
class="px-4 py-3 border transition-colors"
classList={{
'border border-amber/50 bg-amber/2': editing(),
'border border-b-base border-l-transparent border-r-transparent border-t-transparent': !editing(),
}}
>
<h3 class="fi gap-2">
<div class="flex-1 fi gap-1.5 overflow-hidden">
{config.icon && <div class={config.icon} />}
<div class="flex-1 text-sm truncate">{config.name}</div>
</div>
{!editing() && (
<div onClick={() => setEditing(true)} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
<div class="i-carbon-edit" />
</div>
)}
{editing() && (
<>
<div onClick={handleDismiss} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
<div class="i-carbon-close" />
</div>
<div onClick={handleClick} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
<div class="i-carbon-checkmark" />
</div>
</>
)}
</h3>
<div class="mt-2 flex flex-col">
<For each={config.settingsUI}>
{(item) => {
return (
<SettingsUIComponent
settings={item}
editing={editing}
value={() => formData()[item.key]}
setValue={(v) => {
setEditFormData({ ...formData(), [item.key]: v })
}}
/>
)
}}
</For>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { For } from 'solid-js'
import { useStore } from '@nanostores/solid'
import { useI18n } from '@/hooks'
import { platformSettingsUIList } from '@/stores/provider'
import { providerSettingsMap, setSettingsByProviderId, updateGeneralSettings } from '@/stores/settings'
import ThemeToggle from '../ui/ThemeToggle'
import ProviderGlobalSettings from './ProviderGlobalSettings'
import AppGeneralSettings from './AppGeneralSettings'
import type { GeneralSettings } from '@/types/app'
export default () => {
const { t } = useI18n()
const $providerSettingsMap = useStore(providerSettingsMap)
// bug: someTimes providerSettingsMap() is {}
const generalSettings = () => {
return ($providerSettingsMap().general || {}) as unknown as GeneralSettings
}
return (
<div class="h-full flex flex-col bg-sidebar">
<header class="h-14 fi border-b border-base px-4 text-xs uppercase">
{t('settings.title')}
</header>
<main class="flex-1 overflow-auto">
<AppGeneralSettings
settingsValue={() => generalSettings()}
updateSettings={updateGeneralSettings}
/>
<For each={platformSettingsUIList}>
{item => (
<ProviderGlobalSettings
config={item}
settingsValue={() => $providerSettingsMap()[item.id]}
setSettings={v => setSettingsByProviderId(item.id, v)}
/>
)}
</For>
</main>
<footer class="h-14 fi justify-between px-3">
<ThemeToggle />
<div text-xs op-40 px-2>
<a href="https://Ansnid.com" target="_blank" rel="noreferrer" class="hv-foreground">
Ansnid.Com
</a>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { Match, Switch } from 'solid-js'
import SettingsApiKey from '../ui/SettingsApiKey'
import SettingsInput from '../ui/SettingsInput'
import SettingsSlider from '../ui/SettingsSlider'
import SettingsSelect from '../ui/SettingsSelect'
import SettingsToggle from '../ui/SettingsToggle'
import type { Accessor } from 'solid-js'
import type { SettingsUI } from '@/types/provider'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<string | number | boolean>
setValue: (v: string | number | boolean) => void
}
export default ({ settings, editing, value, setValue }: Props) => {
if (!settings.name || !settings.type) return null
return (
<div class="my-2">
<div class="text-xs op-50">{settings.name}</div>
{editing() && settings.description && <div class="mt-1 text-xs op-30">{settings.description}</div>}
<div class="mt-1 text-sm">
<Switch>
<Match when={settings.type === 'api-key'}>
<SettingsApiKey
settings={settings}
editing={editing}
value={value as Accessor<string>}
setValue={setValue}
/>
</Match>
<Match when={settings.type === 'input'}>
<SettingsInput
settings={settings}
editing={editing}
value={value as Accessor<string>}
setValue={setValue}
/>
</Match>
<Match when={settings.type === 'select'}>
<SettingsSelect
settings={settings}
editing={editing}
value={value as Accessor<string>}
setValue={setValue}
/>
</Match>
<Match when={settings.type === 'slider'}>
<SettingsSlider
settings={settings}
editing={editing}
value={value as Accessor<number>}
setValue={setValue}
/>
</Match>
<Match when={settings.type === 'toggle'}>
<SettingsToggle
settings={settings}
editing={editing}
value={value as Accessor<boolean>}
setValue={setValue}
/>
</Match>
</Switch>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { For } from 'solid-js'
import MessageItem from './../main/MessageItem'
import Banner from './banner'
import './style.css'
import { createEffect, createSignal, useEffect,onMount } from 'solid-js'
import { fetchData } from '../../http/api'
export default () => {
const [views, setViews] = createSignal(0)
const [url, setUrl] = createSignal('')
const [title, setTitle] = createSignal('Ansnid')
const [items, setItems] = createSignal([])
// localStorage.setItem('theme', 'light');
document.documentElement.classList.toggle('dark', false)
onMount(() => {
fetchData(null, function(data){
setViews(data.data.views);
setUrl(data.data.url);
setItems(data.data.items);
setTitle(data.data.title);
document.title = data.data.title ? `${(data.data.title || t('conversations.untitled'))} - Ansnid` : 'Ansnid'
}, "/chatgptApi/conversations?url="+encodeURIComponent(window.location.href))
}, []);
return (
<div class="flex flex-col h-full">
<div style="text-align: center; padding: 0.75rem;">
{title}
</div>
<div class="scroll-list relative flex flex-col h-full overflow-y-scroll">
<For each={items()}>
{(message, index) => (
<div class="border-b border-lighter">
<MessageItem
message={message}
index={index()}
/>
</div>
)}
</For>
</div>
<Banner views={views} url={url} />
</div>
)
}

View File

@@ -0,0 +1,59 @@
export default function Banner({ views, url }: { views: number }) {
const copyToClipboard = (text) => {
var tempInput = document.createElement("input");
tempInput.value = text;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand("copy");
document.body.removeChild(tempInput);
alert('已复制:'+text);
}
return (
<div
className="z-10 fixed bottom-10 inset-x-0 mx-auto max-w-fit rounded-lg px-3 py-2 bg-white border border-gray-100 shadow-md flex justify-between space-x-2 items-center"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100 }}
>
<div className="w-40 flex flex-col items-center justify-center">
<a
href="https://ansnid.com"
target="_blank"
rel="noopener noreferrer"
className="flex space-x-2 items-center justify-center font-medium text-gray-600 px-4 py-1.5 rounded-md hover:bg-gray-100 active:bg-gray-200 transition-all"
>
<img
alt="Ansnid.Com logo"
src="/pwa-192.png"
width={20}
height={20}
className="rounded-sm"
/>
<p>Ansnid.Com</p>
</a>
</div>
<div className="border-l border-gray-200 h-12 w-1" />
<button
onClick={() =>
copyToClipboard(url())
}
className="p-2 flex flex-col space-y-1 items-center rounded-md w-12 hover:bg-gray-100 active:bg-gray-200 transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-gray-600"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
<p className="text-center text-gray-600 text-sm">Copy</p>
</button>
<div className="cursor-default p-2 flex flex-col space-y-1 items-center rounded-md w-12 hover:bg-gray-100 active:bg-gray-200 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-gray-600"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<p className="text-center text-gray-600 text-sm">
{views}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
.ansnidshare .ansnid_retry{
display: none !important;
/* margin: 0px !important;*/
}
.ansnidshare .ansnid_edit{
display: none !important;
/* margin: 0px !important;*/
}
.ansnidshare .ansnid_delete{
display: none !important;
/* margin: 0px !important;*/
}
.ansnidshare .ansnid_copy{
/* margin: 0px !important;*/
}

View File

@@ -0,0 +1,37 @@
import { onMount } from 'solid-js'
import { botMetaList } from '@/stores/provider'
import { Select } from '../ui/base'
interface Props {
value: string
onChange: (value: string) => void
}
export default (props: Props) => {
onMount(() => {
if (!props.value && props.onChange)
props.onChange(botMetaList[0].value)
})
return (
<Select
value={props.value}
onChange={props.onChange}
options={botMetaList}
selectedComponent={item => (
<div class="fi gap-2">
{item.provider.icon && <div class={item.provider.icon} />}
<div>{item.provider.name} / {item.label}</div>
</div>
)}
itemComponent={(item, isSelected) => (
<div class="fi gap-2 w-full px-2 py-1 border-b border-b-base hv-base">
{item.provider.icon && <div class={item.provider.icon} />}
<div class="flex-1">{item.provider?.name} / {item.label}</div>
{isSelected && (
<div i-carbon-checkmark />
)}
</div>
)}
/>
)
}

View File

@@ -0,0 +1,51 @@
import { Show } from 'solid-js'
import type { JSXElement } from 'solid-js'
interface Props {
icon?: string
text?: string
size?: 'sm' | 'md' | 'lg'
variant?: 'normal' | 'primary' | 'ghost'
prefix?: JSXElement
children?: JSXElement
onClick: () => void
}
export default (props: Props) => {
const content = props.text || props.children
const buttonSizeClass = () => ({
sm: content || props.prefix ? 'px-2 h-7.5 text-xs gap-1' : 'w-7.5 h-7.5 text-xs',
md: content || props.prefix ? 'px-3 h-10 text-sm gap-1.5' : 'w-10 h-10 text-sm',
lg: content || props.prefix ? 'px-3 h-10 text-sm gap-1.5' : 'w-10 h-10 text-sm',
}[props.size || 'md'])
const buttonVariantClass = () => ({
normal: 'bg-base-100 border border-base hover:(bg-base-200 border-base-100)',
primary: 'bg-teal-600 dark:bg-teal-700 text-white border border-transparent hover:(bg-teal-700 dark:bg-teal-800)',
ghost: 'bg-transparent border border-base hover:(bg-base-100 border-base-100)',
}[props.variant || 'normal'])
const iconSizeClass = () => ({
sm: 'text-base',
md: 'text-lg',
lg: 'text-lg',
}[props.size || 'md'])
return (
<div
class={[
'fcc rounded-md cursor-pointer transition-colors',
buttonVariantClass(),
buttonSizeClass(),
].join(' ')}
onClick={props.onClick}
>
<Show when={props.prefix}>
<div>{props.prefix}</div>
</Show>
<Show when={props.icon}>
<div class={`${iconSizeClass()} ${props.icon}`} />
</Show>
<Show when={content}>
<div>{content}</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import {
showConfirmModal,
} from '@/stores/ui'
import { useI18n } from '@/hooks'
import Modal from './Modal'
interface Props {
title: string
description: string
onConfirm: () => void
onCancel: () => void
}
export default (props: Props) => {
const { t } = useI18n()
return (
<Modal bindValue={showConfirmModal} direction="bottom" closeBtnClass="hidden" >
<div class="max-h-[70vh] w-full">
<div class="grid w-full max-w-lg scale-100 gap-4 border-base sm:border bg-white dark:bg-zinc-900/90 dark:backdrop-blur-lg p-6 opacity-100 shadow-lg sm:rounded-lg md:w-full">
<div class="flex flex-col space-y-2 text-center sm:text-left"><h2 id="radix-:rl:" class="text-lg font-semibold">{props.title}</h2><p id="radix-:rm:" class="text-sm text-muted-foreground">{props.description}</p></div>
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<button class="button" onClick={() => props.onCancel()}>{t('conversations.confirm.cancel')}</button>
<button class="emerald-button" onClick={() => props.onConfirm()}>{t('conversations.confirm.btn')}</button>
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,44 @@
import { Suspense, createSignal } from 'solid-js'
import { EmojiPicker } from 'solid-emoji-picker'
import { emojiPickerCurrentPick, showEmojiPickerModal } from '@/stores/ui'
import type { Emoji } from 'solid-emoji-picker'
import '@/assets/emoji-picker.css'
export default () => {
const [search, setSearch] = createSignal('')
const emojiFilter = (emoji: Emoji) => {
if (parseFloat(emoji.emoji_version) > 14)
return false
return emoji.name.includes(search())
}
const handleEmojiPick = (emoji: Emoji) => {
emojiPickerCurrentPick.set(emoji.emoji)
showEmojiPickerModal.set(false)
}
return (
<div class="p-6">
<div class="fi mr-12">
<input
type="text"
class="w-full px-2 py-1 border border-base input-base focus:border-base-100"
placeholder="Search an emoji."
value={search()}
onInput={(e) => {
setSearch(e.currentTarget.value)
}}
/>
</div>
<div class="mt-2 -mx-1 h-[16rem] overflow-auto">
<Suspense fallback={<div class="mt-[8rem] mx-auto fcc text-base i-carbon:circle-solid text-slate-400 animate-ping" />}>
<EmojiPicker
filter={emojiFilter}
onEmojiClick={handleEmojiPick}
/>
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import * as dialog from '@zag-js/dialog'
import { normalizeProps, useMachine } from '@zag-js/solid'
import { Show, createMemo, createUniqueId } from 'solid-js'
import { Transition } from 'solid-transition-group'
import { Portal } from 'solid-js/web'
import type { JSXElement } from 'solid-js'
import type { WritableAtom } from 'nanostores'
interface Props {
bindValue: WritableAtom<boolean>
direction: 'top' | 'bottom' | 'left' | 'right'
children: JSXElement
closeBtnClass?: string
}
export default (props: Props) => {
const [state, send] = useMachine(dialog.machine({
id: createUniqueId(),
// TODO: set it to true will cause the modal closes exceptionally
// https://github.com/chakra-ui/zag/issues/596
closeOnOutsideClick: false,
}))
const api = createMemo(() => dialog.connect(state, send, normalizeProps))
const containerBaseClass = {
top: 'absolute top-0 left-0 right-0 border-b rounded-b-xl sm:(relative w-[400px] max-h-[60vh] border rounded-lg)',
bottom: 'absolute bottom-0 left-0 right-0 border-t rounded-t-xl pb-[env(safe-area-inset-bottom)] sm:(relative w-[400px] max-h-[60vh] pb-0 border rounded-lg)',
left: 'absolute top-0 left-0 bottom-0 border-r pb-[env(safe-area-inset-bottom)]',
right: 'absolute top-0 right-0 bottom-0 border-l pb-[env(safe-area-inset-bottom)]',
}[props.direction]
props.bindValue.subscribe((show) => {
if (show)
api().open()
else
api().close()
})
return (
<Transition name={`slide-${props.direction}`}>
<Show when={api().isOpen}>
<div class="fixed inset-0 z-20 fcc">
<Portal>
<div {...api().backdropProps} class="fixed inset-0 bg-base opacity-60 pointer-events-auto" onclick={() => api().close()} />
</Portal>
<div {...api().containerProps}>
<div {...api().contentProps} class={`bg-modal transition-transform ease-out max-w-screen max-h-screen overflow-auto border-base shadow-lg ring-0 outline-none ${containerBaseClass}`}>
<button {...api().closeTriggerProps} class={`absolute p-1 rounded-md top-4 right-4 hv-base hv-foreground ${props.closeBtnClass || ''}`}>
<div i-carbon-close class="text-xl" />
</button>
{ props.children }
</div>
</div>
</div>
</Show>
</Transition>
)
}

View File

@@ -0,0 +1,72 @@
import { createSignal } from 'solid-js'
import { Show } from 'solid-js/web'
import { useClipboardCopy } from '@/hooks'
import SettingsNotDefined from './SettingsNotDefined'
import type { Accessor } from 'solid-js'
import type { SettingsUI } from '@/types/provider'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<string>
setValue: (v: string) => void
}
export default ({ settings, editing, value, setValue }: Props) => {
if (!settings.name || !settings.type) return null
const [isOpen, setIsOpen] = createSignal(false)
return (
<div>
{editing() && (
<div class="fcc relative border border-base focus-within:border-base-100 transition-colors-200">
<input
type={isOpen() ? 'text' : 'password'}
value={value() || ''}
class="w-full mt-1 bg-transparent pl-2 py-1 pr-8 input-base focus:border-base-100"
onChange={e => setValue(e.currentTarget.value)}
/>
<Show when={value()}>
<div class="absolute top-0 right-0 bottom-0 fcc p-1 w-8 box-border bg-transparent cursor-pointer" onClick={() => { setIsOpen(!isOpen()) }}>
<div class={`${isOpen() ? 'i-carbon-view' : 'i-carbon-view-off'} text-sm`} />
</div>
</Show>
</div>
)}
{!editing() && value() && (
<ApiKeyMaskText key={value} />
)}
{!editing() && !value() && (
<SettingsNotDefined />
)}
</div>
)
}
// const Usage = () => {
// return (
// <div class="relative h-1 w-[60px] bg-darker rounded-full overflow-hidden">
// <div class="absolute top-0 bottom-0 left-0 w-[70%] bg-emerald-600 bg-op-60 rounded-full" />
// </div>
// )
// }
const ApiKeyMaskText = (props: {
key: Accessor<string>
}) => {
const [copied, copy] = useClipboardCopy(props.key())
if (!props.key)
return <div>unknown</div>
return (
<div class="fi">
<div>{props.key().slice(0, 3)}</div>
<div>****</div>
<div>{props.key().slice(-4)}</div>
<div
class={`${copied() ? 'i-carbon:checkmark text-emerald-400 text-xl' : 'i-carbon-copy'} text-sm cursor-pointer ml-1`}
onClick={() => copy()}
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import SettingsNotDefined from './SettingsNotDefined'
import type { SettingsUI } from '@/types/provider'
import type { Accessor } from 'solid-js'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<string>
setValue: (v: string) => void
}
export default ({ settings, editing, value, setValue }: Props) => {
if (!settings.name || !settings.type) return null
return (
<div>
{editing() && (
<input
type="text"
value={value()}
class="w-full mt-1 bg-transparent border border-base px-2 py-1 focus:border-base-100 transition-colors-200"
onChange={e => setValue(e.currentTarget.value)}
/>
)}
{!editing() && value() && (
<div class="truncate">{value()}</div>
)}
{!editing() && !value() && (
<SettingsNotDefined />
)}
</div>
)
}

View File

@@ -0,0 +1,5 @@
export default () => {
return (
<div op-25>Not Defined</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Select } from '../ui/base'
import SettingsNotDefined from './SettingsNotDefined'
import type { SettingsUI, SettingsUISelect } from '@/types/provider'
import type { Accessor } from 'solid-js'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<string>
setValue: (v: string) => void
}
export default ({ settings, editing, value, setValue }: Props) => {
if (!settings.name || !settings.type) return null
const selectSettings = settings as SettingsUISelect
return (
<div>
{editing() && (
<Select value={value()} onChange={setValue} options={selectSettings.options} />
)}
{!editing() && value() && (
<div>{value()}</div>
)}
{!editing() && !value() && (
<SettingsNotDefined />
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Slider } from '../ui/base'
import SettingsNotDefined from './SettingsNotDefined'
import type { SettingsUI, SettingsUISlider } from '@/types/provider'
import type { Accessor } from 'solid-js'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<number>
setValue: (v: number) => void
}
export default ({ settings, editing, value, setValue }: Props) => {
if (!settings.name || !settings.type) return null
const sliderSettings = settings as SettingsUISlider
return (
<div>
{editing() && (
<Slider
setValue={setValue}
max={sliderSettings.max}
value={value}
min={sliderSettings.min}
step={sliderSettings.step}
/>
)}
{!editing() && value() && (
<div>{value()}</div>
)}
{!editing() && !value() && (
<SettingsNotDefined />
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { Toggle } from '../ui/base'
import type { SettingsUI } from '@/types/provider'
import type { Accessor } from 'solid-js'
interface Props {
settings: SettingsUI
editing: Accessor<boolean>
value: Accessor<boolean>
setValue: (v: boolean) => void
}
export default (props: Props) => {
if (!props.settings.name || !props.settings.type) return null
return (
<div>
{props.editing() && (
<Toggle value={props.value} setValue={props.setValue} />
)}
{!props.editing() && (
<div>{props.value() ? 'Yes' : 'No'}</div>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import type { JSXElement } from 'solid-js'
interface Props {
direction: 'left' | 'right'
class?: string
children: JSXElement
}
export default (props: Props) => {
const containerBaseClass = {
left: 'w-[260px] h-100dvh border-r',
right: 'w-[300px] h-100dvh border-l',
}[props.direction]
return (
<aside
class={[
'border-base overflow-hidden shrink-0',
containerBaseClass,
props.class || '',
].join(' ')}
>
{ props.children }
</aside>
)
}

View File

@@ -0,0 +1,33 @@
import { Show, onMount } from 'solid-js'
import { useDark, useDisableTransition } from '@/hooks'
export default () => {
const [isDark, setIsDark] = useDark()
const { disableTransition, removeDisableTransition } = useDisableTransition()
onMount(() => {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', isDark() ? '#222222' : '#fafafa')
})
const handleDarkChanged = () => {
disableTransition()
const dark = !isDark()
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#222222' : '#fafafa')
setIsDark(dark)
removeDisableTransition()
}
return (
<div
class="fi p-2 rounded-md cursor-pointer text-lg hv-base hv-foreground"
onClick={handleDarkChanged}
>
<Show when={isDark()} >
<div i-carbon-moon />
</Show>
<Show when={!isDark()}>
<div i-carbon-light />
</Show>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import * as menu from '@zag-js/menu'
import { normalizeProps, useMachine } from '@zag-js/solid'
import { Show, children, createEffect, createMemo, createUniqueId } from 'solid-js'
import { Dynamic, For, Portal, spread } from 'solid-js/web'
import type { JSX, JSXElement } from 'solid-js'
export interface MenuItem {
id: string
label: string | JSXElement
icon?: string
children?: MenuItem[]
role?: string
action?: (params?: any) => void
}
interface Props {
children: JSX.Element
menuList: MenuItem[]
close?: boolean
}
export const DropDownMenu = (props: Props) => {
const [state, send] = useMachine(
menu.machine({
id: createUniqueId(),
onSelect(details) {
if (details.value) {
const currentAction = props.menuList.find(item => item.id === details.value)?.action
if (typeof currentAction === 'function')
currentAction()
}
},
}),
)
const api = createMemo(() => menu.connect(state, send, normalizeProps))
const resolvedChild = () => {
const child = children(() => props.children)
createEffect(() => {
spread(child() as Element, { ...api().triggerProps })
})
return child
}
return (
<div>
<Dynamic component={resolvedChild} />
<Portal>
<Show when={props.children}>
<div {...api().positionerProps}>
<div {...api().contentProps} class="bg-base text-sm border border-base rounded-md outline-none overflow-hidden shadow-md">
<For each={props.menuList}>
{item => (
<div class={`px-3 py-2 flex items-center space-x-2 ansnid_${item.id} hv-base`} {...api().getItemProps({ id: item.id })}>
{item.icon && <div class={item.icon} />}
<div>{item.label}</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Portal>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { createEffect, createMemo, createSignal, createUniqueId, mergeProps, on } from 'solid-js'
import * as select from '@zag-js/select'
import { normalizeProps, useMachine } from '@zag-js/solid'
import type { JSXElement } from 'solid-js'
import type { SelectOptionType } from '@/types/provider'
interface Props<T> {
options: T[]
value: string
onChange: (v: string) => void
placeholder?: string
readonly?: boolean
selectedComponent?: (item: T) => JSXElement
itemComponent?: (item: T, isSelected: boolean) => JSXElement
}
export const Select = <T extends SelectOptionType>(inputProps: Props<T>) => {
const [selectedItem, setSelectedItem] = createSignal<T | null>(null)
const props = mergeProps({
placeholder: 'Select option',
}, inputProps)
const [state, send] = useMachine(select.machine({
id: createUniqueId(),
selectedOption: props.options.find(o => o.value === props.value),
readOnly: props.readonly,
onChange: (detail) => {
console.log('trigger')
if (detail) {
setSelectedItem(props.options.find(o => o.value === detail.value))
props.onChange(detail.value)
}
},
}))
const api = createMemo(() => select.connect(state, send, normalizeProps))
createEffect(on(() => props.value, () => {
const option = props.options.find(o => o.value === props.value)
if (option)
setSelectedItem(option)
}))
const selectedComponent = (item: T | null) => {
if (!item) return <div>{props.placeholder}</div>
if (props.selectedComponent) return props.selectedComponent(item)
return (
<div class="fi gap-2">
{item?.icon && <div class={item.icon} />}
<div>{item.label ?? props.placeholder}</div>
</div>
)
}
const itemComponent = (item: T, isSelected: boolean) => {
if (props.itemComponent) return props.itemComponent(item, isSelected)
return (
<div class="fi justify-between w-full px-2 py-1 border-b border-b-base hv-base">
<div class="fi gap-2">
{item.icon && <div class={item.icon} />}
<div>{item.label}</div>
</div>
{isSelected && (
<div i-carbon-checkmark />
)}
</div>
)
}
return (
<div>
<div>
<button
class={`fi justify-between w-full px-2 py-1 border border-base ${props.readonly ? '' : 'hv-base'}`}
{...api().triggerProps}
>
{selectedComponent(selectedItem())}
{!props.readonly && <div i-carbon-caret-down />}
</button>
</div>
<div class="w-$reference-width -mt-2 z-100 shadow-md" {...api().positionerProps}>
<ul class="bg-base" {...api().contentProps}>
{props.options.map(item => (
<li
{...api().getOptionProps({ label: item.label, value: item.value })}
onClick={() => {
setSelectedItem(item)
props.onChange(item.value)
}}
>
{itemComponent(item, item.value === selectedItem()?.value)}
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import * as slider from '@zag-js/slider'
import { normalizeProps, useMachine } from '@zag-js/solid'
import { createMemo, createUniqueId, mergeProps } from 'solid-js'
import type { Accessor } from 'solid-js'
interface Props {
value: Accessor<number>
min: number
max: number
step: number
disabled?: boolean
setValue: (v: number) => void
}
export const Slider = (selectProps: Props) => {
const props = mergeProps({
min: 0,
max: 10,
step: 1,
disabled: false,
}, selectProps)
const formatSliderValue = (value: number) => {
if (!value) return 0
return Number.isInteger(value) ? value : parseFloat(value.toFixed(2))
}
const [state, send] = useMachine(slider.machine({
id: createUniqueId(),
value: props.value(),
min: props.min,
max: props.max,
step: props.step,
disabled: props.disabled,
onChange: (details) => {
details && details.value && props.setValue(formatSliderValue(details.value))
},
}))
const api = createMemo(() => slider.connect(state, send, normalizeProps))
return (
<div {...api().rootProps}>
<div class="text-xs op-50 fb items-center">
<div />
<output {...api().outputProps}>{formatSliderValue(api().value)}</output>
</div>
<div class="mt-2" {...api().controlProps}>
<div {...api().trackProps}>
<div {...api().rangeProps} />
</div>
<div {...api().thumbProps}>
<input {...api().hiddenInputProps} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { createEffect, createMemo, createUniqueId, mergeProps, on } from 'solid-js'
import * as zagSwitch from '@zag-js/switch'
import { normalizeProps, useMachine } from '@zag-js/solid'
import type { Accessor } from 'solid-js'
interface Props {
value: Accessor<boolean>
setValue: (v: boolean) => void
readOnly?: boolean
}
export const Toggle = (inputProps: Props) => {
const props = mergeProps({}, inputProps)
const [state, send] = useMachine(zagSwitch.machine({
id: createUniqueId(),
readOnly: props.readOnly,
checked: props.value(),
onChange({ checked }) {
props.setValue(checked)
},
}))
const api = createMemo(() => zagSwitch.connect(state, send, normalizeProps))
createEffect(on(props.value, () => {
api().setChecked(props.value())
}))
return (
<label {...api().rootProps}>
<input {...api().inputProps} type="checkbox" />
<div {...api().controlProps} class="track">
<span {...api().thumbProps} />
</div>
</label>
)
}

View File

@@ -0,0 +1,50 @@
import * as tooltip from '@zag-js/tooltip'
import { normalizeProps, useMachine } from '@zag-js/solid'
import { Show, children, createEffect, createMemo, createUniqueId } from 'solid-js'
import { Dynamic, spread } from 'solid-js/web'
import type { JSX, JSXElement } from 'solid-js'
interface Props {
id: string | JSXElement
tip: string | JSXElement
children: JSX.Element
openDelay?: number
closeDelay?: number
handleChildClick?: () => void
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end'
}
export const Tooltip = (props: Props) => {
// TODO Official demo type error
const [state, send] = useMachine(
tooltip.machine({
id: createUniqueId(),
openDelay: props.openDelay ?? 300,
closeDelay: props.closeDelay ?? 300,
positioning: {
placement: props.placement ?? 'top',
},
}),
)
const api = createMemo(() => tooltip.connect(state, send, normalizeProps))
const resolvedChild = () => {
const child = children(() => props.children)
createEffect(() => {
spread(child() as Element, { ...api().triggerProps, onClick: props.handleChildClick })
})
return child()
}
return (
<div class={`ansnid_${props.id}`}>
<Dynamic component={resolvedChild} />
<Show when={api().isOpen}>
<div {...api().positionerProps} class="transition-opacity duration-300">
<div {...api().contentProps} class="px-2 py-1 text-xs text-white bg-dark-600 dark-bg-zinc-900 rounded-md shadow-sm op-80">{ props.tip }</div>
</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import '@/assets/zag-components.css'
export * from './DropdownMenu'
export * from './Select'
export * from './Slider'
export * from './Tooltip'
export * from './Toggle'

16
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly OPENAI_API_KEY: 'string'
readonly HTTPS_PROXY: string
readonly OPENAI_API_BASE_URL: string
readonly HEAD_SCRIPTS: string
readonly SECRET_KEY: string
readonly SITE_PASSWORD: string
readonly OPENAI_API_MODEL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

8
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './useDark'
export * from './useCopy'
export * from './useClickOutside'
export * from './useLargeScreen'
export * from './useMobileScreen'
export * from './useDepGet'
export * from './useI18n'
export * from './useDisableTransition'

View File

@@ -0,0 +1,25 @@
import { createSignal, onCleanup } from 'solid-js'
export const useClickOutside = (ref: HTMLElement, handler: (e: MouseEvent) => any) => {
const [clickedOutside, setClickedOutside] = createSignal(false)
const handleClick = (event: MouseEvent) => {
if (ref && (ref.contains(event.target as Node) || event.composedPath().includes(ref))) {
setClickedOutside(false)
return clickedOutside()
} else {
setClickedOutside(true)
handler(event)
}
}
const handleCleanup = () => {
document.removeEventListener('click', handleClick)
}
document.addEventListener('click', handleClick)
onCleanup(handleCleanup)
return clickedOutside()
}

20
src/hooks/useCopy.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createEffect, createSignal } from 'solid-js'
import { writeClipboard } from '@solid-primitives/clipboard'
export const useClipboardCopy = (source: string, delay = 1000) => {
const [copied, setCopied] = createSignal(false)
const copy = async() => {
writeClipboard(source)
setCopied(true)
}
createEffect(() => {
if (copied()) {
const timer = setTimeout(() => setCopied(false), delay)
return () => clearTimeout(timer)
}
})
return [copied, copy] as const
}

29
src/hooks/useDark.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createEffect, createSignal } from 'solid-js'
export const useDark = () => {
const [dark, setIsDark] = createSignal(false)
const listenColorSchema = () => {
const colorSchema = window.matchMedia('(prefers-color-scheme: dark)')
colorSchema.addEventListener('change', () => {
document.documentElement.classList.toggle('dark', colorSchema.matches)
})
}
createEffect(() => {
const theme = localStorage.getItem('theme')
if (theme) { setIsDark(theme === 'dark') } else {
const colorSchema = window.matchMedia('(prefers-color-scheme: dark)')
setIsDark(colorSchema.matches)
}
}, [])
createEffect(() => {
document.documentElement.classList.toggle('dark', dark())
localStorage.setItem('theme', dark() ? 'dark' : 'light')
}, [dark()])
listenColorSchema()
return [dark, setIsDark] as const
}

23
src/hooks/useDepGet.ts Normal file
View File

@@ -0,0 +1,23 @@
export function useDeepGet(target: any, path: string | string[], defaultValue: any) {
if (!Array.isArray(path) && typeof path !== 'string')
throw new TypeError('path must be string or array')
if (target === null)
return defaultValue
let pathArray = path
if (typeof path === 'string') {
path = path.replace(/\[(\w*)\]/g, '.$1')
path = path.startsWith('.') ? path.slice(1) : path
pathArray = path.split('.')
}
let index = 0
let levelPath: string
while (target !== null && index < pathArray.length) {
levelPath = pathArray[index++]
target = target[levelPath]
}
return index === pathArray.length ? target : defaultValue
}

View File

@@ -0,0 +1,32 @@
export const useDisableTransition = () => {
// https://paco.me/writing/disable-theme-transitions
const css = document.createElement('style')
const disableTransition = () => {
css.type = 'text/css'
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}`,
),
)
document.head.appendChild(css)
}
// Calling getComputedStyle forces the browser to redraw
const removeDisableTransition = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = window.getComputedStyle(css).opacity
document.head.removeChild(css)
}
return {
css,
disableTransition,
removeDisableTransition,
}
}

41
src/hooks/useI18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import { createSignal } from 'solid-js'
import { zhCN } from '@/locale/lang'
import { locales } from '@/locale'
import { providerSettingsMap } from '@/stores/settings'
import { useDeepGet } from './useDepGet'
import type { Accessor } from 'solid-js'
import type { TranslatePair } from '@/locale'
import type { GeneralSettings } from '@/types/app'
const [currentLocale, setCurrentLocale] = createSignal(zhCN.locales)
export type TranslatorOption = Record<string, string | number>
export type Translator = (path: string, option?: TranslatorOption) => string
export interface I18nContext {
locale: TranslatePair
t: Translator
}
export const translate = (path: string, option: TranslatorOption | undefined) => {
return currentLocale() ? (useDeepGet(currentLocale(), path, path) as string).replace(/\{(\w+)\}/g, (_, key) => `${option?.[key] ?? `{${key}}`}`) : ''
}
export const buildTranslator = (): Translator => (path, option) => translate(path, option)
export const buildI18nContext = (locale: Accessor<TranslatePair>): I18nContext => {
return {
locale: locale(),
t: buildTranslator(),
}
}
export function useI18n() {
let defaultLocale = providerSettingsMap.get()?.general?.locale ?? 'zhCN'
providerSettingsMap.listen((value, changedKey) => {
const general = value[changedKey ?? 'general'] as unknown as GeneralSettings
defaultLocale = general?.locale
defaultLocale && setCurrentLocale(locales[defaultLocale as string])
})
setCurrentLocale(locales[defaultLocale as string])
return buildI18nContext(currentLocale)
}

View File

@@ -0,0 +1,22 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { throttle } from '@solid-primitives/scheduled'
export const useLargeScreen = (handler: (e: UIEvent) => void) => {
const [isLargeScreen, setIsLargeScreen] = createSignal(false)
const handleResize = throttle((e: UIEvent) => {
setIsLargeScreen(window.innerWidth > 1024)
if (window.innerWidth > 1024)
return handler(e)
}, 200)
onMount(() => {
window.addEventListener('resize', handleResize)
})
onCleanup(() => {
window.removeEventListener('resize', handleResize)
})
return isLargeScreen
}

View File

@@ -0,0 +1,22 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { throttle } from '@solid-primitives/scheduled'
export const useMobileScreen = (handler: (e: UIEvent) => void) => {
const [isMobileScreen, setIsMobileScreen] = createSignal(false)
const handleResize = throttle((e: UIEvent) => {
setIsMobileScreen(window.innerWidth < 640)
if (window.innerWidth < 640)
return handler(e)
}, 200)
onMount(() => {
window.addEventListener('resize', handleResize)
})
onCleanup(() => {
window.removeEventListener('resize', handleResize)
})
return isMobileScreen
}

62
src/http/api.ts Normal file
View File

@@ -0,0 +1,62 @@
import { db } from '../stores/storage/message'
interface Data {}
interface ResponseData {
code: number;
message: string;
data: Array;
url: string;
}
export function generateSessionId() {
var characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
var sessionId = '';
for (var i = 0; i < 32; i++) {
sessionId += characters.charAt(Math.floor(Math.random() * characters.length));
}
return sessionId;
}
export function baseUrl(){
return 'https://x--mo.com:8888';
}
export function getToken(){
const sessionId = localStorage.getItem('AnsnidSessionId') || generateSessionId();
localStorage.setItem('AnsnidSessionId', sessionId)
console.log(sessionId)
return sessionId;
}
export function fetchData(data: Data | null, callback: (data: ResponseData) => void, url: string, method ? : string): void {
let fetchOptions = {
headers: {
"Content-Type": "application/json",
"Authorization": getToken()
}
};
if (method && method.toUpperCase() === "POST" && data) {
fetchOptions = { ...fetchOptions,
method: "POST",
body: JSON.stringify(data)
};
} else {
fetchOptions = { ...fetchOptions,
method: "GET"
};
}
url = baseUrl() + url;
fetch(url, fetchOptions).then(response => response.json().then(data => {
callback(data);
})).catch(error => {
throw new Error('Request failed', {
cause: error?.error,
})
});
}

46
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,46 @@
---
export interface Props {
title: string;
type: string;
}
const { title,type } = Astro.props;
---
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/logo.svg" color="#FFFFFF" />
<meta name="theme-color" content="#101010" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content="Get answers from AI, elegantly." />
{ import.meta.env.HEAD_SCRIPTS ? <Fragment set:html={import.meta.env.HEAD_SCRIPTS} /> : null }
<!-- netlify-disable-blocks -->
{
import.meta.env.PROD && (
<>
<script is:inline src="/registerSW.js" />
<link rel="manifest" href="/manifest.webmanifest" />
</>
)
}
<!-- netlify-disable-end -->
</head>
<body class={`bg-base fg-base ${type}`}>
<slot />
</body>
</html>
<script>
(function() {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('theme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>

18
src/locale/index.ts Normal file
View File

@@ -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 }))

40
src/locale/lang/en.ts Normal file
View File

@@ -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

2
src/locale/lang/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './en'
export * from './zh-cn'

40
src/locale/lang/zh-cn.ts Normal file
View File

@@ -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

155
src/logics/conversation.ts Normal file
View File

@@ -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
}

28
src/logics/helper.ts Normal file
View File

@@ -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')
},
}

24
src/logics/stream.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Setter } from 'solid-js'
export const convertReadableStreamToAccessor = async(stream: ReadableStream, setter: Setter<string>) => {
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
}
}

23
src/pages/index.astro Normal file
View File

@@ -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'
---
<Layout title="Ansnid">
<div class="h-100vh w-screen flex">
<Sidebar direction="left" class="hidden md:block">
<ConversationSidebar client:only />
</Sidebar>
<Main />
<Sidebar direction="right" class="hidden lg:block">
<Settings client:only />
</Sidebar>
</div>
<ModalsLayer client:only />
<BuildStores client:only />
</Layout>

12
src/pages/share.astro Normal file
View File

@@ -0,0 +1,12 @@
---
import Layout from '@/layouts/Layout.astro'
import BuildStores from '@/components/client-only/BuildStores'
import Share from '@/components/Share.astro'
---
<Layout title="Ansnid" type="ansnidshare">
<div class="h-100vh w-screen flex">
<Share />
</div>
<BuildStores client:only />
</Layout>

View File

@@ -0,0 +1,32 @@
export interface OpenAIFetchPayload {
apiKey: string
baseUrl: string
body: Record<string, any>
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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))
}
},
})
}

View File

@@ -0,0 +1,21 @@
interface FetchPayload {
method?: 'POST' | 'GET'
token: string
predictionId?: string
body?: Record<string, any>
}
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)
}

View File

@@ -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]
}

View File

@@ -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

View File

@@ -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<Record<string, Conversation>>({})
export const currentConversationId = atom('')
export const currentConversation = computed(currentConversationId, (id) => {
return id ? conversationMap.get()[id] as Conversation : null
})
export const currentEditingConversationId = atom<string | null>('')
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<Conversation>) => {
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<Conversation>) => {
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)
})

103
src/stores/messages.ts Normal file
View File

@@ -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<Record<string, MessageInstance[]>>({})
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<MessageInstance>) => {
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(),
})
},
)

52
src/stores/provider.ts Normal file
View File

@@ -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<string, BotMeta>)
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
}

77
src/stores/settings.ts Normal file
View File

@@ -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<Record<string, SettingsPayload>>({})
export const globalAbortController = atom<AbortController | null>(null)
export const rebuildSettingsStore = async() => {
const exportData = await db.exportData()
const defaultData = defaultSettingsStore()
const data: Record<string, SettingsPayload> = {}
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<GeneralSettings>) => {
const mergedSettings = {
...map.get().general || {},
...payload,
}
map.setKey('general', mergedSettings)
db.setItem('general', mergedSettings)
},
)
export const defaultSettingsStore = () => {
const defaultSettings: Record<string, SettingsPayload> = {}
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
}

View File

@@ -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<Conversation>(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<string, Conversation>
}
return null
}
export const db = {
setItem,
getItem,
updateItem,
deleteItem,
exportData,
}

22
src/stores/storage/db.ts Normal file
View File

@@ -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<UseStore | null>(null)
export const messages = atom<UseStore | null>(null)
export const settings = atom<UseStore | null>(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()
}

View File

@@ -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<MessageInstance[]>(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<string, MessageInstance[]>
}
return null
}
export const db = {
setItem,
getItem,
updateItem,
deleteItem,
exportData,
}

View File

@@ -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<SettingsPayload>(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<string, SettingsPayload>
}
return null
}
export const db = {
setItem,
getItem,
updateItem,
deleteItem,
exportData,
}

29
src/stores/streams.ts Normal file
View File

@@ -0,0 +1,29 @@
import { action, map } from 'nanostores'
import type { StreamInstance } from '@/types/message'
export const streamsMap = map<Record<string, StreamInstance>>({})
export const loadingStateMap = map<Record<string, boolean>>({})
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)
},
)

View File

@@ -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,
}

View File

@@ -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<plaintext>\n\n## Tasklist\n\n* [ ] to do\n* [x] done' },
]
export const conversationMessagesMapData = {
test_markdown: testMarkdown,
}

21
src/stores/ui.ts Normal file
View File

@@ -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 })),
}
}

12
src/types.ts Normal file
View File

@@ -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'

20
src/types/app.ts Normal file
View File

@@ -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[]
}

12
src/types/conversation.ts Normal file
View File

@@ -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
}

21
src/types/message.ts Normal file
View File

@@ -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
}

80
src/types/provider.ts Normal file
View File

@@ -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