first commit
This commit is contained in:
15
src/assets/emoji-picker.css
Normal file
15
src/assets/emoji-picker.css
Normal 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
62
src/assets/prism.css
Normal 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
56
src/assets/transition.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
115
src/assets/zag-components.css
Normal file
115
src/assets/zag-components.css
Normal 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
18
src/components/Main.astro
Normal 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>
|
||||
41
src/components/Markdown.tsx
Normal file
41
src/components/Markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/ModalsLayer.tsx
Normal file
38
src/components/ModalsLayer.tsx
Normal 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
219
src/components/Send.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/components/Share.astro
Normal file
11
src/components/Share.astro
Normal 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>
|
||||
54
src/components/StreamableText.tsx
Normal file
54
src/components/StreamableText.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
11
src/components/client-only/BuildStores.tsx
Normal file
11
src/components/client-only/BuildStores.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createStores, rebuildStores } from '@/stores/storage/db'
|
||||
|
||||
const buildStores = async() => {
|
||||
await createStores()
|
||||
await rebuildStores()
|
||||
}
|
||||
|
||||
export default () => {
|
||||
buildStores()
|
||||
return null
|
||||
}
|
||||
84
src/components/conversations/ConversationEdit.tsx
Normal file
84
src/components/conversations/ConversationEdit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/conversations/ConversationEditModal.tsx
Normal file
37
src/components/conversations/ConversationEditModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/conversations/ConversationSidebar.tsx
Normal file
36
src/components/conversations/ConversationSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/conversations/ConversationSidebarAdd.tsx
Normal file
20
src/components/conversations/ConversationSidebarAdd.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/conversations/ConversationSidebarItem.tsx
Normal file
50
src/components/conversations/ConversationSidebarItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/header/ConversationHeaderInfo.tsx
Normal file
26
src/components/header/ConversationHeaderInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/header/ConversationHeaderShare.tsx
Normal file
38
src/components/header/ConversationHeaderShare.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
src/components/header/ConversationMessageClearButton.tsx
Normal file
34
src/components/header/ConversationMessageClearButton.tsx
Normal 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) }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
39
src/components/header/Header.tsx
Normal file
39
src/components/header/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/components/main/Continuous.tsx
Normal file
67
src/components/main/Continuous.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
73
src/components/main/Conversation.tsx
Normal file
73
src/components/main/Conversation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/components/main/ConversationEmpty.tsx
Normal file
29
src/components/main/ConversationEmpty.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/main/Image.tsx
Normal file
35
src/components/main/Image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
src/components/main/MessageItem.tsx
Normal file
163
src/components/main/MessageItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/components/main/Single.tsx
Normal file
45
src/components/main/Single.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/components/main/Welcome.tsx
Normal file
39
src/components/main/Welcome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/settings/AppGeneralSettings.tsx
Normal file
59
src/components/settings/AppGeneralSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/components/settings/ProviderGlobalSettings.tsx
Normal file
83
src/components/settings/ProviderGlobalSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/components/settings/SettingsSidebar.tsx
Normal file
49
src/components/settings/SettingsSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/components/settings/SettingsUIComponent.tsx
Normal file
69
src/components/settings/SettingsUIComponent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/components/share/Conversation.tsx
Normal file
54
src/components/share/Conversation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/share/banner.tsx
Normal file
59
src/components/share/banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/share/style.css
Normal file
17
src/components/share/style.css
Normal 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;*/
|
||||
}
|
||||
37
src/components/ui/BotSelect.tsx
Normal file
37
src/components/ui/BotSelect.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
src/components/ui/Button.tsx
Normal file
51
src/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/components/ui/ConfirmModal.tsx
Normal file
29
src/components/ui/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/ui/EmojiPickerModal.tsx
Normal file
44
src/components/ui/EmojiPickerModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/Modal.tsx
Normal file
58
src/components/ui/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/ui/SettingsApiKey.tsx
Normal file
72
src/components/ui/SettingsApiKey.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/components/ui/SettingsInput.tsx
Normal file
32
src/components/ui/SettingsInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/components/ui/SettingsNotDefined.tsx
Normal file
5
src/components/ui/SettingsNotDefined.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export default () => {
|
||||
return (
|
||||
<div op-25>Not Defined</div>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/SettingsSelect.tsx
Normal file
30
src/components/ui/SettingsSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/ui/SettingsSlider.tsx
Normal file
36
src/components/ui/SettingsSlider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/ui/SettingsToggle.tsx
Normal file
25
src/components/ui/SettingsToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/ui/Sidebar.tsx
Normal file
26
src/components/ui/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/ui/ThemeToggle.tsx
Normal file
33
src/components/ui/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/components/ui/base/DropdownMenu.tsx
Normal file
67
src/components/ui/base/DropdownMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/ui/base/Select.tsx
Normal file
97
src/components/ui/base/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/components/ui/base/Slider.tsx
Normal file
56
src/components/ui/base/Slider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/ui/base/Toggle.tsx
Normal file
37
src/components/ui/base/Toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/ui/base/Tooltip.tsx
Normal file
50
src/components/ui/base/Tooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
src/components/ui/base/index.ts
Normal file
7
src/components/ui/base/index.ts
Normal 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
16
src/env.d.ts
vendored
Normal 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
8
src/hooks/index.ts
Normal 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'
|
||||
25
src/hooks/useClickOutside.ts
Normal file
25
src/hooks/useClickOutside.ts
Normal 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
20
src/hooks/useCopy.ts
Normal 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
29
src/hooks/useDark.ts
Normal 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
23
src/hooks/useDepGet.ts
Normal 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
|
||||
}
|
||||
32
src/hooks/useDisableTransition.ts
Normal file
32
src/hooks/useDisableTransition.ts
Normal 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
41
src/hooks/useI18n.ts
Normal 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)
|
||||
}
|
||||
22
src/hooks/useLargeScreen.ts
Normal file
22
src/hooks/useLargeScreen.ts
Normal 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
|
||||
}
|
||||
22
src/hooks/useMobileScreen.ts
Normal file
22
src/hooks/useMobileScreen.ts
Normal 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
62
src/http/api.ts
Normal 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
46
src/layouts/Layout.astro
Normal 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
18
src/locale/index.ts
Normal 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
40
src/locale/lang/en.ts
Normal 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
2
src/locale/lang/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './en'
|
||||
export * from './zh-cn'
|
||||
40
src/locale/lang/zh-cn.ts
Normal file
40
src/locale/lang/zh-cn.ts
Normal 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
155
src/logics/conversation.ts
Normal 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
28
src/logics/helper.ts
Normal 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
24
src/logics/stream.ts
Normal 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
23
src/pages/index.astro
Normal 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
12
src/pages/share.astro
Normal 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>
|
||||
|
||||
32
src/providers/openai/api.ts
Normal file
32
src/providers/openai/api.ts
Normal 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)
|
||||
}
|
||||
85
src/providers/openai/handler.ts
Normal file
85
src/providers/openai/handler.ts
Normal 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
|
||||
}
|
||||
110
src/providers/openai/index.ts
Normal file
110
src/providers/openai/index.ts
Normal 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
|
||||
42
src/providers/openai/parser.ts
Normal file
42
src/providers/openai/parser.ts
Normal 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))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
21
src/providers/replicate/api.ts
Normal file
21
src/providers/replicate/api.ts
Normal 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)
|
||||
}
|
||||
70
src/providers/replicate/handler.ts
Normal file
70
src/providers/replicate/handler.ts
Normal 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]
|
||||
}
|
||||
36
src/providers/replicate/index.ts
Normal file
36
src/providers/replicate/index.ts
Normal 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
|
||||
84
src/stores/conversation.ts
Normal file
84
src/stores/conversation.ts
Normal 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
103
src/stores/messages.ts
Normal 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
52
src/stores/provider.ts
Normal 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
77
src/stores/settings.ts
Normal 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
|
||||
}
|
||||
45
src/stores/storage/conversation.ts
Normal file
45
src/stores/storage/conversation.ts
Normal 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
22
src/stores/storage/db.ts
Normal 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()
|
||||
}
|
||||
45
src/stores/storage/message.ts
Normal file
45
src/stores/storage/message.ts
Normal 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,
|
||||
}
|
||||
45
src/stores/storage/settings.ts
Normal file
45
src/stores/storage/settings.ts
Normal 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
29
src/stores/streams.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
13
src/stores/tests/conversation.mock.ts
Normal file
13
src/stores/tests/conversation.mock.ts
Normal 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,
|
||||
}
|
||||
34
src/stores/tests/message.mock.ts
Normal file
34
src/stores/tests/message.mock.ts
Normal 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
21
src/stores/ui.ts
Normal 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
12
src/types.ts
Normal 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
20
src/types/app.ts
Normal 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
12
src/types/conversation.ts
Normal 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
21
src/types/message.ts
Normal 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
80
src/types/provider.ts
Normal 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
|
||||
Reference in New Issue
Block a user