Skip to content

Commit

Permalink
feat: Add system instruction support
Browse files Browse the repository at this point in the history
refactor: Reconstruct the topic square and introduce Agent list
chore: Some page components have been changed to lazy loading to improve the loading speed of the home page.
  • Loading branch information
Amery2010 committed May 12, 2024
1 parent 6b424b2 commit 59cd85d
Show file tree
Hide file tree
Showing 27 changed files with 465 additions and 332 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ NEXT_PUBLIC_ENABLE_PROTECT=0
ACCESS_PASSWORD=
GEMINI_API_KEY=
GEMINI_API_BASE_URL=
AGENTS_INDEX_URL=
HEAD_SCRIPTS=
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Deploy your private Gemini application for free with one click, supporting Gemin
- **Deploy for free with one-click** on Vercel in under 1 minute
- Talk mode: Let you talk directly to Gemini
- Visual recognition allows Gemini to understand the content of the picture
- Topic square with hundreds of selected system instruction
- Full Markdown support: LaTex formulas, code highlighting, and more
- Automatically compress contextual chat records to save Tokens while supporting very long conversations
- Privacy and security, all data is saved locally in the user's browser
Expand All @@ -64,6 +65,7 @@ Deploy your private Gemini application for free with one click, supporting Gemin
- 在 1 分钟内使用 Vercel **免费一键部署**
- 语音模式:让您直接与 Gemini 对话
- 视觉识别,让 Gemini 可以看懂图片内容
- 话题广场,拥有数百精选的系统指令
- 完整的 Markdown 支持:LaTex 公式、代码高亮等等
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
- 隐私安全,所有数据保存在用户浏览器本地
Expand All @@ -74,14 +76,14 @@ Deploy your private Gemini application for free with one click, supporting Gemin

## Roadmap

- [ ] Reconstruct the topic square and introduce Prompt list
- [x] Reconstruct the topic square and introduce Prompt list
- [ ] Add conversation list
- [ ] Use tauri to package desktop applications
- [ ] Share as image, share to ShareGPT link

## 开发计划

- [ ] 重构话题广场,引入 Prompt 列表
- [x] 重构话题广场,引入 Prompt 列表
- [ ] 增加对话列表
- [ ] 使用 tauri 打包桌面应用
- [ ] 分享为图片,分享到 ShareGPT 链接
Expand Down
102 changes: 63 additions & 39 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use client'
import dynamic from 'next/dynamic'
import { useRef, useState, useMemo, KeyboardEvent, useEffect, useCallback, useLayoutEffect } from 'react'
import { EdgeSpeech, SpeechRecognition } from '@xiangfa/polly'
import type { InlineDataPart } from '@google/generative-ai'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import SiriWave from 'siriwave'
import {
MessageCircleHeart,
Expand All @@ -21,10 +20,8 @@ import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import MessageItem from '@/components/MessageItem'
import ErrorMessageItem from '@/components/ErrorMessageItem'
import Setting from '@/components/Setting'
import Topic from '@/components/Topic'
import SystemInstruction from '@/components/SystemInstruction'
import Button from '@/components/Button'
import ImageUploader from '@/components/ImageUploader'
import { useMessageStore } from '@/store/chat'
import { useSettingStore } from '@/store/setting'
import chat, { type RequestProps } from '@/utils/chat'
Expand All @@ -34,8 +31,8 @@ import PromiseQueue from '@/utils/PromiseQueue'
import textStream, { streamToText } from '@/utils/textStream'
import { generateSignature, generateUTCTimestamp } from '@/utils/signature'
import { shuffleArray, formatTime } from '@/utils/common'
import { agentMarket } from '@/utils/AgentMarket'
import { cn } from '@/utils'
import topics from '@/constant/topics'
import { Model } from '@/constant/model'
import { customAlphabet } from 'nanoid'
import { isFunction } from 'lodash-es'
Expand All @@ -50,6 +47,10 @@ interface AnswerParams {
const buildMode = process.env.NEXT_PUBLIC_BUILD_MODE as string
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8)

const AgentMarket = dynamic(() => import('@/components/AgentMarket'))
const Setting = dynamic(() => import('@/components/Setting'))
const ImageUploader = dynamic(() => import('@/components/ImageUploader'))

export default function Home() {
const { t } = useTranslation()
const siriWaveRef = useRef<HTMLDivElement>(null)
Expand All @@ -61,9 +62,8 @@ export default function Home() {
const messagesRef = useRef(useMessageStore.getState().messages)
const messageStore = useMessageStore()
const settingStore = useSettingStore()
const [messageAutoAnimate] = useAutoAnimate()
const [textareaHeight, setTextareaHeight] = useState<number>(40)
const [randomTopic, setRandomTopic] = useState<Topic[]>([])
const [randomAgent, setRandomAgent] = useState<Agent[]>([])
const [siriWave, setSiriWave] = useState<SiriWave>()
const [content, setContent] = useState<string>('')
const [subtitle, setSubtitle] = useState<string>('')
Expand All @@ -72,7 +72,7 @@ export default function Home() {
const [recordTimer, setRecordTimer] = useState<NodeJS.Timeout>()
const [recordTime, setRecordTime] = useState<number>(0)
const [settingOpen, setSetingOpen] = useState<boolean>(false)
const [topicOpen, setTopicOpen] = useState<boolean>(false)
const [agentMarketOpen, setAgentMarketOpen] = useState<boolean>(false)
const [speechSilence, setSpeechSilence] = useState<boolean>(false)
const [disableSpeechRecognition, setDisableSpeechRecognition] = useState<boolean>(false)
const [status, setStatus] = useState<'thinkng' | 'silence' | 'talking'>('silence')
Expand Down Expand Up @@ -132,6 +132,7 @@ export default function Home() {
}, [])

const fetchAnswer = useCallback(async ({ messages, model, onResponse, onError }: AnswerParams) => {
const { systemInstruction } = useMessageStore.getState()
const { apiKey, apiProxy, password } = useSettingStore.getState()
setErrorMessage('')
if (apiKey !== '') {
Expand All @@ -141,6 +142,7 @@ export default function Home() {
model,
}
if (apiProxy) config.baseUrl = apiProxy
if (systemInstruction) config.systemInstruction = systemInstruction
try {
const result = await chat(config)
const encoder = new TextEncoder()
Expand All @@ -167,14 +169,22 @@ export default function Home() {
}
} else {
const utcTimestamp = generateUTCTimestamp()
const config: {
messages: Message[]
model: string
systemInstruction?: string
ts: number
sign: string
} = {
messages,
model,
ts: utcTimestamp,
sign: generateSignature(password, utcTimestamp),
}
if (systemInstruction) config.systemInstruction = systemInstruction
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
messages,
model,
ts: utcTimestamp,
sign: generateSignature(password, utcTimestamp),
}),
body: JSON.stringify(config),
})
if (response.status < 400 && response.body) {
onResponse(response.body)
Expand Down Expand Up @@ -422,22 +432,26 @@ export default function Home() {
})
}, [])

const initTopic = useCallback((topic: Topic) => {
const { add: addMessage, clear: clearMessage } = useMessageStore.getState()
const initAgent = useCallback((prompt: string) => {
const { instruction, clear: clearMessage } = useMessageStore.getState()
clearMessage()
topic.parts.forEach((part) => {
addMessage({ id: nanoid(), ...part })
})
instruction(prompt)
}, [])

useEffect(() => useMessageStore.subscribe((state) => (messagesRef.current = state.messages)), [])
const initAgentMarket = useCallback((agentList: Agent[]) => {
setRandomAgent(shuffleArray<Agent>(agentList).slice(0, 3))
}, [])

useEffect(() => {
if (messagesRef.current.length === 0) {
const langType = settingStore.lang.split('-')[0] === 'zh' ? 'zh' : 'en'
setRandomTopic(shuffleArray<Topic>(topics[langType]).slice(0, 3))
}
}, [settingStore.lang])
const handleSelectAgent = useCallback(
async (identifier: string) => {
const response = await fetch(agentMarket.getAgentUrl(identifier, settingStore.lang))
const data: AgentDetail = await response.json()
initAgent(data.config.systemRole)
},
[settingStore.lang, initAgent],
)

useEffect(() => useMessageStore.subscribe((state) => (messagesRef.current = state.messages)), [])

useEffect(() => {
requestAnimationFrame(scrollToBottom)
Expand Down Expand Up @@ -505,7 +519,7 @@ export default function Home() {
</Button>
</div>
</div>
{messageStore.messages.length === 0 && content === '' ? (
{messageStore.messages.length === 0 && content === '' && messageStore.systemInstruction === '' ? (
<div className="relative flex min-h-full grow items-center justify-center text-sm">
<div className="relative -top-8 text-center text-sm">
<PackageOpen
Expand All @@ -515,28 +529,33 @@ export default function Home() {
<p className="my-2 text-gray-300 dark:text-gray-700">{t('chatEmpty')}</p>
<p className="text-gray-600">{t('selectTopicTip')}</p>
</div>
<div className="absolute bottom-2 flex text-gray-600">
{randomTopic.map((topic) => {
<div className="absolute bottom-2 flex gap-1 text-gray-600">
{randomAgent.map((agent) => {
return (
<div
key={topic.id}
className="mx-1 cursor-pointer overflow-hidden text-ellipsis text-nowrap rounded-md border px-2 py-1 hover:bg-slate-100 dark:hover:bg-slate-900"
onClick={() => initTopic(topic)}
key={agent.identifier}
className="cursor-pointer rounded-md border px-2 py-1 hover:bg-slate-100 max-sm:first:hidden dark:hover:bg-slate-900"
onClick={() => handleSelectAgent(agent.identifier)}
>
{topic.title}
{agent.meta.title}
</div>
)
})}
<div
className="mx-1 cursor-pointer rounded-md p-1 text-center underline underline-offset-4"
onClick={() => setTopicOpen(true)}
className="cursor-pointer rounded-md p-1 text-center underline underline-offset-4"
onClick={() => setAgentMarketOpen(true)}
>
{t('more')}
</div>
</div>
</div>
) : (
<div ref={messageAutoAnimate} className="flex min-h-full flex-1 grow flex-col justify-start">
<div className="flex min-h-full flex-1 grow flex-col justify-start">
{messageStore.systemInstruction !== '' ? (
<div className="p-4 pt-0">
<SystemInstruction prompt={messageStore.systemInstruction} onClear={() => initAgent('')} />
</div>
) : null}
{messageStore.messages.map((msg, idx) => (
<div
className="group text-slate-500 transition-colors last:text-slate-800 hover:text-slate-800 max-sm:hover:bg-transparent dark:last:text-slate-400 dark:hover:text-slate-400"
Expand All @@ -551,7 +570,7 @@ export default function Home() {
{t('regenerateAnswer')}
</span>
<Separator orientation="vertical" />
<span className="mx-2 cursor-pointer hover:text-slate-500" onClick={() => setTopicOpen(true)}>
<span className="mx-2 cursor-pointer hover:text-slate-500" onClick={() => setAgentMarketOpen(true)}>
{t('changeTopic')}
</span>
<Separator orientation="vertical" />
Expand Down Expand Up @@ -684,7 +703,12 @@ export default function Home() {
</div>
</div>
<Setting open={settingOpen} hiddenTalkPanel={disableSpeechRecognition} onClose={() => setSetingOpen(false)} />
<Topic open={topicOpen} onClose={() => setTopicOpen(false)} onSelect={initTopic} />
<AgentMarket
open={agentMarketOpen}
onClose={() => setAgentMarketOpen(false)}
onSelect={initAgent}
onLoaded={initAgentMarket}
/>
</main>
)
}
114 changes: 114 additions & 0 deletions components/AgentMarket.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useState, useCallback, useEffect, memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Card, CardDescription, CardHeader, CardFooter, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import SearchBar from '@/components/SearchBar'
import { useSettingStore } from '@/store/setting'
import { agentMarket } from '@/utils/AgentMarket'

type AgentProps = {
open: boolean
onClose: () => void
onSelect: (prompt: string) => void
onLoaded: (agentList: Agent[]) => void
}

function search(keyword: string, data: Agent[]): Agent[] {
const results: Agent[] = []
// 'i' means case-insensitive
const regex = new RegExp(keyword.trim(), 'gi')
data.forEach((item) => {
if (item.meta.tags.includes(keyword) || regex.test(item.meta.title) || regex.test(item.meta.description)) {
results.push(item)
}
})
return results
}

function Agent({ open, onClose, onSelect, onLoaded }: AgentProps) {
const { t } = useTranslation()
const { lang } = useSettingStore()
const [reources, setResources] = useState<Agent[]>([])
const [agentList, setAgentList] = useState<Agent[]>([])

const handleClose = useCallback(
(open: boolean) => {
if (!open) onClose()
},
[onClose],
)

const handleSelect = useCallback(
async (agent: Agent) => {
onClose()
const response = await fetch(agentMarket.getAgentUrl(agent.identifier, lang))
const agentDeatil: AgentDetail = await response.json()
onSelect(agentDeatil.config.systemRole)
},
[lang, onClose, onSelect],
)

const handleSearch = useCallback(
(keyword: string) => {
const result = search(keyword, reources)
setAgentList(result)
},
[reources],
)

const fetchAgentMarketIndex = useCallback(async () => {
const response = await fetch(agentMarket.getIndexUrl(lang))
const agentMarketIndex = await response.json()
setResources(agentMarketIndex.agents)
setAgentList(agentMarketIndex.agents)
onLoaded(agentMarketIndex.agents)
}, [lang, onLoaded])

useEffect(() => {
fetchAgentMarketIndex()
}, [fetchAgentMarketIndex])

return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-screen-md p-0 max-sm:h-full landscape:max-md:h-full">
<DialogHeader className="p-6 pb-0 max-sm:p-4 max-sm:pb-0">
<DialogTitle>{t('topicSquare')}</DialogTitle>
<DialogDescription className="pb-2">{t('selectTopic')}</DialogDescription>
<SearchBar onSearch={handleSearch} onClear={() => setAgentList(reources)} />
</DialogHeader>
<ScrollArea className="h-[400px] w-full scroll-smooth max-sm:h-full">
<div className="grid grid-cols-2 gap-2 p-6 pt-0 max-sm:grid-cols-1 max-sm:p-4 max-sm:pt-0">
{agentList.map((agent) => {
return (
<Card
key={agent.identifier}
className="cursor-pointer transition-colors hover:drop-shadow-md dark:hover:border-white/80"
onClick={() => handleSelect(agent)}
>
<CardHeader className="p-4 pb-2">
<CardTitle className="truncate text-lg">{agent.meta.title}</CardTitle>
<CardDescription className="text-line-clamp-2 h-10">{agent.meta.description}</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between p-4 pt-0">
<span>{agent.createAt}</span>
<a
className="underline-offset-4 hover:underline"
href={agent.homepage}
target="_blank"
onClick={(ev) => ev.stopPropagation()}
>
@{agent.author}
</a>
</CardFooter>
</Card>
)
})}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

export default memo(Agent)
Loading

0 comments on commit 59cd85d

Please sign in to comment.