Эх сурвалжийг харах

1.0 待添加功能 保持上下文

cauto 1 жил өмнө
parent
commit
e341a59dc2
59 өөрчлөгдсөн 2497 нэмэгдсэн , 187 устгасан
  1. 3 0
      .commitlintrc.json
  2. 11 0
      .editorconfig
  3. 1 1
      .env
  4. 4 0
      .eslintrc.cjs
  5. 1 0
      config/index.ts
  6. 16 0
      config/proxy.ts
  7. 69 3
      index.html
  8. 26 9
      package.json
  9. 5 5
      postcss.config.js
  10. 2 4
      src/App.vue
  11. 22 0
      src/api/index.ts
  12. 0 38
      src/components/HelloWorld.vue
  13. 20 0
      src/components/common/HoverButton/Button.vue
  14. 46 0
      src/components/common/HoverButton/index.vue
  15. 43 0
      src/components/common/NaiveProvider/index.vue
  16. 21 0
      src/components/common/SvgIcon/index.vue
  17. 4 0
      src/components/common/index.ts
  18. 8 0
      src/hooks/useBasicLayout.ts
  19. 36 0
      src/hooks/useIconRender.ts
  20. 1 2
      src/main.ts
  21. 16 4
      src/router/index.ts
  22. 23 22
      src/router/permission.ts
  23. 15 0
      src/store/modules/auth/helper.ts
  24. 54 0
      src/store/modules/auth/index.ts
  25. 22 0
      src/store/modules/chat/helper.ts
  26. 194 0
      src/store/modules/chat/index.ts
  27. 5 1
      src/store/modules/index.ts
  28. 18 0
      src/store/modules/prompt/helper.ts
  29. 17 0
      src/store/modules/prompt/index.ts
  30. 32 0
      src/store/modules/user/helper.ts
  31. 22 0
      src/store/modules/user/index.ts
  32. 0 80
      src/style.css
  33. 53 0
      src/typings/chat.d.ts
  34. 6 0
      src/typings/global.d.ts
  35. 44 0
      src/utils/format/index.ts
  36. 18 0
      src/utils/functions/debounce.ts
  37. 7 0
      src/utils/functions/index.ts
  38. 55 0
      src/utils/is/index.ts
  39. 32 0
      src/utils/request/axios.ts
  40. 84 0
      src/utils/request/index.ts
  41. 81 0
      src/views/chat/components/Header/index.vue
  42. 28 0
      src/views/chat/components/Message/Avatar.vue
  43. 85 0
      src/views/chat/components/Message/Text.vue
  44. 133 0
      src/views/chat/components/Message/index.vue
  45. 75 0
      src/views/chat/components/Message/style.less
  46. 3 0
      src/views/chat/components/index.ts
  47. 28 0
      src/views/chat/hooks/useChat.ts
  48. 24 0
      src/views/chat/hooks/useCopyCode.ts
  49. 44 0
      src/views/chat/hooks/useScroll.ts
  50. 23 0
      src/views/chat/hooks/useUsingContext.ts
  51. 548 0
      src/views/chat/index.vue
  52. 51 0
      src/views/chat/layout/Layout.vue
  53. 80 0
      src/views/chat/layout/Permission.vue
  54. 3 0
      src/views/chat/layout/index.ts
  55. 24 0
      src/views/chat/layout/sider/Footer.vue
  56. 106 0
      src/views/chat/layout/sider/List.vue
  57. 95 0
      src/views/chat/layout/sider/index.vue
  58. 10 9
      tsconfig.json
  59. 0 9
      tsconfig.node.json

+ 3 - 0
.commitlintrc.json

@@ -0,0 +1,3 @@
+{
+  "extends": ["@commitlint/config-conventional"]
+}

+ 11 - 0
.editorconfig

@@ -0,0 +1,11 @@
+# Editor configuration, see http://editorconfig.org
+
+root = true
+
+[*]
+charset = utf-8
+indent_style = tab
+indent_size = 2
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true

+ 1 - 1
.env

@@ -1,7 +1,7 @@
 # Glob API URL
 VITE_GLOB_API_URL=/api
 
-VITE_APP_API_BASE_URL=http://127.0.0.1:3002/
+VITE_APP_API_BASE_URL=http://127.0.0.1:8080/api/
 
 # Whether long replies are supported, which may result in higher API fees
 VITE_GLOB_OPEN_LONG_REPLY=false

+ 4 - 0
.eslintrc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  root: true,
+  extends: ['@antfu'],
+}

+ 1 - 0
config/index.ts

@@ -0,0 +1 @@
+export * from './proxy'

+ 16 - 0
config/proxy.ts

@@ -0,0 +1,16 @@
+import type { ProxyOptions } from 'vite'
+
+export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
+  if (!isOpenProxy)
+    return
+
+  const proxy: Record<string, string | ProxyOptions> = {
+    '/api': {
+      target: viteEnv.VITE_APP_API_BASE_URL,
+      changeOrigin: true,
+      rewrite: path => path.replace('/api/', '/'),
+    },
+  }
+
+  return proxy
+}

+ 69 - 3
index.html

@@ -3,11 +3,77 @@
   <head>
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Vite + Vue + TS</title>
+      <meta name="viewport"
+            content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
+      <title>ChatGPT Web</title>
   </head>
   <body>
-    <div id="app"></div>
+    <div id="app">
+        <style>
+            .loading-wrap {
+                display: flex;
+                justify-content: center;
+                align-items: center;
+                height: 100vh;
+            }
+
+            .balls {
+                width: 4em;
+                display: flex;
+                flex-flow: row nowrap;
+                align-items: center;
+                justify-content: space-between;
+            }
+
+            .balls div {
+                width: 0.8em;
+                height: 0.8em;
+                border-radius: 50%;
+                background-color: #4b9e5f;
+            }
+
+            .balls div:nth-of-type(1) {
+                transform: translateX(-100%);
+                animation: left-swing 0.5s ease-in alternate infinite;
+            }
+
+            .balls div:nth-of-type(3) {
+                transform: translateX(-95%);
+                animation: right-swing 0.5s ease-out alternate infinite;
+            }
+
+            @keyframes left-swing {
+
+                50%,
+                100% {
+                    transform: translateX(95%);
+                }
+            }
+
+            @keyframes right-swing {
+                50% {
+                    transform: translateX(-95%);
+                }
+
+                100% {
+                    transform: translateX(100%);
+                }
+            }
+
+            @media (prefers-color-scheme: dark) {
+                body {
+                    background: #121212;
+                }
+            }
+        </style>
+        <div class="loading-wrap">
+            <div class="balls">
+                <div></div>
+                <div></div>
+                <div></div>
+            </div>
+        </div>
+    </div>
     <script type="module" src="/src/main.ts"></script>
   </body>
 </html>

+ 26 - 9
package.json

@@ -1,8 +1,15 @@
 {
-  "name": "gpt",
-  "private": true,
-  "version": "0.0.0",
-  "type": "module",
+  "name": "chatgpt-web",
+  "version": "1.0.0",
+  "private": false,
+  "description": "ChatGPT Web",
+  "author": "",
+  "keywords": [
+    "chatgpt-web",
+    "chatgpt",
+    "chatbot",
+    "vue"
+  ],
   "scripts": {
     "dev": "vite",
     "build": "vue-tsc && vite build",
@@ -14,6 +21,7 @@
     "highlight.js": "^11.7.0",
     "html2canvas": "^1.4.1",
     "katex": "^0.16.4",
+    "markdown-it": "^13.0.1",
     "naive-ui": "^2.34.3",
     "pinia": "^2.0.33",
     "vue": "^3.2.47",
@@ -21,22 +29,31 @@
     "vue-router": "^4.1.6"
   },
   "devDependencies": {
+    "@antfu/eslint-config": "^0.35.3",
+    "@commitlint/cli": "^17.5.1",
+    "@commitlint/config-conventional": "^17.4.4",
+    "@iconify/vue": "^4.1.0",
     "@types/crypto-js": "^4.1.1",
+    "@types/katex": "^0.16.0",
+    "@types/markdown-it": "^12.2.3",
+    "@types/markdown-it-link-attributes": "^3.0.1",
+    "@types/node": "^18.15.11",
     "@vitejs/plugin-vue": "^4.1.0",
     "autoprefixer": "^10.4.14",
     "axios": "^1.3.4",
     "crypto-js": "^4.1.1",
-    "eslint": "^8.35.0",
+    "eslint": "^8.37.0",
     "husky": "^8.0.3",
     "less": "^4.1.3",
-    "lint-staged": "^13.1.2",
+    "lint-staged": "^13.2.0",
     "markdown-it-link-attributes": "^4.0.1",
     "npm-run-all": "^4.1.5",
     "postcss": "^8.4.21",
+    "rimraf": "^4.4.1",
     "tailwindcss": "^3.3.1",
-    "typescript": "^4.9.3",
-    "vite": "^4.2.0",
-    "vite-plugin-pwa": "^0.14.4",
+    "typescript": "~4.9.5",
+    "vite": "^4.2.1",
+    "vite-plugin-pwa": "^0.14.7",
     "vue-tsc": "^1.2.0"
   },
   "lint-staged": {

+ 5 - 5
postcss.config.js

@@ -1,6 +1,6 @@
-export default {
-  plugins: {
-    tailwindcss: {},
-    autoprefixer: {},
-  },
+module.exports = {
+	plugins: {
+		tailwindcss: {},
+		autoprefixer: {},
+	},
 }

+ 2 - 4
src/App.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { NConfigProvider } from 'naive-ui'
+import { NaiveProvider } from '@/components/common'
 import { useTheme } from '@/hooks/useTheme'
 import { useLanguage } from '@/hooks/useLanguage'
 const { theme, themeOverrides } = useTheme()
@@ -17,7 +18,4 @@ const { language } = useLanguage()
       <RouterView />
     </NaiveProvider>
   </NConfigProvider>
-</template>
-
-<style scoped>
-</style>
+</template>

+ 22 - 0
src/api/index.ts

@@ -0,0 +1,22 @@
+import type { GenericAbortSignal } from 'axios'
+import { post } from '@/utils/request'
+export function fetchSession<T>() {
+	return post<T>({
+		url: '/session',
+	})
+}
+
+
+export function fetchChatAPIProcess<T = any>(
+	params: {
+		prompt: string
+		options?: { conversationId?: string; parentMessageId?: string }
+		signal?: GenericAbortSignal
+	}
+) {
+	return post<T>({
+		url: '/v1/chat',
+		data: {'role':'user','content':params.prompt},
+		signal: params.signal,
+	})
+}

+ 0 - 38
src/components/HelloWorld.vue

@@ -1,38 +0,0 @@
-<script setup lang="ts">
-import { ref } from 'vue'
-
-defineProps<{ msg: string }>()
-
-const count = ref(0)
-</script>
-
-<template>
-  <h1>{{ msg }}</h1>
-
-  <div class="card">
-    <button type="button" @click="count++">count is {{ count }}</button>
-    <p>
-      Edit
-      <code>components/HelloWorld.vue</code> to test HMR
-    </p>
-  </div>
-
-  <p>
-    Check out
-    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
-      >create-vue</a
-    >, the official Vue + Vite starter
-  </p>
-  <p>
-    Install
-    <a href="https://github.com/vuejs/language-tools" target="_blank">Volar</a>
-    in your IDE for a better DX
-  </p>
-  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
-</template>
-
-<style scoped>
-.read-the-docs {
-  color: #888;
-}
-</style>

+ 20 - 0
src/components/common/HoverButton/Button.vue

@@ -0,0 +1,20 @@
+<script setup lang='ts'>
+interface Emit {
+  (e: 'click'): void
+}
+
+const emit = defineEmits<Emit>()
+
+function handleClick() {
+  emit('click')
+}
+</script>
+
+<template>
+  <button
+      class="flex items-center justify-center w-10 h-10 transition rounded-full hover:bg-neutral-100 dark:hover:bg-[#414755]"
+      @click="handleClick"
+  >
+    <slot />
+  </button>
+</template>

+ 46 - 0
src/components/common/HoverButton/index.vue

@@ -0,0 +1,46 @@
+<script setup lang='ts'>
+import { computed } from 'vue'
+import type { PopoverPlacement } from 'naive-ui'
+import { NTooltip } from 'naive-ui'
+import Button from './Button.vue'
+
+interface Props {
+  tooltip?: string
+  placement?: PopoverPlacement
+}
+
+interface Emit {
+  (e: 'click'): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  tooltip: '',
+  placement: 'bottom',
+})
+
+const emit = defineEmits<Emit>()
+
+const showTooltip = computed(() => Boolean(props.tooltip))
+
+function handleClick() {
+  emit('click')
+}
+</script>
+
+<template>
+  <div v-if="showTooltip">
+    <NTooltip :placement="placement" trigger="hover">
+      <template #trigger>
+        <Button @click="handleClick">
+          <slot />
+        </Button>
+      </template>
+      {{ tooltip }}
+    </NTooltip>
+  </div>
+  <div v-else>
+    <Button @click="handleClick">
+      <slot />
+    </Button>
+  </div>
+</template>

+ 43 - 0
src/components/common/NaiveProvider/index.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import { defineComponent, h } from 'vue'
+import {
+  NDialogProvider,
+  NLoadingBarProvider,
+  NMessageProvider,
+  NNotificationProvider,
+  useDialog,
+  useLoadingBar,
+  useMessage,
+  useNotification,
+} from 'naive-ui'
+
+function registerNaiveTools() {
+  window.$loadingBar = useLoadingBar()
+  window.$dialog = useDialog()
+  window.$message = useMessage()
+  window.$notification = useNotification()
+}
+
+const NaiveProviderContent = defineComponent({
+  name: 'NaiveProviderContent',
+  setup() {
+    registerNaiveTools()
+  },
+  render() {
+    return h('div')
+  },
+})
+</script>
+
+<template>
+  <NLoadingBarProvider>
+    <NDialogProvider>
+      <NNotificationProvider>
+        <NMessageProvider>
+          <slot />
+          <NaiveProviderContent />
+        </NMessageProvider>
+      </NNotificationProvider>
+    </NDialogProvider>
+  </NLoadingBarProvider>
+</template>

+ 21 - 0
src/components/common/SvgIcon/index.vue

@@ -0,0 +1,21 @@
+<script setup lang='ts'>
+import { computed, useAttrs } from 'vue'
+import { Icon } from '@iconify/vue'
+
+interface Props {
+  icon?: string
+}
+
+defineProps<Props>()
+
+const attrs = useAttrs()
+
+const bindAttrs = computed<{ class: string; style: string }>(() => ({
+  class: (attrs.class as string) || '',
+  style: (attrs.style as string) || '',
+}))
+</script>
+
+<template>
+  <Icon :icon="icon" v-bind="bindAttrs" />
+</template>

+ 4 - 0
src/components/common/index.ts

@@ -0,0 +1,4 @@
+import HoverButton from './HoverButton/index.vue'
+import NaiveProvider from './NaiveProvider/index.vue'
+import SvgIcon from './SvgIcon/index.vue'
+export { HoverButton, NaiveProvider,SvgIcon}

+ 8 - 0
src/hooks/useBasicLayout.ts

@@ -0,0 +1,8 @@
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
+
+export function useBasicLayout() {
+  const breakpoints = useBreakpoints(breakpointsTailwind)
+  const isMobile = breakpoints.smaller('sm')
+
+  return { isMobile }
+}

+ 36 - 0
src/hooks/useIconRender.ts

@@ -0,0 +1,36 @@
+import { h } from 'vue'
+import { SvgIcon } from '@/components/common'
+
+export const useIconRender = () => {
+  interface IconConfig {
+    icon?: string
+    color?: string
+    fontSize?: number
+  }
+
+  interface IconStyle {
+    color?: string
+    fontSize?: string
+  }
+
+  const iconRender = (config: IconConfig) => {
+    const { color, fontSize, icon } = config
+
+    const style: IconStyle = {}
+
+    if (color)
+      style.color = color
+
+    if (fontSize)
+      style.fontSize = `${fontSize}px`
+
+    if (!icon)
+      window.console.warn('iconRender: icon is required')
+
+    return () => h(SvgIcon, { icon, style })
+  }
+
+  return {
+    iconRender,
+  }
+}

+ 1 - 2
src/main.ts

@@ -1,5 +1,4 @@
 import { createApp } from 'vue'
-import './style.css'
 import App from './App.vue'
 import { setupI18n } from './locales'
 import { setupAssets, setupScrollbarStyle } from './plugins'
@@ -19,4 +18,4 @@ async function bootstrap() {
     app.mount('#app')
 }
 
-bootstrap()
+bootstrap()

+ 16 - 4
src/router/index.ts

@@ -1,12 +1,24 @@
 import type { App } from 'vue'
 import type { RouteRecordRaw } from 'vue-router'
 import { createRouter, createWebHashHistory } from 'vue-router'
-import { setupPageGuard } from './permission'
-// import { ChatLayout } from '@/views/chat/layout'
+//import { setupPageGuard } from './permission'
+import { ChatLayout } from '@/views/chat/layout'
 
 
 const routes: RouteRecordRaw[] = [
-
+    {
+        path: '/',
+        name: 'Root',
+        component: ChatLayout,
+        redirect: '/chat',
+        children: [
+            {
+                path: '/chat/:uuid?',
+                name: 'Chat',
+                component: () => import('@/views/chat/index.vue'),
+            },
+        ],
+    },
 ]
 
 export const router = createRouter({
@@ -15,7 +27,7 @@ export const router = createRouter({
     scrollBehavior: () => ({ left: 0, top: 0 }),
 })
 
-setupPageGuard(router)
+//setupPageGuard(router)
 
 export async function setupRouter(app: App) {
     app.use(router)

+ 23 - 22
src/router/permission.ts

@@ -1,26 +1,27 @@
 import type { Router } from 'vue-router'
+import { useAuthStoreWithout } from '@/store/modules/auth'
 export function setupPageGuard(router: Router) {
     router.beforeEach(async (to, from, next) => {
-        // const authStore = useAuthStoreWithout()
-        // if (!authStore.session) {
-        //     try {
-        //         const data = await authStore.getSession()
-        //         if (String(data.auth) === 'false' && authStore.token)
-        //             authStore.removeToken()
-        //         if (to.path === '/500')
-        //             next({ name: 'Root' })
-        //         else
-        //             next()
-        //     }
-        //     catch (error) {
-        //         if (to.path !== '/500')
-        //             next({ name: '500' })
-        //         else
-        //             next()
-        //     }
-        // }
-        // else {
-        //     next()
-        // }
+        const authStore = useAuthStoreWithout()
+        if (!authStore.session) {
+            try {
+                const data = await authStore.getSession()
+                if (String(data.auth) === 'false' && authStore.token)
+                    authStore.removeToken()
+                if (to.path === '/500')
+                    next({ name: 'Root' })
+                else
+                    next()
+            }
+            catch (error) {
+                if (to.path !== '/500')
+                    next({ name: '500' })
+                else
+                    next()
+            }
+        }
+        else {
+            next()
+        }
     })
-}
+}

+ 15 - 0
src/store/modules/auth/helper.ts

@@ -0,0 +1,15 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'SECRET_TOKEN'
+
+export function getToken() {
+  return ss.get(LOCAL_NAME)
+}
+
+export function setToken(token: string) {
+  return ss.set(LOCAL_NAME, token)
+}
+
+export function removeToken() {
+  return ss.remove(LOCAL_NAME)
+}

+ 54 - 0
src/store/modules/auth/index.ts

@@ -0,0 +1,54 @@
+import { defineStore } from 'pinia'
+import { getToken, removeToken, setToken } from './helper'
+import { store } from '@/store'
+import { fetchSession } from '@/api'
+
+interface SessionResponse {
+  auth: boolean
+  model: 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI'
+}
+
+export interface AuthState {
+  token: string | undefined
+  session: SessionResponse | null
+}
+
+export const useAuthStore = defineStore('auth-store', {
+  state: (): AuthState => ({
+    token: getToken(),
+    session: null,
+  }),
+
+  getters: {
+    isChatGPTAPI(state): boolean {
+      return state.session?.model === 'ChatGPTAPI'
+    },
+  },
+
+  actions: {
+    async getSession() {
+      try {
+        const { data } = await fetchSession<SessionResponse>()
+        this.session = { ...data }
+        return Promise.resolve(data)
+      }
+      catch (error) {
+        return Promise.reject(error)
+      }
+    },
+
+    setToken(token: string) {
+      this.token = token
+      setToken(token)
+    },
+
+    removeToken() {
+      this.token = undefined
+      removeToken()
+    },
+  },
+})
+
+export function useAuthStoreWithout() {
+  return useAuthStore(store)
+}

+ 22 - 0
src/store/modules/chat/helper.ts

@@ -0,0 +1,22 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'chatStorage'
+
+export function defaultState(): Chat.ChatState {
+  const uuid = 1002
+  return {
+    active: uuid,
+    usingContext: true,
+    history: [{ uuid, title: 'New Chat', isEdit: false }],
+    chat: [{ uuid, data: [] }],
+  }
+}
+
+export function getLocalState(): Chat.ChatState {
+  const localState = ss.get(LOCAL_NAME)
+  return { ...defaultState(), ...localState }
+}
+
+export function setLocalState(state: Chat.ChatState) {
+  ss.set(LOCAL_NAME, state)
+}

+ 194 - 0
src/store/modules/chat/index.ts

@@ -0,0 +1,194 @@
+import { defineStore } from 'pinia'
+import { getLocalState, setLocalState } from './helper'
+import { router } from '@/router'
+
+export const useChatStore = defineStore('chat-store', {
+  state: (): Chat.ChatState => getLocalState(),
+
+  getters: {
+    getChatHistoryByCurrentActive(state: Chat.ChatState) {
+      const index = state.history.findIndex(item => item.uuid === state.active)
+      if (index !== -1)
+        return state.history[index]
+      return null
+    },
+
+    getChatByUuid(state: Chat.ChatState) {
+      return (uuid?: number) => {
+        if (uuid)
+          return state.chat.find(item => item.uuid === uuid)?.data ?? []
+        return state.chat.find(item => item.uuid === state.active)?.data ?? []
+      }
+    },
+  },
+
+  actions: {
+    setUsingContext(context: boolean) {
+      this.usingContext = context
+      this.recordState()
+    },
+
+    addHistory(history: Chat.History, chatData: Chat.Chat[] = []) {
+      this.history.unshift(history)
+      this.chat.unshift({ uuid: history.uuid, data: chatData })
+      this.active = history.uuid
+      this.reloadRoute(history.uuid)
+    },
+
+    updateHistory(uuid: number, edit: Partial<Chat.History>) {
+      const index = this.history.findIndex(item => item.uuid === uuid)
+      if (index !== -1) {
+        this.history[index] = { ...this.history[index], ...edit }
+        this.recordState()
+      }
+    },
+
+    async deleteHistory(index: number) {
+      this.history.splice(index, 1)
+      this.chat.splice(index, 1)
+
+      if (this.history.length === 0) {
+        this.active = null
+        this.reloadRoute()
+        return
+      }
+
+      if (index > 0 && index <= this.history.length) {
+        const uuid = this.history[index - 1].uuid
+        this.active = uuid
+        this.reloadRoute(uuid)
+        return
+      }
+
+      if (index === 0) {
+        if (this.history.length > 0) {
+          const uuid = this.history[0].uuid
+          this.active = uuid
+          this.reloadRoute(uuid)
+        }
+      }
+
+      if (index > this.history.length) {
+        const uuid = this.history[this.history.length - 1].uuid
+        this.active = uuid
+        this.reloadRoute(uuid)
+      }
+    },
+
+    async setActive(uuid: number) {
+      this.active = uuid
+      return await this.reloadRoute(uuid)
+    },
+
+    getChatByUuidAndIndex(uuid: number, index: number) {
+      if (!uuid || uuid === 0) {
+        if (this.chat.length)
+          return this.chat[0].data[index]
+        return null
+      }
+      const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+      if (chatIndex !== -1)
+        return this.chat[chatIndex].data[index]
+      return null
+    },
+
+    addChatByUuid(uuid: number, chat: Chat.Chat) {
+      if (!uuid || uuid === 0) {
+        if (this.history.length === 0) {
+          const uuid = Date.now()
+          this.history.push({ uuid, title: chat.text, isEdit: false })
+          this.chat.push({ uuid, data: [chat] })
+          this.active = uuid
+          this.recordState()
+        }
+        else {
+          this.chat[0].data.push(chat)
+          if (this.history[0].title === 'New Chat')
+            this.history[0].title = chat.text
+          this.recordState()
+        }
+      }
+
+      const index = this.chat.findIndex(item => item.uuid === uuid)
+      if (index !== -1) {
+        this.chat[index].data.push(chat)
+        if (this.history[index].title === 'New Chat')
+          this.history[index].title = chat.text
+        this.recordState()
+      }
+    },
+
+    updateChatByUuid(uuid: number, index: number, chat: Chat.Chat) {
+      if (!uuid || uuid === 0) {
+        if (this.chat.length) {
+          this.chat[0].data[index] = chat
+          this.recordState()
+        }
+        return
+      }
+
+      const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+      if (chatIndex !== -1) {
+        this.chat[chatIndex].data[index] = chat
+        this.recordState()
+      }
+    },
+
+    updateChatSomeByUuid(uuid: number, index: number, chat: Partial<Chat.Chat>) {
+      if (!uuid || uuid === 0) {
+        if (this.chat.length) {
+          this.chat[0].data[index] = { ...this.chat[0].data[index], ...chat }
+          this.recordState()
+        }
+        return
+      }
+
+      const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+      if (chatIndex !== -1) {
+        this.chat[chatIndex].data[index] = { ...this.chat[chatIndex].data[index], ...chat }
+        this.recordState()
+      }
+    },
+
+    deleteChatByUuid(uuid: number, index: number) {
+      if (!uuid || uuid === 0) {
+        if (this.chat.length) {
+          this.chat[0].data.splice(index, 1)
+          this.recordState()
+        }
+        return
+      }
+
+      const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+      if (chatIndex !== -1) {
+        this.chat[chatIndex].data.splice(index, 1)
+        this.recordState()
+      }
+    },
+
+    clearChatByUuid(uuid: number) {
+      if (!uuid || uuid === 0) {
+        if (this.chat.length) {
+          this.chat[0].data = []
+          this.recordState()
+        }
+        return
+      }
+
+      const index = this.chat.findIndex(item => item.uuid === uuid)
+      if (index !== -1) {
+        this.chat[index].data = []
+        this.recordState()
+      }
+    },
+
+    async reloadRoute(uuid?: number) {
+      this.recordState()
+      await router.push({ name: 'Chat', params: { uuid } })
+    },
+
+    recordState() {
+      setLocalState(this.$state)
+    },
+  },
+})

+ 5 - 1
src/store/modules/index.ts

@@ -1 +1,5 @@
-export * from './app'
+export * from './app'
+export * from './auth'
+export * from './chat'
+export * from './prompt'
+export * from './user'

+ 18 - 0
src/store/modules/prompt/helper.ts

@@ -0,0 +1,18 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'promptStore'
+
+export type PromptList = []
+
+export interface PromptStore {
+  promptList: PromptList
+}
+
+export function getLocalPromptList(): PromptStore {
+  const promptStore: PromptStore | undefined = ss.get(LOCAL_NAME)
+  return promptStore ?? { promptList: [] }
+}
+
+export function setLocalPromptList(promptStore: PromptStore): void {
+  ss.set(LOCAL_NAME, promptStore)
+}

+ 17 - 0
src/store/modules/prompt/index.ts

@@ -0,0 +1,17 @@
+import { defineStore } from 'pinia'
+import type { PromptStore } from './helper'
+import { getLocalPromptList, setLocalPromptList } from './helper'
+
+export const usePromptStore = defineStore('prompt-store', {
+  state: (): PromptStore => getLocalPromptList(),
+
+  actions: {
+    updatePromptList(promptList: []) {
+      this.$patch({ promptList })
+      setLocalPromptList({ promptList })
+    },
+    getPromptList() {
+      return this.$state
+    },
+  },
+})

+ 32 - 0
src/store/modules/user/helper.ts

@@ -0,0 +1,32 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'userStorage'
+
+export interface UserInfo {
+  avatar: string
+  name: string
+  description: string
+}
+
+export interface UserState {
+  userInfo: UserInfo
+}
+
+export function defaultSetting(): UserState {
+  return {
+    userInfo: {
+      avatar: 'https://raw.githubusercontent.com/Chanzhaoyu/chatgpt-web/main/src/assets/avatar.jpg',
+      name: 'ChenZhaoYu',
+      description: 'Star on <a href="https://github.com/Chanzhaoyu/chatgpt-bot" class="text-blue-500" target="_blank" >Github</a>',
+    },
+  }
+}
+
+export function getLocalState(): UserState {
+  const localSetting: UserState | undefined = ss.get(LOCAL_NAME)
+  return { ...defaultSetting(), ...localSetting }
+}
+
+export function setLocalState(setting: UserState): void {
+  ss.set(LOCAL_NAME, setting)
+}

+ 22 - 0
src/store/modules/user/index.ts

@@ -0,0 +1,22 @@
+import { defineStore } from 'pinia'
+import type { UserInfo, UserState } from './helper'
+import { defaultSetting, getLocalState, setLocalState } from './helper'
+
+export const useUserStore = defineStore('user-store', {
+  state: (): UserState => getLocalState(),
+  actions: {
+    updateUserInfo(userInfo: Partial<UserInfo>) {
+      this.userInfo = { ...this.userInfo, ...userInfo }
+      this.recordState()
+    },
+
+    resetUserInfo() {
+      this.userInfo = { ...defaultSetting().userInfo }
+      this.recordState()
+    },
+
+    recordState() {
+      setLocalState(this.$state)
+    },
+  },
+})

+ 0 - 80
src/style.css

@@ -1,80 +0,0 @@
-:root {
-  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-  line-height: 1.5;
-  font-weight: 400;
-
-  color-scheme: light dark;
-  color: rgba(255, 255, 255, 0.87);
-  background-color: #242424;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  -webkit-text-size-adjust: 100%;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
-}
-
-body {
-  margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}
-
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
-}
-
-#app {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
-}

+ 53 - 0
src/typings/chat.d.ts

@@ -0,0 +1,53 @@
+declare namespace Chat {
+
+	interface Chat {
+		dateTime: string
+		text: string
+		inversion?: boolean
+		error?: boolean
+		loading?: boolean
+		conversationOptions?: ConversationRequest | null
+		requestOptions: { prompt: string; options?: ConversationRequest | null }
+	}
+
+	interface History {
+		title: string
+		isEdit: boolean
+		uuid: number
+	}
+
+	interface ChatState {
+		active: number | null
+		usingContext: boolean;
+		history: History[]
+		chat: { uuid: number; data: Chat[] }[]
+	}
+
+	interface ConversationRequest {
+		conversationId?: string
+		parentMessageId?: string
+	}
+
+	interface ConversationResponse {
+		conversationId: string
+		detail: {
+			choices: { finish_reason: string; index: number; logprobs: any; text: string }[]
+			created: number
+			id: string
+			model: string
+			object: string
+			usage: { completion_tokens: number; prompt_tokens: number; total_tokens: number }
+		}
+		id: string
+		parentMessageId: string
+		role: string
+		text: string
+	}
+
+
+	interface ChatResponse {
+		text: string
+	}
+
+
+}

+ 6 - 0
src/typings/global.d.ts

@@ -0,0 +1,6 @@
+interface Window {
+  $loadingBar?: import('naive-ui').LoadingBarProviderInst;
+  $dialog?: import('naive-ui').DialogProviderInst;
+  $message?: import('naive-ui').MessageProviderInst;
+  $notification?: import('naive-ui').NotificationProviderInst;
+}

+ 44 - 0
src/utils/format/index.ts

@@ -0,0 +1,44 @@
+/**
+ * 转义 HTML 字符
+ * @param source
+ */
+export function encodeHTML(source: string) {
+  return source
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+/**
+ * 判断是否为代码块
+ * @param text
+ */
+export function includeCode(text: string | null | undefined) {
+  const regexp = /^(?:\s{4}|\t).+/gm
+  return !!(text?.includes(' = ') || text?.match(regexp))
+}
+
+/**
+ * 复制文本
+ * @param options
+ */
+export function copyText(options: { text: string; origin?: boolean }) {
+  const props = { origin: true, ...options }
+
+  let input: HTMLInputElement | HTMLTextAreaElement
+
+  if (props.origin)
+    input = document.createElement('textarea')
+  else
+    input = document.createElement('input')
+
+  input.setAttribute('readonly', 'readonly')
+  input.value = props.text
+  document.body.appendChild(input)
+  input.select()
+  if (document.execCommand('copy'))
+    document.execCommand('copy')
+  document.body.removeChild(input)
+}

+ 18 - 0
src/utils/functions/debounce.ts

@@ -0,0 +1,18 @@
+type CallbackFunc<T extends unknown[]> = (...args: T) => void
+
+export function debounce<T extends unknown[]>(
+  func: CallbackFunc<T>,
+  wait: number,
+): (...args: T) => void {
+  let timeoutId: ReturnType<typeof setTimeout> | undefined
+
+  return (...args: T) => {
+    const later = () => {
+      clearTimeout(timeoutId)
+      func(...args)
+    }
+
+    clearTimeout(timeoutId)
+    timeoutId = setTimeout(later, wait)
+  }
+}

+ 7 - 0
src/utils/functions/index.ts

@@ -0,0 +1,7 @@
+export function getCurrentDate() {
+  const date = new Date()
+  const day = date.getDate()
+  const month = date.getMonth() + 1
+  const year = date.getFullYear()
+  return `${year}-${month}-${day}`
+}

+ 55 - 0
src/utils/is/index.ts

@@ -0,0 +1,55 @@
+export function isNumber<T extends number>(value: T | unknown): value is number {
+  return Object.prototype.toString.call(value) === '[object Number]'
+}
+
+export function isString<T extends string>(value: T | unknown): value is string {
+  return Object.prototype.toString.call(value) === '[object String]'
+}
+
+export function isBoolean<T extends boolean>(value: T | unknown): value is boolean {
+  return Object.prototype.toString.call(value) === '[object Boolean]'
+}
+
+export function isNull<T extends null>(value: T | unknown): value is null {
+  return Object.prototype.toString.call(value) === '[object Null]'
+}
+
+export function isUndefined<T extends undefined>(value: T | unknown): value is undefined {
+  return Object.prototype.toString.call(value) === '[object Undefined]'
+}
+
+export function isObject<T extends object>(value: T | unknown): value is object {
+  return Object.prototype.toString.call(value) === '[object Object]'
+}
+
+export function isArray<T extends any[]>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Array]'
+}
+
+export function isFunction<T extends (...args: any[]) => any | void | never>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Function]'
+}
+
+export function isDate<T extends Date>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Date]'
+}
+
+export function isRegExp<T extends RegExp>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object RegExp]'
+}
+
+export function isPromise<T extends Promise<any>>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Promise]'
+}
+
+export function isSet<T extends Set<any>>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Set]'
+}
+
+export function isMap<T extends Map<any, any>>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object Map]'
+}
+
+export function isFile<T extends File>(value: T | unknown): value is T {
+  return Object.prototype.toString.call(value) === '[object File]'
+}

+ 32 - 0
src/utils/request/axios.ts

@@ -0,0 +1,32 @@
+import axios, { type AxiosResponse } from 'axios'
+import { useAuthStore } from '@/store'
+
+const service = axios.create({
+	baseURL: import.meta.env.VITE_GLOB_API_URL,
+})
+
+service.interceptors.request.use(
+	(config) => {
+		const token = useAuthStore().token
+		if (token)
+			config.headers.Authorization = `Bearer ${token}`
+		return config
+	},
+	(error) => {
+		return Promise.reject(error.response)
+	},
+)
+
+service.interceptors.response.use(
+	(response: AxiosResponse): AxiosResponse => {
+		if (response.status === 200)
+			return response
+
+		throw new Error(response.status.toString())
+	},
+	(error) => {
+		return Promise.reject(error)
+	},
+)
+
+export default service

+ 84 - 0
src/utils/request/index.ts

@@ -0,0 +1,84 @@
+import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios'
+import request from './axios'
+import { useAuthStore } from '@/store'
+
+export interface HttpOption {
+	url: string
+	data?: any
+	method?: string
+	headers?: any
+	onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
+	signal?: GenericAbortSignal
+	beforeRequest?: () => void
+	afterRequest?: () => void
+}
+
+export interface Response<T = any> {
+	data: T
+	message: string | null
+	status: number
+}
+
+function http<T = any>(
+	{ url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
+) {
+	const successHandler = (res: AxiosResponse<Response<T>>) => {
+		const authStore = useAuthStore()
+
+		if (res.data.status ===  0)
+			return res.data
+
+		if (res.data.status === 401) {
+			authStore.removeToken()
+			window.location.reload()
+		}
+
+		return Promise.reject(res.data)
+	}
+
+	const failHandler = (error: Response<Error>) => {
+		afterRequest?.()
+		throw new Error(error?.message || 'Error')
+	}
+
+	beforeRequest?.()
+
+	method = method || 'GET'
+
+	const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
+
+	return method === 'GET'
+		? request.get(url, { params, signal, onDownloadProgress }).then(successHandler, failHandler)
+		: request.post(url, params, { headers, signal, onDownloadProgress }).then(successHandler, failHandler)
+}
+
+export function get<T = any>(
+	{ url, data, method = 'GET', onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
+): Promise<Response<T>> {
+	return http<T>({
+		url,
+		method,
+		data,
+		onDownloadProgress,
+		signal,
+		beforeRequest,
+		afterRequest,
+	})
+}
+
+export function post<T = any>(
+	{ url, data, method = 'POST', headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
+): Promise<Response<T>> {
+	return http<T>({
+		url,
+		method,
+		data,
+		headers,
+		onDownloadProgress,
+		signal,
+		beforeRequest,
+		afterRequest,
+	})
+}
+
+export default post

+ 81 - 0
src/views/chat/components/Header/index.vue

@@ -0,0 +1,81 @@
+<script lang="ts" setup>
+import { computed, nextTick } from 'vue'
+import { HoverButton, SvgIcon } from '@/components/common'
+import {useBasicLayout} from "@/hooks/useBasicLayout";
+import { useAppStore,useChatStore } from '@/store'
+interface Props {
+  usingContext: boolean
+}
+interface Emit {
+  (ev: 'export'): void
+      (ev: 'toggleUsingContext'): void
+}
+defineProps<Props>()
+const emit = defineEmits<Emit>()
+const appStore = useAppStore()
+const chatStore = useChatStore()
+
+const collapsed = computed(() => appStore.siderCollapsed)
+const currentChatHistory = computed(() => chatStore.getChatHistoryByCurrentActive)
+function handleUpdateCollapsed() {
+  appStore.setSiderCollapsed(!collapsed.value)
+}
+
+
+function onScrollToTop() {
+  const scrollRef = document.querySelector('#scrollRef')
+  if (scrollRef)
+    nextTick(() => scrollRef.scrollTop = 0)
+}
+
+function handleExport() {
+  emit('export')
+}
+
+function toggleUsingContext() {
+  emit('toggleUsingContext')
+}
+
+
+</script>
+
+<template>
+  <header
+      class="sticky top-0 left-0 right-0 z-30 border-b dark:border-neutral-800 bg-white/80 dark:bg-black/20 backdrop-blur"
+  >
+    <div class="relative flex items-center justify-between min-w-0 overflow-hidden h-14">
+      <div class="flex items-center">
+        <button
+            class="flex items-center justify-center w-11 h-11"
+            @click="handleUpdateCollapsed"
+        >
+          <SvgIcon v-if="collapsed" class="text-2xl" icon="ri:align-justify" />
+          <SvgIcon v-else class="text-2xl" icon="ri:align-right" />
+        </button>
+      </div>
+      <h1
+          class="flex-1 px-4 pr-6 overflow-hidden cursor-pointer select-none text-ellipsis whitespace-nowrap"
+          @dblclick="onScrollToTop"
+      >
+        {{ currentChatHistory?.title ?? '' }}
+      </h1>
+      <div class="flex items-center space-x-2">
+        <HoverButton @click="toggleUsingContext">
+          <span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
+            <SvgIcon icon="ri:chat-history-line" />
+          </span>
+        </HoverButton>
+        <HoverButton @click="handleExport">
+          <span class="text-xl text-[#4f555e] dark:text-white">
+            <SvgIcon icon="ri:download-2-line" />
+          </span>
+        </HoverButton>
+      </div>
+    </div>
+  </header>
+</template>
+
+
+<style scoped>
+
+</style>

+ 28 - 0
src/views/chat/components/Message/Avatar.vue

@@ -0,0 +1,28 @@
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { NAvatar } from 'naive-ui'
+import { useUserStore } from '@/store'
+import { isString } from '@/utils/is'
+import defaultAvatar from '@/assets/avatar.jpg'
+
+interface Props {
+  image?: boolean
+}
+defineProps<Props>()
+
+const userStore = useUserStore()
+
+const avatar = computed(() => userStore.userInfo.avatar)
+</script>
+
+<template>
+  <template v-if="image">
+    <NAvatar v-if="isString(avatar) && avatar.length > 0" :src="avatar" :fallback-src="defaultAvatar" />
+    <NAvatar v-else round :src="defaultAvatar" />
+  </template>
+  <span v-else class="text-[28px] dark:text-white">
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" width="1em" height="1em">
+      <path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z" fill="currentColor" />
+    </svg>
+  </span>
+</template>

+ 85 - 0
src/views/chat/components/Message/Text.vue

@@ -0,0 +1,85 @@
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import MarkdownIt from 'markdown-it'
+import mdKatex from '@traptitech/markdown-it-katex'
+import mila from 'markdown-it-link-attributes'
+import hljs from 'highlight.js'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { t } from '@/locales'
+
+interface Props {
+  inversion?: boolean
+  error?: boolean
+  text?: string
+  loading?: boolean
+  asRawText?: boolean
+}
+
+const props = defineProps<Props>()
+
+const { isMobile } = useBasicLayout()
+
+const textRef = ref<HTMLElement>()
+
+const mdi = new MarkdownIt({
+  linkify: true,
+  highlight(code, language) {
+    const validLang = !!(language && hljs.getLanguage(language))
+    if (validLang) {
+      const lang = language ?? ''
+      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
+    }
+    return highlightBlock(hljs.highlightAuto(code).value, '')
+  },
+})
+
+mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } })
+mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' })
+
+const wrapClass = computed(() => {
+  return [
+    'text-wrap',
+    'min-w-[20px]',
+    'rounded-md',
+    isMobile.value ? 'p-2' : 'px-3 py-2',
+    props.inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]',
+    props.inversion ? 'dark:bg-[#a1dc95]' : 'dark:bg-[#1e1e20]',
+    props.inversion ? 'message-request' : 'message-reply',
+    { 'text-red-500': props.error },
+  ]
+})
+
+const text = computed(() => {
+  const value = props.text ?? ''
+  if (!props.asRawText)
+    return mdi.render(value)
+  return value
+})
+
+function highlightBlock(str: string, lang?: string) {
+  return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${t('chat.copyCode')}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
+}
+
+defineExpose({ textRef })
+</script>
+
+<template>
+  <div class="text-black" :class="wrapClass">
+    <template v-if="loading">
+      <span class="dark:text-white w-[4px] h-[20px] block animate-blink" />
+    </template>
+    <template v-else>
+      <div ref="textRef" class="leading-relaxed break-words">
+        <div v-if="!inversion">
+          <div v-if="!asRawText" class="markdown-body" v-html="text" />
+          <div v-else class="whitespace-pre-wrap" v-text="text" />
+        </div>
+        <div v-else class="whitespace-pre-wrap" v-text="text" />
+      </div>
+    </template>
+  </div>
+</template>
+
+<style lang="less">
+@import url(./style.less);
+</style>

+ 133 - 0
src/views/chat/components/Message/index.vue

@@ -0,0 +1,133 @@
+<script setup lang='ts'>
+import { computed, ref } from 'vue'
+import { NDropdown } from 'naive-ui'
+import AvatarComponent from './Avatar.vue'
+import TextComponent from './Text.vue'
+import { SvgIcon } from '@/components/common'
+import { copyText } from '@/utils/format'
+import { useIconRender } from '@/hooks/useIconRender'
+import { t } from '@/locales'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+
+interface Props {
+  dateTime?: string
+  text?: string
+  inversion?: boolean
+  error?: boolean
+  loading?: boolean
+}
+
+interface Emit {
+  (ev: 'regenerate'): void
+  (ev: 'delete'): void
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<Emit>()
+
+const { isMobile } = useBasicLayout()
+
+const { iconRender } = useIconRender()
+
+const textRef = ref<HTMLElement>()
+
+const asRawText = ref(props.inversion)
+
+const messageRef = ref<HTMLElement>()
+
+const options = computed(() => {
+  const common = [
+    {
+      label: t('chat.copy'),
+      key: 'copyText',
+      icon: iconRender({ icon: 'ri:file-copy-2-line' }),
+    },
+    {
+      label: t('common.delete'),
+      key: 'delete',
+      icon: iconRender({ icon: 'ri:delete-bin-line' }),
+    },
+  ]
+
+  if (!props.inversion) {
+    common.unshift({
+      label: asRawText.value ? t('chat.preview') : t('chat.showRawText'),
+      key: 'toggleRenderType',
+      icon: iconRender({ icon: asRawText.value ? 'ic:outline-code-off' : 'ic:outline-code' }),
+    })
+  }
+
+  return common
+})
+
+function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') {
+  switch (key) {
+    case 'copyText':
+      copyText({ text: props.text ?? '' })
+      return
+    case 'toggleRenderType':
+      asRawText.value = !asRawText.value
+      return
+    case 'delete':
+      emit('delete')
+  }
+}
+
+function handleRegenerate() {
+  messageRef.value?.scrollIntoView()
+  emit('regenerate')
+}
+</script>
+
+<template>
+  <div
+    ref="messageRef"
+    class="flex w-full mb-6 overflow-hidden"
+    :class="[{ 'flex-row-reverse': inversion }]"
+  >
+    <div
+      class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8"
+      :class="[inversion ? 'ml-2' : 'mr-2']"
+    >
+      <AvatarComponent :image="inversion" />
+    </div>
+    <div class="overflow-hidden text-sm " :class="[inversion ? 'items-end' : 'items-start']">
+      <p class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']">
+        {{ dateTime }}
+      </p>
+      <div
+        class="flex items-end gap-1 mt-2"
+        :class="[inversion ? 'flex-row-reverse' : 'flex-row']"
+      >
+        <TextComponent
+          ref="textRef"
+          :inversion="inversion"
+          :error="error"
+          :text="text"
+          :loading="loading"
+          :as-raw-text="asRawText"
+        />
+        <div class="flex flex-col">
+          <button
+            v-if="!inversion"
+            class="mb-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
+            @click="handleRegenerate"
+          >
+            <SvgIcon icon="ri:restart-line" />
+          </button>
+          <NDropdown
+            :trigger="isMobile ? 'click' : 'hover'"
+            :placement="!inversion ? 'right' : 'left'"
+            :options="options"
+            @select="handleSelect"
+          >
+            <button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200">
+              <SvgIcon icon="ri:more-2-fill" />
+            </button>
+          </NDropdown>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 75 - 0
src/views/chat/components/Message/style.less

@@ -0,0 +1,75 @@
+.markdown-body {
+	background-color: transparent;
+	font-size: 14px;
+
+	p {
+		white-space: pre-wrap;
+	}
+
+	ol {
+		list-style-type: decimal;
+	}
+
+	ul {
+		list-style-type: disc;
+	}
+
+	pre code,
+	pre tt {
+		line-height: 1.65;
+	}
+
+	.highlight pre,
+	pre {
+		background-color: #fff;
+	}
+
+	code.hljs {
+		padding: 0;
+	}
+
+	.code-block {
+		&-wrapper {
+			position: relative;
+			padding-top: 24px;
+		}
+
+		&-header {
+			position: absolute;
+			top: 5px;
+			right: 0;
+			width: 100%;
+			padding: 0 1rem;
+			display: flex;
+			justify-content: flex-end;
+			align-items: center;
+			color: #b3b3b3;
+
+			&__copy {
+				cursor: pointer;
+				margin-left: 0.5rem;
+				user-select: none;
+
+				&:hover {
+					color: #65a665;
+				}
+			}
+		}
+	}
+
+}
+
+html.dark {
+
+	.message-reply {
+		.whitespace-pre-wrap {
+			white-space: pre-wrap;
+			color: var(--n-text-color);
+		}
+	}
+
+	.highlight pre,
+	pre {
+		background-color: #282c34;
+	}
+}

+ 3 - 0
src/views/chat/components/index.ts

@@ -0,0 +1,3 @@
+import Message from './Message/index.vue'
+
+export { Message }

+ 28 - 0
src/views/chat/hooks/useChat.ts

@@ -0,0 +1,28 @@
+import { useChatStore } from '@/store'
+
+export function useChat() {
+  const chatStore = useChatStore()
+
+  const getChatByUuidAndIndex = (uuid: number, index: number) => {
+    return chatStore.getChatByUuidAndIndex(uuid, index)
+  }
+
+  const addChat = (uuid: number, chat: Chat.Chat) => {
+    chatStore.addChatByUuid(uuid, chat)
+  }
+
+  const updateChat = (uuid: number, index: number, chat: Chat.Chat) => {
+    chatStore.updateChatByUuid(uuid, index, chat)
+  }
+
+  const updateChatSome = (uuid: number, index: number, chat: Partial<Chat.Chat>) => {
+    chatStore.updateChatSomeByUuid(uuid, index, chat)
+  }
+
+  return {
+    addChat,
+    updateChat,
+    updateChatSome,
+    getChatByUuidAndIndex,
+  }
+}

+ 24 - 0
src/views/chat/hooks/useCopyCode.ts

@@ -0,0 +1,24 @@
+import { onMounted, onUpdated } from 'vue'
+import { copyText } from '@/utils/format'
+
+export function useCopyCode() {
+  function copyCodeBlock() {
+    const codeBlockWrapper = document.querySelectorAll('.code-block-wrapper')
+    codeBlockWrapper.forEach((wrapper) => {
+      const copyBtn = wrapper.querySelector('.code-block-header__copy')
+      const codeBlock = wrapper.querySelector('.code-block-body')
+      if (copyBtn && codeBlock) {
+        copyBtn.addEventListener('click', () => {
+          if (navigator.clipboard?.writeText)
+            navigator.clipboard.writeText(codeBlock.textContent ?? '')
+          else
+            copyText({ text: codeBlock.textContent ?? '', origin: true })
+        })
+      }
+    })
+  }
+
+  onMounted(() => copyCodeBlock())
+
+  onUpdated(() => copyCodeBlock())
+}

+ 44 - 0
src/views/chat/hooks/useScroll.ts

@@ -0,0 +1,44 @@
+import type { Ref } from 'vue'
+import { nextTick, ref } from 'vue'
+
+type ScrollElement = HTMLDivElement | null
+
+interface ScrollReturn {
+  scrollRef: Ref<ScrollElement>
+  scrollToBottom: () => Promise<void>
+  scrollToTop: () => Promise<void>
+  scrollToBottomIfAtBottom: () => Promise<void>
+}
+
+export function useScroll(): ScrollReturn {
+  const scrollRef = ref<ScrollElement>(null)
+
+  const scrollToBottom = async () => {
+    await nextTick()
+    if (scrollRef.value)
+      scrollRef.value.scrollTop = scrollRef.value.scrollHeight
+  }
+
+  const scrollToTop = async () => {
+    await nextTick()
+    if (scrollRef.value)
+      scrollRef.value.scrollTop = 0
+  }
+
+  const scrollToBottomIfAtBottom = async () => {
+    await nextTick()
+    if (scrollRef.value) {
+      const threshold = 100 // 阈值,表示滚动条到底部的距离阈值
+      const distanceToBottom = scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight
+      if (distanceToBottom <= threshold)
+        scrollRef.value.scrollTop = scrollRef.value.scrollHeight
+    }
+  }
+
+  return {
+    scrollRef,
+    scrollToBottom,
+    scrollToTop,
+    scrollToBottomIfAtBottom,
+  }
+}

+ 23 - 0
src/views/chat/hooks/useUsingContext.ts

@@ -0,0 +1,23 @@
+import { computed } from 'vue'
+import { useMessage } from 'naive-ui'
+import { t } from '@/locales'
+import { useChatStore } from '@/store'
+
+export function useUsingContext() {
+  const ms = useMessage()
+  const chatStore = useChatStore()
+  const usingContext = computed<boolean>(() => chatStore.usingContext)
+
+  function toggleUsingContext() {
+    chatStore.setUsingContext(!usingContext.value)
+    if (usingContext.value)
+      ms.success(t('chat.turnOnContext'))
+    else
+      ms.warning(t('chat.turnOffContext'))
+  }
+
+  return {
+    usingContext,
+    toggleUsingContext,
+  }
+}

+ 548 - 0
src/views/chat/index.vue

@@ -0,0 +1,548 @@
+<script setup lang='ts'>
+import type { Ref } from 'vue'
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { storeToRefs } from 'pinia'
+import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui'
+import html2canvas from 'html2canvas'
+import { Message } from './components'
+import { useScroll } from './hooks/useScroll'
+import { useChat } from './hooks/useChat'
+import { useCopyCode } from './hooks/useCopyCode'
+import { useUsingContext } from './hooks/useUsingContext'
+import HeaderComponent from './components/Header/index.vue'
+import { HoverButton, SvgIcon } from '@/components/common'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { useChatStore, usePromptStore } from '@/store'
+// import { fetchChatAPIProcess } from '@/api'
+import { t } from '@/locales'
+import {fetchChatAPIProcess} from "@/api";
+
+let controller = new AbortController()
+
+const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true'
+
+const route = useRoute()
+const dialog = useDialog()
+const ms = useMessage()
+
+const chatStore = useChatStore()
+
+useCopyCode()
+
+const { isMobile } = useBasicLayout()
+const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
+const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
+const { usingContext, toggleUsingContext } = useUsingContext()
+
+const { uuid } = route.params as { uuid: string }
+
+const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
+const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !item.error)))
+
+const prompt = ref<string>('')
+const loading = ref<boolean>(false)
+const inputRef = ref<Ref | null>(null)
+
+// 添加PromptStore
+const promptStore = usePromptStore()
+
+// 使用storeToRefs,保证store修改后,联想部分能够重新渲染
+const { promptList: promptTemplate } = storeToRefs<any>(promptStore)
+
+// 未知原因刷新页面,loading 状态不会重置,手动重置
+dataSources.value.forEach((item, index) => {
+  if (item.loading)
+    updateChatSome(+uuid, index, { loading: false })
+})
+
+function handleSubmit() {
+  onConversation()
+}
+
+async function onConversation() {
+  let message = prompt.value
+
+  if (loading.value)
+    return
+
+  if (!message || message.trim() === '')
+    return
+
+  controller = new AbortController()
+
+  addChat(
+      +uuid,
+      {
+        dateTime: new Date().toLocaleString(),
+        text: message,
+        inversion: true,
+        error: false,
+        conversationOptions: null,
+        requestOptions: { prompt: message, options: null },
+      },
+  )
+  scrollToBottom()
+
+  loading.value = true
+  prompt.value = ''
+
+  let options: Chat.ConversationRequest = {}
+  const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions
+
+  if (lastContext && usingContext.value)
+    options = { ...lastContext }
+
+  addChat(
+      +uuid,
+      {
+        dateTime: new Date().toLocaleString(),
+        text: '',
+        loading: true,
+        inversion: false,
+        error: false,
+        conversationOptions: null,
+        requestOptions: { prompt: message, options: { ...options } },
+      },
+  )
+  scrollToBottom()
+
+  try {
+    let lastText = ''
+    const fetchChatAPIOnce = async () => {
+
+			var data = await  fetchChatAPIProcess<Chat.ChatResponse>(
+				{
+					prompt: message,
+					signal: controller.signal
+				}
+			)
+
+			updateChat(
+				+uuid,
+				dataSources.value.length - 1,
+				{
+					dateTime: new Date().toLocaleString(),
+					text: lastText + data.data.text ?? '',
+					inversion: false,
+					error: false,
+					loading: false,
+					conversationOptions: { conversationId: "data.conversationId", parentMessageId: "data.id" },
+					requestOptions: { prompt: message, options: { ...options } },
+				},
+			)
+
+
+			lastText = data.data.text
+			message = ''
+
+			scrollToBottomIfAtBottom()
+    }
+
+    await fetchChatAPIOnce()
+  }
+  catch (error: any) {
+    const errorMessage = error?.message ?? t('common.wrong')
+
+    if (error.message === 'canceled') {
+      updateChatSome(
+          +uuid,
+          dataSources.value.length - 1,
+          {
+            loading: false,
+          },
+      )
+      scrollToBottomIfAtBottom()
+      return
+    }
+
+    const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1)
+
+    if (currentChat?.text && currentChat.text !== '') {
+      updateChatSome(
+          +uuid,
+          dataSources.value.length - 1,
+          {
+            text: `${currentChat.text}\n[${errorMessage}]`,
+            error: false,
+            loading: false,
+          },
+      )
+      return
+    }
+
+    updateChat(
+        +uuid,
+        dataSources.value.length - 1,
+        {
+          dateTime: new Date().toLocaleString(),
+          text: errorMessage,
+          inversion: false,
+          error: true,
+          loading: false,
+          conversationOptions: null,
+          requestOptions: { prompt: message, options: { ...options } },
+        },
+    )
+    scrollToBottomIfAtBottom()
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+async function onRegenerate(index: number) {
+  if (loading.value)
+    return
+
+  controller = new AbortController()
+
+  const { requestOptions } = dataSources.value[index]
+
+  let message = requestOptions?.prompt ?? ''
+
+  let options: Chat.ConversationRequest = {}
+
+  if (requestOptions.options)
+    options = { ...requestOptions.options }
+
+  loading.value = true
+
+  updateChat(
+      +uuid,
+      index,
+      {
+        dateTime: new Date().toLocaleString(),
+        text: '',
+        inversion: false,
+        error: false,
+        loading: true,
+        conversationOptions: null,
+        requestOptions: { prompt: message, ...options },
+      },
+  )
+
+  try {
+    let lastText = ''
+    const fetchChatAPIOnce = async () => {
+      // await fetchChatAPIProcess<Chat.ConversationResponse>({
+      //   prompt: message,
+      //   options,
+      //   signal: controller.signal,
+      //   onDownloadProgress: ({ event }) => {
+      //     const xhr = event.target
+      //     const { responseText } = xhr
+      //     // Always process the final line
+      //     const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2)
+      //     let chunk = responseText
+      //     if (lastIndex !== -1)
+      //       chunk = responseText.substring(lastIndex)
+      //     try {
+      //       const data = JSON.parse(chunk)
+      //       updateChat(
+      //           +uuid,
+      //           index,
+      //           {
+      //             dateTime: new Date().toLocaleString(),
+      //             text: lastText + data.text ?? '',
+      //             inversion: false,
+      //             error: false,
+      //             loading: false,
+      //             conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
+      //             requestOptions: { prompt: message, ...options },
+      //           },
+      //       )
+      //
+      //       if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
+      //         options.parentMessageId = data.id
+      //         lastText = data.text
+      //         message = ''
+      //         return fetchChatAPIOnce()
+      //       }
+      //     }
+      //     catch (error) {
+      //       //
+      //     }
+      //   },
+      // })
+    }
+    await fetchChatAPIOnce()
+  }
+  catch (error: any) {
+    if (error.message === 'canceled') {
+      updateChatSome(
+          +uuid,
+          index,
+          {
+            loading: false,
+          },
+      )
+      return
+    }
+
+    const errorMessage = error?.message ?? t('common.wrong')
+
+    updateChat(
+        +uuid,
+        index,
+        {
+          dateTime: new Date().toLocaleString(),
+          text: errorMessage,
+          inversion: false,
+          error: true,
+          loading: false,
+          conversationOptions: null,
+          requestOptions: { prompt: message, ...options },
+        },
+    )
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function handleExport() {
+  if (loading.value)
+    return
+
+  const d = dialog.warning({
+    title: t('chat.exportImage'),
+    content: t('chat.exportImageConfirm'),
+    positiveText: t('common.yes'),
+    negativeText: t('common.no'),
+    onPositiveClick: async () => {
+      try {
+        d.loading = true
+        const ele = document.getElementById('image-wrapper')
+        const canvas = await html2canvas(ele as HTMLDivElement, {
+          useCORS: true,
+        })
+        const imgUrl = canvas.toDataURL('image/png')
+        const tempLink = document.createElement('a')
+        tempLink.style.display = 'none'
+        tempLink.href = imgUrl
+        tempLink.setAttribute('download', 'chat-shot.png')
+        if (typeof tempLink.download === 'undefined')
+          tempLink.setAttribute('target', '_blank')
+
+        document.body.appendChild(tempLink)
+        tempLink.click()
+        document.body.removeChild(tempLink)
+        window.URL.revokeObjectURL(imgUrl)
+        d.loading = false
+        ms.success(t('chat.exportSuccess'))
+        Promise.resolve()
+      }
+      catch (error: any) {
+        ms.error(t('chat.exportFailed'))
+      }
+      finally {
+        d.loading = false
+      }
+    },
+  })
+}
+
+function handleDelete(index: number) {
+  if (loading.value)
+    return
+
+  dialog.warning({
+    title: t('chat.deleteMessage'),
+    content: t('chat.deleteMessageConfirm'),
+    positiveText: t('common.yes'),
+    negativeText: t('common.no'),
+    onPositiveClick: () => {
+      chatStore.deleteChatByUuid(+uuid, index)
+    },
+  })
+}
+
+function handleClear() {
+  if (loading.value)
+    return
+
+  dialog.warning({
+    title: t('chat.clearChat'),
+    content: t('chat.clearChatConfirm'),
+    positiveText: t('common.yes'),
+    negativeText: t('common.no'),
+    onPositiveClick: () => {
+      chatStore.clearChatByUuid(+uuid)
+    },
+  })
+}
+
+function handleEnter(event: KeyboardEvent) {
+  if (!isMobile.value) {
+    if (event.key === 'Enter' && !event.shiftKey) {
+      event.preventDefault()
+      handleSubmit()
+    }
+  }
+  else {
+    if (event.key === 'Enter' && event.ctrlKey) {
+      event.preventDefault()
+      handleSubmit()
+    }
+  }
+}
+
+function handleStop() {
+  if (loading.value) {
+    controller.abort()
+    loading.value = false
+  }
+}
+
+// 可优化部分
+// 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果)
+// 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现
+const searchOptions = computed(() => {
+  if (prompt.value.startsWith('/')) {
+    return promptTemplate.value.filter((item: { key: string }) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase())).map((obj: { value: any }) => {
+      return {
+        label: obj.value,
+        value: obj.value,
+      }
+    })
+  }
+  else {
+    return []
+  }
+})
+
+// value反渲染key
+const renderOption = (option: { label: string }) => {
+  for (const i of promptTemplate.value) {
+    if (i.value === option.label)
+      return [i.key]
+  }
+  return []
+}
+
+const placeholder = computed(() => {
+  if (isMobile.value)
+    return t('chat.placeholderMobile')
+  return t('chat.placeholder')
+})
+
+const buttonDisabled = computed(() => {
+  return loading.value || !prompt.value || prompt.value.trim() === ''
+})
+
+const footerClass = computed(() => {
+  let classes = ['p-4']
+  if (isMobile.value)
+    classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden']
+  return classes
+})
+
+onMounted(() => {
+  scrollToBottom()
+  if (inputRef.value && !isMobile.value)
+    inputRef.value?.focus()
+})
+
+onUnmounted(() => {
+  if (loading.value)
+    controller.abort()
+})
+</script>
+
+<template>
+  <div class="flex flex-col w-full h-full">
+    <HeaderComponent
+        v-if="isMobile"
+        :using-context="usingContext"
+        @export="handleExport"
+        @toggle-using-context="toggleUsingContext"
+    />
+    <main class="flex-1 overflow-hidden">
+      <div
+          id="scrollRef"
+          ref="scrollRef"
+          class="h-full overflow-hidden overflow-y-auto"
+      >
+        <div
+            id="image-wrapper"
+            class="w-full max-w-screen-xl m-auto dark:bg-[#101014]"
+            :class="[isMobile ? 'p-2' : 'p-4']"
+        >
+          <template v-if="!dataSources.length">
+            <div class="flex items-center justify-center mt-4 text-center text-neutral-300">
+              <SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
+              <span>Aha~</span>
+            </div>
+          </template>
+          <template v-else>
+            <div>
+              <Message
+                  v-for="(item, index) of dataSources"
+                  :key="index"
+                  :date-time="item.dateTime"
+                  :text="item.text"
+                  :inversion="item.inversion"
+                  :error="item.error"
+                  :loading="item.loading"
+                  @regenerate="onRegenerate(index)"
+                  @delete="handleDelete(index)"
+              />
+              <div class="sticky bottom-0 left-0 flex justify-center">
+                <NButton v-if="loading" type="warning" @click="handleStop">
+                  <template #icon>
+                    <SvgIcon icon="ri:stop-circle-line" />
+                  </template>
+                  Stop Responding
+                </NButton>
+              </div>
+            </div>
+          </template>
+        </div>
+      </div>
+    </main>
+    <footer :class="footerClass">
+      <div class="w-full max-w-screen-xl m-auto">
+        <div class="flex items-center justify-between space-x-2">
+          <HoverButton @click="handleClear">
+            <span class="text-xl text-[#4f555e] dark:text-white">
+              <SvgIcon icon="ri:delete-bin-line" />
+            </span>
+          </HoverButton>
+          <HoverButton v-if="!isMobile" @click="handleExport">
+            <span class="text-xl text-[#4f555e] dark:text-white">
+              <SvgIcon icon="ri:download-2-line" />
+            </span>
+          </HoverButton>
+          <HoverButton v-if="!isMobile" @click="toggleUsingContext">
+            <span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
+              <SvgIcon icon="ri:chat-history-line" />
+            </span>
+          </HoverButton>
+          <NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption">
+            <template #default="{ handleInput, handleBlur, handleFocus }">
+              <NInput
+                  ref="inputRef"
+                  v-model:value="prompt"
+                  type="textarea"
+                  :placeholder="placeholder"
+                  :autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
+                  @input="handleInput"
+                  @focus="handleFocus"
+                  @blur="handleBlur"
+                  @keypress="handleEnter"
+              />
+            </template>
+          </NAutoComplete>
+          <NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
+            <template #icon>
+              <span class="dark:text-black">
+                <SvgIcon icon="ri:send-plane-fill" />
+              </span>
+            </template>
+          </NButton>
+        </div>
+      </div>
+    </footer>
+  </div>
+</template>

+ 51 - 0
src/views/chat/layout/Layout.vue

@@ -0,0 +1,51 @@
+<script setup lang='ts'>
+import { computed } from 'vue'
+import { NLayout, NLayoutContent } from 'naive-ui'
+import { useRouter } from 'vue-router'
+import Sider from './sider/index.vue'
+import Permission from './Permission.vue'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { useAppStore, useChatStore } from '@/store'
+
+const router = useRouter()
+const appStore = useAppStore()
+const chatStore = useChatStore()
+//const authStore = useAuthStore()
+
+router.replace({ name: 'Chat', params: { uuid: chatStore.active } })
+
+const { isMobile } = useBasicLayout()
+
+const collapsed = computed(() => appStore.siderCollapsed)
+
+//const needPermission = computed(() => !!authStore.session?.auth && !authStore.token)
+
+const getMobileClass = computed(() => {
+  if (isMobile.value)
+    return ['rounded-none', 'shadow-none']
+  return ['border', 'rounded-md', 'shadow-md', 'dark:border-neutral-800']
+})
+
+const getContainerClass = computed(() => {
+  return [
+    'h-full',
+    { 'pl-[260px]': !isMobile.value && !collapsed.value },
+  ]
+})
+</script>
+
+<template>
+  <div class="h-full dark:bg-[#24272e] transition-all" :class="[isMobile ? 'p-0' : 'p-4']">
+    <div class="h-full overflow-hidden" :class="getMobileClass">
+      <NLayout class="z-40 transition" :class="getContainerClass" has-sider>
+        <Sider />
+        <NLayoutContent class="h-full">
+          <RouterView v-slot="{ Component, route }">
+            <component :is="Component" :key="route.fullPath" />
+          </RouterView>
+        </NLayoutContent>
+      </NLayout>
+    </div>
+<!--    <Permission :visible="needPermission" />-->
+  </div>
+</template>

+ 80 - 0
src/views/chat/layout/Permission.vue

@@ -0,0 +1,80 @@
+<script setup lang='ts'>
+import { computed, ref } from 'vue'
+import { NButton, NInput, NModal, useMessage } from 'naive-ui'
+// import { fetchVerify } from '@/api'
+// import { useAuthStore } from '@/store'
+// import Icon403 from '@/icons/403.vue'
+
+interface Props {
+  visible: boolean
+}
+
+defineProps<Props>()
+
+//const authStore = useAuthStore()
+
+const ms = useMessage()
+
+const loading = ref(false)
+const token = ref('')
+
+const disabled = computed(() => !token.value.trim() || loading.value)
+
+async function handleVerify() {
+  const secretKey = token.value.trim()
+
+  if (!secretKey)
+    return
+
+  // try {
+  //   loading.value = true
+  //   await fetchVerify(secretKey)
+  //   authStore.setToken(secretKey)
+  //   ms.success('success')
+  //   window.location.reload()
+  // }
+  // catch (error: any) {
+  //   ms.error(error.message ?? 'error')
+  //   authStore.removeToken()
+  //   token.value = ''
+  // }
+  // finally {
+  //   loading.value = false
+  // }
+}
+
+function handlePress(event: KeyboardEvent) {
+  if (event.key === 'Enter' && !event.shiftKey) {
+    event.preventDefault()
+    handleVerify()
+  }
+}
+</script>
+
+<template>
+  <NModal :show="visible" style="width: 90%; max-width: 640px">
+    <div class="p-10 bg-white rounded dark:bg-slate-800">
+      <div class="space-y-4">
+        <header class="space-y-2">
+          <h2 class="text-2xl font-bold text-center text-slate-800 dark:text-neutral-200">
+            403
+          </h2>
+          <p class="text-base text-center text-slate-500 dark:text-slate-500">
+            {{ $t('common.unauthorizedTips') }}
+          </p>
+          <Icon403 class="w-[200px] m-auto" />
+        </header>
+        <NInput v-model:value="token" type="password" placeholder="" @keypress="handlePress" />
+        <NButton
+          block
+          type="primary"
+          :disabled="disabled"
+          :loading="loading"
+          @click="handleVerify"
+        >
+          {{ $t('common.verify') }}
+        </NButton>
+      </div>
+    </div>
+  </NModal>
+</template>

+ 3 - 0
src/views/chat/layout/index.ts

@@ -0,0 +1,3 @@
+import ChatLayout from './Layout.vue'
+
+export { ChatLayout }

+ 24 - 0
src/views/chat/layout/sider/Footer.vue

@@ -0,0 +1,24 @@
+<script setup lang='ts'>
+// import { defineAsyncComponent, ref } from 'vue'
+// import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
+//
+// const Setting = defineAsyncComponent(() => import('@/components/common/Setting/index.vue'))
+//
+// const show = ref(false)
+</script>
+
+<template>
+<!--  <footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t dark:border-neutral-800">-->
+<!--    <div class="flex-1 flex-shrink-0 overflow-hidden">-->
+<!--      <UserAvatar />-->
+<!--    </div>-->
+
+<!--    <HoverButton @click="show = true">-->
+<!--      <span class="text-xl text-[#4f555e] dark:text-white">-->
+<!--        <SvgIcon icon="ri:settings-4-line" />-->
+<!--      </span>-->
+<!--    </HoverButton>-->
+
+<!--    <Setting v-if="show" v-model:visible="show" />-->
+<!--  </footer>-->
+</template>

+ 106 - 0
src/views/chat/layout/sider/List.vue

@@ -0,0 +1,106 @@
+<script setup lang='ts'>
+import { computed } from 'vue'
+import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
+import { SvgIcon } from '@/components/common'
+import { useAppStore, useChatStore } from '@/store'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { debounce } from '@/utils/functions/debounce'
+
+const { isMobile } = useBasicLayout()
+
+const appStore = useAppStore()
+const chatStore = useChatStore()
+
+const dataSources = computed(() => chatStore.history)
+
+async function handleSelect({ uuid }: Chat.History) {
+  if (isActive(uuid))
+    return
+
+  if (chatStore.active)
+    chatStore.updateHistory(chatStore.active, { isEdit: false })
+  await chatStore.setActive(uuid)
+
+  if (isMobile.value)
+    appStore.setSiderCollapsed(true)
+}
+
+function handleEdit({ uuid }: Chat.History, isEdit: boolean, event?: MouseEvent) {
+  event?.stopPropagation()
+  chatStore.updateHistory(uuid, { isEdit })
+}
+
+function handleDelete(index: number, event?: MouseEvent | TouchEvent) {
+  event?.stopPropagation()
+  chatStore.deleteHistory(index)
+  if (isMobile.value)
+    appStore.setSiderCollapsed(true)
+}
+
+const handleDeleteDebounce = debounce(handleDelete, 600)
+
+function handleEnter({ uuid }: Chat.History, isEdit: boolean, event: KeyboardEvent) {
+  event?.stopPropagation()
+  if (event.key === 'Enter')
+    chatStore.updateHistory(uuid, { isEdit })
+}
+
+function isActive(uuid: number) {
+  return chatStore.active === uuid
+}
+</script>
+
+<template>
+  <NScrollbar class="px-4">
+    <div class="flex flex-col gap-2 text-sm">
+      <template v-if="!dataSources.length">
+        <div class="flex flex-col items-center mt-4 text-center text-neutral-300">
+          <SvgIcon icon="ri:inbox-line" class="mb-2 text-3xl" />
+          <span>{{ $t('common.noData') }}</span>
+        </div>
+      </template>
+      <template v-else>
+        <div v-for="(item, index) of dataSources" :key="index">
+          <a
+            class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group dark:border-neutral-800 dark:hover:bg-[#24272e]"
+            :class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'dark:bg-[#24272e]', 'dark:border-[#4b9e5f]', 'pr-14']"
+            @click="handleSelect(item)"
+          >
+            <span>
+              <SvgIcon icon="ri:message-3-line" />
+            </span>
+            <div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
+              <NInput
+                v-if="item.isEdit"
+                v-model:value="item.title" size="tiny"
+                @keypress="handleEnter(item, false, $event)"
+              />
+              <span v-else>{{ item.title }}</span>
+            </div>
+            <div v-if="isActive(item.uuid)" class="absolute z-10 flex visible right-1">
+              <template v-if="item.isEdit">
+                <button class="p-1" @click="handleEdit(item, false, $event)">
+                  <SvgIcon icon="ri:save-line" />
+                </button>
+              </template>
+              <template v-else>
+                <button class="p-1">
+                  <SvgIcon icon="ri:edit-line" @click="handleEdit(item, true, $event)" />
+                </button>
+
+                <NPopconfirm placement="bottom" @positive-click="handleDeleteDebounce(index, $event)">
+                  <template #trigger>
+                    <button class="p-1">
+                      <SvgIcon icon="ri:delete-bin-line" />
+                    </button>
+                  </template>
+                  {{ $t('chat.deleteHistoryConfirm') }}
+                </NPopconfirm>
+              </template>
+            </div>
+          </a>
+        </div>
+      </template>
+    </div>
+  </NScrollbar>
+</template>

+ 95 - 0
src/views/chat/layout/sider/index.vue

@@ -0,0 +1,95 @@
+<script setup lang='ts'>
+import type { CSSProperties } from 'vue'
+import { computed, ref, watch } from 'vue'
+import { NButton, NLayoutSider } from 'naive-ui'
+import List from './List.vue'
+import Footer from './Footer.vue'
+import { useAppStore, useChatStore } from '@/store'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+//import { PromptStore } from '@/components/common'
+
+const appStore = useAppStore()
+const chatStore = useChatStore()
+
+const { isMobile } = useBasicLayout()
+const show = ref(false)
+
+const collapsed = computed(() => appStore.siderCollapsed)
+
+function handleAdd() {
+  chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
+  if (isMobile.value)
+    appStore.setSiderCollapsed(true)
+}
+
+function handleUpdateCollapsed() {
+  appStore.setSiderCollapsed(!collapsed.value)
+}
+
+const getMobileClass = computed<CSSProperties>(() => {
+  if (isMobile.value) {
+    return {
+      position: 'fixed',
+      zIndex: 50,
+    }
+  }
+  return {}
+})
+
+const mobileSafeArea = computed(() => {
+  if (isMobile.value) {
+    return {
+      paddingBottom: 'env(safe-area-inset-bottom)',
+    }
+  }
+  return {}
+})
+
+watch(
+  isMobile,
+  (val) => {
+    appStore.setSiderCollapsed(val)
+  },
+  {
+    immediate: true,
+    flush: 'post',
+  },
+)
+</script>
+
+<template>
+  <NLayoutSider
+    :collapsed="collapsed"
+    :collapsed-width="0"
+    :width="260"
+    :show-trigger="isMobile ? false : 'arrow-circle'"
+    collapse-mode="transform"
+    position="absolute"
+    bordered
+    :style="getMobileClass"
+    @update-collapsed="handleUpdateCollapsed"
+  >
+    <div class="flex flex-col h-full" :style="mobileSafeArea">
+      <main class="flex flex-col flex-1 min-h-0">
+        <div class="p-4">
+          <NButton dashed block @click="handleAdd">
+            {{ $t('chat.newChatButton') }}
+          </NButton>
+        </div>
+        <div class="flex-1 min-h-0 pb-4 overflow-hidden">
+          <List />
+        </div>
+<!--        <div class="p-4">-->
+<!--          <NButton block @click="show = true">-->
+<!--            {{ $t('store.siderButton') }}-->
+<!--          </NButton>-->
+<!--        </div>-->
+      </main>
+      <Footer />
+    </div>
+  </NLayoutSider>
+  <template v-if="isMobile">
+    <div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
+  </template>
+<!--  <PromptStore v-model:visible="show" />-->
+</template>

+ 10 - 9
tsconfig.json

@@ -1,22 +1,23 @@
 {
   "compilerOptions": {
-    "target": "ESNext",
-    "useDefineForClassFields": true,
+    "baseUrl": ".",
     "module": "ESNext",
-    "moduleResolution": "Node",
+    "target": "ESNext",
+    "lib": ["DOM", "ESNext"],
     "strict": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
     "jsx": "preserve",
+    "moduleResolution": "node",
     "resolveJsonModule": true,
-    "isolatedModules": true,
-    "esModuleInterop": true,
-    "lib": ["ESNext", "DOM"],
+    "noUnusedLocals": true,
+    "strictNullChecks": true,
+    "forceConsistentCasingInFileNames": true,
     "skipLibCheck": true,
-    "noEmit": true,
     "paths": {
       "@/*": ["./src/*"]
     },
     "types": ["vite/client", "node", "naive-ui/volar"]
   },
-  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
-  "references": [{ "path": "./tsconfig.node.json" }]
+  "exclude": ["node_modules", "dist"]
 }

+ 0 - 9
tsconfig.node.json

@@ -1,9 +0,0 @@
-{
-  "compilerOptions": {
-    "composite": true,
-    "module": "ESNext",
-    "moduleResolution": "Node",
-    "allowSyntheticDefaultImports": true
-  },
-  "include": ["vite.config.ts"]
-}