├── src ├── config │ ├── index.ts │ └── env.ts ├── assets │ ├── fonts │ │ ├── index.ts │ │ └── iconfont.js │ ├── images │ │ └── entry-background.png │ └── svg │ │ └── empty-status.svg ├── base │ └── index.ts ├── data │ ├── index.ts │ └── mock-md.md ├── hooks │ ├── useCurrentInstance.ts │ ├── useClipText.ts │ ├── useTheme.ts │ └── useCopyCode.ts ├── api │ └── index.ts ├── components │ ├── Layout │ │ ├── default.vue │ │ ├── SlotArea.vue │ │ ├── SlotFrame.vue │ │ ├── CenterPanel.vue │ │ └── SlotCenterPanel.vue │ ├── CustomTooltip │ │ └── index.vue │ ├── 404.vue │ ├── IconifyIcon │ │ └── index.vue │ ├── ClipBoard │ │ └── index.vue │ ├── TableList │ │ └── index.vue │ ├── Navigation │ │ ├── NavFooter.vue │ │ ├── NavSideBar.vue │ │ ├── NavOctocat.vue │ │ └── NavBar.vue │ ├── MarkdownPreview │ │ ├── plugins │ │ │ ├── highlight.ts │ │ │ ├── preWrapper.ts │ │ │ └── markdown.ts │ │ ├── transform │ │ │ └── index.ts │ │ ├── index.vue │ │ └── models │ │ │ └── index.ts │ ├── SideBar │ │ └── Item.vue │ ├── IconFont │ │ └── index.vue │ └── Pagination │ │ └── index.vue ├── env.d.ts ├── store │ ├── index.ts │ ├── hooks │ │ └── useAppStore.ts │ ├── plugins │ │ └── index.ts │ ├── utils │ │ └── mixin.ts │ └── business │ │ └── index.ts ├── types │ ├── global.d.ts │ └── index.d.ts ├── shims-vue.d.ts ├── router │ ├── permission.ts │ ├── routes.ts │ ├── child-routes.ts │ └── index.ts ├── styles │ ├── naive-variables.scss │ ├── theme.scss │ ├── global.scss │ ├── markdown.scss │ └── index.scss ├── utils │ ├── location.ts │ ├── number.ts │ ├── type.ts │ ├── files-tool.ts │ └── request.ts ├── main.ts ├── App.vue ├── NaiveProvider.vue └── views │ └── chat.vue ├── .stylelintignore ├── babel.config.cjs ├── .vscode ├── extensions.json └── settings.json ├── components-instance.d.ts ├── .env.template ├── index.html ├── .gitignore ├── public └── favicon.svg ├── .github └── workflows │ └── deploy.yml ├── LICENSE ├── tsconfig.json ├── uno.config.ts ├── .stylelintrc.cjs ├── package.json ├── vite.config.ts ├── components.d.ts ├── .eslintrc-auto-import.json ├── eslint.config.js └── README.md /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env' 2 | -------------------------------------------------------------------------------- /src/assets/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import '@/assets/fonts/iconfont' 2 | -------------------------------------------------------------------------------- /src/base/index.ts: -------------------------------------------------------------------------------- 1 | export const systemTitle = 'MVP Vue3 大模型单轮 AI 对话' 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | obj 4 | *.* 5 | !*.vue 6 | !*.css 7 | !*.scss 8 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@vue/babel-plugin-jsx' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/data/index.ts: -------------------------------------------------------------------------------- 1 | import mockMd from './mock-md.md' 2 | 3 | export const mockEventStreamText = mockMd 4 | -------------------------------------------------------------------------------- /src/assets/images/entry-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdsuwwz/chatgpt-vue3-light-mvp/HEAD/src/assets/images/entry-background.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "stylelint.vscode-stylelint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: 若是 Github 演示部署环境,则仅模拟大模型相关策略,不调接口 3 | */ 4 | export const isGithubDeployed = process.env.VITE_ROUTER_MODE === 'hash' 5 | 6 | -------------------------------------------------------------------------------- /components-instance.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | type ComponentsInstance = { 3 | [Property in keyof GlobalComponents]: InstanceType 4 | } 5 | } 6 | export { } 7 | 8 | -------------------------------------------------------------------------------- /src/hooks/useCurrentInstance.ts: -------------------------------------------------------------------------------- 1 | export default function useCurrentInstance() { 2 | const { proxy } = getCurrentInstance() as ComponentInternalInstance 3 | 4 | return { 5 | proxy: proxy! 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request' 2 | 3 | /** 4 | * Get 请求示例 5 | */ 6 | export function getXxxxPrompt (params) { 7 | return request.get(`/xxxxxx/test/prompt`, params) 8 | } 9 | 10 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # 请替换 APIKey(假使APIKey是key123456)和APISecret(假使APISecret是secret123456) 2 | VITE_SPARK_KEY=key123456:secret123456 3 | VITE_SILICONFLOW_KEY=sk-xxxxxx 4 | VITE_MOONSHOT_KEY=sk-xxxxxx 5 | VITE_DEEPSEEK_KEY=sk-xxxxxx 6 | -------------------------------------------------------------------------------- /src/components/Layout/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv extends Readonly> { 4 | readonly VITE_BASE_API: string 5 | readonly VITE_SPARK_KEY: string 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { pluginPinia } from '@/store/plugins' 3 | 4 | const store = createPinia() 5 | 6 | export function setupStore(app: App) { 7 | app.use(store) 8 | } 9 | 10 | store.use(pluginPinia) 11 | export { store } 12 | -------------------------------------------------------------------------------- /src/store/hooks/useAppStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { store } from '@/store' 3 | 4 | export const useAppStore = defineStore('app-store', () => { 5 | return { 6 | } 7 | }) 8 | 9 | export function useAppStoreWithOut() { 10 | return useAppStore(store) 11 | } 12 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | $ModalMessage: import('naive-ui').MessageProviderInst 3 | $ModalNotification: import('naive-ui').NotificationProviderInst 4 | $ModalDialog: import('naive-ui').DialogProviderInst 5 | $ModalLoadingBar: import('naive-ui').LoadingBarProviderInst 6 | } 7 | -------------------------------------------------------------------------------- /src/store/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Plugins for Pinia 3 | */ 4 | 5 | import { getFilterResponse } from '@/store/utils/mixin' 6 | import router from '@/router' 7 | 8 | export const pluginPinia = ({ store }) => { 9 | store.filterResponse = getFilterResponse 10 | store.router = router 11 | } 12 | -------------------------------------------------------------------------------- /src/components/CustomTooltip/index.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 16 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | type EmptyObject = Record 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | 5 | const component: DefineComponent 6 | export default component 7 | } 8 | 9 | // 声明一个模块 '*.md',设置为字符串类型 10 | declare module '*.md' { 11 | const content: string 12 | export default content 13 | } 14 | -------------------------------------------------------------------------------- /src/router/permission.ts: -------------------------------------------------------------------------------- 1 | import NProgress from 'nprogress' 2 | import type { Router } from 'vue-router' 3 | 4 | NProgress.configure({ 5 | showSpinner: false 6 | }) 7 | 8 | export function createRouterGuards(router: Router) { 9 | 10 | router.beforeEach(async (to, from, next) => { 11 | 12 | NProgress.start() 13 | next() 14 | }) 15 | 16 | router.afterEach(() => { 17 | NProgress.done() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import childRoutes from '@/router/child-routes' 2 | 3 | const routes: Array = [ 4 | { 5 | path: '/', 6 | name: 'Root', 7 | redirect: { 8 | name: 'ChatRoot' 9 | } 10 | }, 11 | ...childRoutes, 12 | { 13 | path: '/:pathMatch(.*)', 14 | name: '404', 15 | component: () => import('@/components/404.vue') 16 | } 17 | ] 18 | 19 | export default routes 20 | -------------------------------------------------------------------------------- /src/styles/naive-variables.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable rule-empty-line-before */ 2 | // Only variables can be placed here, 3 | // and other files such as themes cannot be introduced, 4 | // otherwise it will affect the packaging speed. 5 | 6 | /* theme color */ 7 | $color-primary: #692ee6; 8 | $color-success: #52c41a; 9 | $color-warning: #fe7d18; 10 | $color-danger: #fa5555; 11 | $color-info: #909399; 12 | $color-default: #536fec; 13 | -------------------------------------------------------------------------------- /src/styles/theme.scss: -------------------------------------------------------------------------------- 1 | @use "highlight.js/styles/lightfair.css" as *; 2 | @use "@/styles/naive-variables.scss" as *; 3 | 4 | #nprogress .bar { 5 | background: $color-primary !important; 6 | } 7 | 8 | #nprogress .peg { 9 | box-shadow: 0 0 10px $color-primary, 0 0 5px $color-primary !important; 10 | } 11 | 12 | #nprogress .spinner-icon { 13 | border-top-color: $color-primary; 14 | border-left-color: $color-primary; 15 | } 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MVP Vue3 大模型单轮 AI 对话 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/utils/location.ts: -------------------------------------------------------------------------------- 1 | const locationHost = { 2 | hostname: 'localhost', 3 | baseApiIp: 'http://10.30.10.54:10001', 4 | baseApi: 'http://10.30.10.54:10001/api' 5 | } 6 | 7 | const hostList = [ 8 | locationHost 9 | ] 10 | 11 | /** 12 | * 获取当前服务的 host 前缀 13 | */ 14 | export const currentHost = hostList.find((hostItem) => { 15 | 16 | return window.location.hostname === hostItem.hostname 17 | 18 | }) || locationHost 19 | 20 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:uno.css' 2 | 3 | import { setupRouter } from '@/router' 4 | import { setupStore } from '@/store' 5 | 6 | import App from '@/App.vue' 7 | 8 | 9 | const app = createApp(App) 10 | 11 | function setupPlugins() { 12 | // ... 13 | } 14 | 15 | async function setupApp() { 16 | setupStore(app) 17 | await setupRouter(app) 18 | app.mount('#app') 19 | } 20 | 21 | setupPlugins() 22 | setupApp() 23 | 24 | export default app 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .vite 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | dist 14 | cache 15 | dist-ssr 16 | 17 | # misc 18 | .DS_Store 19 | *.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | .eslintcache 26 | 27 | # IDE 28 | .idea 29 | 30 | .env* 31 | !.env.template 32 | 33 | vite.config.ts.timestamp* 34 | -------------------------------------------------------------------------------- /src/router/child-routes.ts: -------------------------------------------------------------------------------- 1 | const LayoutDefault = () => import('@/components/Layout/default.vue') 2 | 3 | const childrenRoutes: Array = [ 4 | { 5 | path: '/chat', 6 | component: LayoutDefault, 7 | name: 'ChatRoot', 8 | redirect: { 9 | name: 'ChatIndex' 10 | }, 11 | children: [ 12 | { 13 | path: '', 14 | name: 'ChatIndex', 15 | component: () => import('@/views/chat.vue') 16 | } 17 | ] 18 | } 19 | ] 20 | 21 | export default childrenRoutes 22 | -------------------------------------------------------------------------------- /src/components/404.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 24 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouterGuards } from '@/router/permission' 2 | import routes from './routes' 3 | import { createWebHashHistory } from 'vue-router' 4 | import { isGithubDeployed } from '@/config' 5 | 6 | const history = isGithubDeployed 7 | ? createWebHashHistory() 8 | : createWebHistory() 9 | 10 | const router = createRouter({ 11 | history, 12 | routes 13 | }) 14 | 15 | export async function setupRouter(app: App) { 16 | createRouterGuards(router) 17 | app.use(router) 18 | 19 | await router.isReady() 20 | } 21 | 22 | export default router 23 | 24 | -------------------------------------------------------------------------------- /src/components/IconifyIcon/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/utils/mixin.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getFilterResponse( 3 | res: globalThis.IRequestData, 4 | successCallback?: globalThis.IStoreFilterCallBack | null, 5 | errorCallback?: globalThis.IStoreFilterCallBack | null 6 | ): Promise { 7 | return new Promise((resolve) => { 8 | if (res && res.error === 0) { 9 | if (successCallback) { 10 | successCallback(res) 11 | } 12 | } else if (errorCallback) { 13 | errorCallback(res) 14 | } else { 15 | window.$ModalMessage.error(res.msg!, { 16 | closable: true 17 | }) 18 | } 19 | resolve(res) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /src/NaiveProvider.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | 4 | // Auto fix 5 | 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit", 8 | "source.organizeImports": "never", 9 | "source.fixAll.stylelint": "explicit" 10 | }, 11 | "eslint.run": "onType", 12 | "eslint.format.enable": true, 13 | "stylelint.validate": [ 14 | "vue", 15 | "scss" 16 | ], 17 | "css.validate": false, 18 | "less.validate": false, 19 | 20 | "files.autoSaveDelay": 500, 21 | 22 | // Enable eslint for all supported languages 23 | "eslint.validate": [ 24 | "javascript", 25 | "javascriptreact", 26 | "typescript", 27 | "typescriptreact", 28 | "vue", 29 | "html", 30 | "markdown", 31 | "json", 32 | "jsonc", 33 | "yaml", 34 | "toml", 35 | "gql", 36 | "graphql" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Layout/SlotArea.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | 25 | 41 | -------------------------------------------------------------------------------- /src/utils/number.ts: -------------------------------------------------------------------------------- 1 | // 千分符函数 【判断是否四舍五入】 2 | export const comma = (num: any, suffix = '') => { 3 | if (!num) return 4 | 5 | const strNum = _.isString(num) ? num : String(num) 6 | const intNum = _.isString(num) ? Number(num) : num 7 | 8 | if (isNaN(intNum)) { 9 | return num 10 | } 11 | 12 | let source = [] as Array 13 | if (strNum.includes('.')) { 14 | source = String(intNum.toFixed(2)).split('.') // 保留两位(四舍五入); 按小数点分成2部分 15 | source[0] = source[0].replace(/(\d)(?=(\d{3})+$)/ig, '$1,')// 只将整数部分进行都好分割 16 | return source.join('.') + suffix // 再将小数部分合并进来 17 | } 18 | 19 | return strNum.replace(/(\d)(?=(\d{3})+$)/ig, '$1,') + suffix 20 | } 21 | 22 | export const generateYears = (startYear) =>{ 23 | const currentYear = new Date().getFullYear() 24 | const endYear = currentYear + 1 // 明年 25 | const years: string[] = [] 26 | 27 | let year = startYear 28 | for (; year <= endYear; year++) { 29 | years.push(year) 30 | } 31 | 32 | return years 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Layout/SlotFrame.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | 29 | 51 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: gh-pages 2 | 3 | on: 4 | # set triggers here, like on push or on release 5 | workflow_dispatch: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.12.x 18 | 19 | - uses: pnpm/action-setup@v3.0.0 20 | with: 21 | version: 10 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: Build 27 | run: pnpm build:gh-pages 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | github_token: ${{ secrets.ACCESS_TOKEN }} 33 | publish_dir: ./dist 34 | # Leave user_name and user_email unset to commit under your own username 35 | user_name: 'github-actions[bot]' 36 | user_email: 'github-actions[bot]@users.noreply.github.com' 37 | # Optional. I'm using this for testing. 38 | allow_empty_commit: false 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-PRESENT Wisdom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Layout/CenterPanel.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 48 | 49 | 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | 4 | "declaration": false, 5 | "noImplicitAny": false, 6 | "removeComments": true, 7 | "noLib": false, 8 | "experimentalDecorators": true, 9 | "forceConsistentCasingInFileNames": true, 10 | 11 | "target": "esnext", 12 | "useDefineForClassFields": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "strict": true, 16 | "jsx": "preserve", 17 | "sourceMap": true, 18 | "resolveJsonModule": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | "allowSyntheticDefaultImports": true, 22 | "lib": ["esnext", "dom"], 23 | 24 | "types": [ 25 | "vite/client", 26 | "node", 27 | "naive-ui/volar" 28 | ], 29 | "baseUrl": "./", 30 | "paths": { 31 | "@/*": [ 32 | "src/*" 33 | ] 34 | } 35 | }, 36 | "include": [ 37 | "src/**/*.ts", 38 | "src/**/*.md", 39 | "src/**/*.d.ts", 40 | "src/**/*.tsx", 41 | "src/**/*.vue", 42 | "__tests__/**/*.ts", 43 | "./auto-imports.d.ts", 44 | "./components.d.ts", 45 | "./components-instance.d.ts" 46 | ], 47 | "exclude": ["node_modules"] 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/type.ts: -------------------------------------------------------------------------------- 1 | const originToString = Object.prototype.toString 2 | 3 | export function isFunction(obj: any) { 4 | return typeof (obj) === 'function' 5 | } 6 | 7 | export function isObject(obj: any) { 8 | return obj === Object(obj) 9 | } 10 | 11 | export function isArray(obj: any) { 12 | return originToString.call(obj) === '[object Array]' 13 | } 14 | 15 | export function isDate(obj: any) { 16 | return originToString.call(obj) === '[object Date]' 17 | } 18 | 19 | export function isRegExp(obj: any) { 20 | return originToString.call(obj) === '[object RegExp]' 21 | } 22 | 23 | export function isBoolean(obj: any) { 24 | return originToString.call(obj) === '[object Boolean]' 25 | } 26 | 27 | export function isString(obj: any): obj is string { 28 | return originToString.call(obj) === '[object String]' 29 | } 30 | 31 | export function isUndefined(obj: any) { 32 | return originToString.call(obj) === '[object Undefined]' 33 | } 34 | 35 | export function isNull(obj: any) { 36 | return originToString.call(obj) === '[object Null]' 37 | } 38 | 39 | export function isBigInt(obj: any) { 40 | return originToString.call(obj) === '[object BigInt]' 41 | } 42 | 43 | export function isNumberical(obj: any) { 44 | return !isNaN(parseFloat(obj)) && isFinite(obj) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ClipBoard/index.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 58 | 59 | 62 | -------------------------------------------------------------------------------- /src/hooks/useClipText.ts: -------------------------------------------------------------------------------- 1 | export function useClipText () { 2 | const copied = ref(false) 3 | const copyDuration = 1500 4 | 5 | const handleCopied = () => { 6 | copied.value = true 7 | setTimeout(() => { 8 | copied.value = false 9 | }, copyDuration) 10 | } 11 | 12 | function copy (textToCopy) { 13 | if (navigator.clipboard && window.isSecureContext) { 14 | return navigator.clipboard.writeText(textToCopy).then(() => { 15 | handleCopied() 16 | }) 17 | } else { 18 | const textArea = document.createElement('textarea') 19 | textArea.value = textToCopy 20 | textArea.style.position = 'fixed' 21 | textArea.style.opacity = '0' 22 | textArea.style.left = '-999999px' 23 | textArea.style.top = '-999999px' 24 | document.body.appendChild(textArea) 25 | textArea.focus() 26 | textArea.select() 27 | return new Promise((resolve, reject) => { 28 | setTimeout(() => { 29 | const exec = document.execCommand('copy') 30 | if (exec) { 31 | handleCopied() 32 | resolve('') 33 | } else { 34 | reject(new Error) 35 | } 36 | textArea.remove() 37 | }) 38 | }) 39 | } 40 | } 41 | 42 | return { 43 | copy, 44 | copied, 45 | copyDuration 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/TableList/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 56 | 57 | 60 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | .fade-enter-active, 2 | .fade-leave-active { 3 | transition: all 0.25s ease; 4 | } 5 | 6 | .fade-enter, 7 | .fade-enter-from, 8 | .fade-leave-to { 9 | opacity: 0; 10 | transform: translateX(-10px); 11 | } 12 | 13 | .fade-leave-active { 14 | position: absolute; 15 | } 16 | 17 | 18 | .fade-between-enter-active, 19 | .fade-between-leave-active { 20 | transition: all 0.25s ease; 21 | left: 0; 22 | right: 0; 23 | } 24 | 25 | .fade-between-enter, 26 | .fade-between-enter-from, 27 | .fade-between-leave-to { 28 | opacity: 0; 29 | transform: translateX(-10px); 30 | } 31 | 32 | .fade-between-leave-active { 33 | position: absolute; 34 | } 35 | 36 | 37 | .transfer-enter-active, 38 | .transfer-leave-active { 39 | transition: all 0.3s ease; 40 | } 41 | 42 | .transfer-enter, 43 | .transfer-enter-from, 44 | .transfer-leave-to { 45 | opacity: 0; 46 | transform: translateX(30px); 47 | } 48 | 49 | .transfer-leave-active { 50 | position: absolute; 51 | } 52 | 53 | .textarea-resize-none { 54 | 55 | .n-input-wrapper { 56 | resize: none !important; 57 | } 58 | 59 | .n-input__textarea-el { 60 | resize: none !important; 61 | } 62 | } 63 | 64 | .wrapper-tooltip-scroller { 65 | --at-apply: max-w-285 max-h-100 overflow-y-auto; 66 | --at-apply: whitespace-pre-wrap; 67 | 68 | &::-webkit-scrollbar { 69 | width: 6px; 70 | height: 6px; 71 | } 72 | 73 | &::-webkit-scrollbar-thumb { 74 | --at-apply: rounded-3 "bg-white/50"; 75 | } 76 | 77 | &::-webkit-scrollbar-track { 78 | --at-apply: rounded-3 "bg-white/15"; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/Navigation/NavFooter.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | 77 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/plugins/highlight.ts: -------------------------------------------------------------------------------- 1 | import hljs from 'highlight.js' 2 | 3 | function hljsDefineVue() { 4 | return { 5 | subLanguage: 'xml', 6 | contains: [ 7 | hljs.COMMENT('', { 8 | relevance: 10 9 | }), 10 | { 11 | begin: /^(\s*)( 12 | 13 | 64 | 65 | 71 | -------------------------------------------------------------------------------- /src/styles/markdown.scss: -------------------------------------------------------------------------------- 1 | .markdown-code-wrapper { 2 | --at-apply: 'relative whitespace-initial'; 3 | --at-apply: 'bg-#f6f6f7'; 4 | --at-apply: 'line-height-24'; 5 | --at-apply: 'flex flex-col'; 6 | --at-apply: 'my-12px'; 7 | --at-apply: rounded-8 overflow-hidden; 8 | 9 | 10 | .markdown-code-header { 11 | --at-apply: flex justify-between items-center; 12 | --at-apply: 'b-b b-b-solid b-b-#3c3c43:10'; 13 | } 14 | 15 | .markdown-code-copy { 16 | --at-apply: px-14 py-8 rounded-4; 17 | --at-apply: 'flex items-center justify-center'; 18 | --at-apply: 'b-none bg-transparent'; 19 | --at-apply: 'c-#808080' cursor-pointer; 20 | --at-apply: transition-all-300 opacity-100 z-1; 21 | 22 | .markdown-copy-icon { 23 | --at-apply: text-16 pointer-events-none mr-4; 24 | --at-apply: 'i-ci:copy'; 25 | } 26 | 27 | .markdown-copy-text { 28 | --at-apply: pointer-events-none; 29 | 30 | &.default { 31 | display: initial; 32 | } 33 | 34 | &.done { 35 | display: none; 36 | } 37 | } 38 | 39 | &.copied { 40 | 41 | .markdown-copy-icon { 42 | --at-apply: 'i-ic:baseline-check'; 43 | } 44 | 45 | .markdown-copy-text { 46 | 47 | &.default { 48 | display: none; 49 | } 50 | 51 | &.done { 52 | display: initial; 53 | } 54 | } 55 | } 56 | } 57 | 58 | .markdown-code-lang { 59 | // --at-apply: absolute right-8 top-2; 60 | 61 | --at-apply: pl-14 text-14 font-500 'c-#3c3c43/56'; 62 | --at-apply: transition-all-300 opacity-100 z-0; 63 | } 64 | 65 | pre, 66 | li { 67 | 68 | code { 69 | --at-apply: 'bg-#f6f6f7'; 70 | --at-apply: whitespace-pre py-15; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetWind3, 6 | toEscapedSelector, 7 | transformerDirectives 8 | } from 'unocss' 9 | 10 | import presetRemToPx from '@unocss/preset-rem-to-px' 11 | 12 | 13 | export default defineConfig({ 14 | presets: [ 15 | presetWind3(), 16 | presetAttributify(), 17 | presetIcons(), 18 | presetRemToPx({ 19 | baseFontSize: 4 20 | }) 21 | ], 22 | transformers: [ 23 | transformerDirectives() 24 | ], 25 | theme: { 26 | breakpoints: { 27 | 'xs': '475px', 28 | 'sm': '640px', 29 | 'md': '1024px', 30 | 'lg': '1200px', 31 | 'xl': '1440px', 32 | '2xl': '1920px' 33 | }, 34 | colors: { 35 | primary: '#692ee6', 36 | success: '#52c41a', 37 | warning: '#fe7d18', 38 | danger: '#fa5555', 39 | info: '#909399', 40 | bgcolor: '#f2ecee', 41 | border: '#c2c2c2' 42 | } 43 | }, 44 | rules: [ 45 | [ 46 | 'navbar-shadow', { 47 | 'box-shadow': '0 1px 4px rgb(0 21 41 / 8%)' 48 | } 49 | ], 50 | [ 51 | /^wrapper-dialog-(.+)$/, 52 | ([, name], { rawSelector, theme }) => { 53 | const themeColor = (theme as any).colors 54 | const selector = toEscapedSelector(rawSelector) 55 | return ` 56 | ${ selector } { 57 | display: flex; 58 | flex-direction: column; 59 | padding: 0; 60 | overflow: hidden; 61 | } 62 | ${ selector } .n-dialog__title { 63 | padding: var(--n-padding); 64 | } 65 | ${ selector } .n-dialog__content { 66 | display: flex; 67 | flex: 1; 68 | min-height: 0; 69 | } 70 | ` 71 | } 72 | ] 73 | ] 74 | }) 75 | -------------------------------------------------------------------------------- /src/components/Navigation/NavSideBar.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 61 | 62 | 80 | -------------------------------------------------------------------------------- /src/store/business/index.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | import { sleep } from '@/utils/request' 4 | import * as GlobalAPI from '@/api' 5 | 6 | 7 | import * as TransformUtils from '@/components/MarkdownPreview/transform' 8 | 9 | import { defaultModelName, modelMappingList } from '@/components/MarkdownPreview/models' 10 | 11 | export interface BusinessState { 12 | systemModelName: string 13 | } 14 | 15 | export const useBusinessStore = defineStore('business-store', { 16 | state: (): BusinessState => { 17 | return { 18 | systemModelName: defaultModelName 19 | } 20 | }, 21 | getters: { 22 | currentModelItem (state) { 23 | return modelMappingList.find(v => v.modelName === state.systemModelName) 24 | } 25 | }, 26 | actions: { 27 | /** 28 | * Event Stream 调用大模型接口 29 | */ 30 | async createAssistantWriterStylized(data): Promise<{error: number 31 | reader: ReadableStreamDefaultReader | null}> { 32 | 33 | // 调用当前模型的接口 34 | return new Promise((resolve) => { 35 | if (!this.currentModelItem?.chatFetch) { 36 | return { 37 | error: 1, 38 | reader: null 39 | } 40 | } 41 | this.currentModelItem.chatFetch(data.text) 42 | .then((res) => { 43 | if (res.body) { 44 | const reader = res.body 45 | .pipeThrough(new TextDecoderStream()) 46 | .pipeThrough(TransformUtils.splitStream('\n')) 47 | .getReader() 48 | 49 | resolve({ 50 | error: 0, 51 | reader 52 | }) 53 | } else { 54 | resolve({ 55 | error: 1, 56 | reader: null 57 | }) 58 | } 59 | }) 60 | .catch((err) => { 61 | resolve({ 62 | error: 1, 63 | reader: null 64 | }) 65 | }) 66 | }) 67 | } 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /src/components/Navigation/NavOctocat.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 27 | 28 | 36 | -------------------------------------------------------------------------------- /src/components/SideBar/Item.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 79 | 80 | 83 | -------------------------------------------------------------------------------- /src/components/Navigation/NavBar.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 65 | 66 | 87 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { getFilterResponse } from '@/store/utils/mixin' 2 | 3 | import type router from '@/router' 4 | import type { AxiosRequestConfig, GenericAbortSignal } from 'axios' 5 | 6 | declare module 'vue' { 7 | /** 8 | * 9 | */ 10 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 11 | interface ComponentCustomProperties extends Window { 12 | // ... 13 | 14 | } 15 | } 16 | 17 | declare module 'axios' { 18 | /** 19 | * Costom Axios Field. 20 | */ 21 | export interface AxiosRequestConfig { 22 | redirect?: string 23 | /** 24 | * 是否触发浏览器下载弹框,默认会触发(仅限 blob type) 25 | */ 26 | autoDownLoadFile?: boolean 27 | } 28 | } 29 | 30 | declare module 'pinia' { 31 | export interface PiniaCustomProperties { 32 | filterResponse: typeof getFilterResponse 33 | router: typeof router 34 | } 35 | } 36 | 37 | declare module 'vue-router' { 38 | export interface RouteMeta { 39 | title?: string 40 | } 41 | } 42 | 43 | declare global { 44 | 45 | /** 46 | * General Object Types. 47 | */ 48 | type ObjectValueSuite = { [key in any]: T } 49 | 50 | /** 51 | * `error`: Response Status Code. 52 | * 53 | * `data`: Response Body. 54 | * 55 | * `msg`: Response Message. 56 | */ 57 | export interface IRequestData { 58 | error: number 59 | data: any 60 | msg: string 61 | aborted?: boolean 62 | } 63 | 64 | interface IRequestSuite { 65 | get(uri: string, params?: ObjectValueSuite, config?: AxiosRequestConfig): Promise 66 | post(uri: string, data?: any, config?: AxiosRequestConfig): Promise 67 | put(uri: string, data?: any, config?: AxiosRequestConfig): Promise 68 | patch(uri: string, data?: any, config?: AxiosRequestConfig): Promise 69 | delete(uri: string, config?: AxiosRequestConfig): Promise 70 | } 71 | 72 | type IModulesApiSuite = ObjectValueSuite<(...args: any) => Promise> 73 | 74 | /** 75 | * Store FilterResponse Callback Type. 76 | */ 77 | type IStoreFilterCallBack = (res: IRequestData) => Promise | void 78 | 79 | } 80 | export { } 81 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @use "./global"; 2 | @use "./theme"; 3 | @use "./markdown"; 4 | @use "nprogress/nprogress.css"; 5 | 6 | body { 7 | width: 100vw; 8 | -moz-osx-font-smoothing: grayscale; 9 | -webkit-font-smoothing: antialiased; 10 | text-rendering: optimizelegibility; 11 | font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; 12 | 13 | --at-apply: c-#303133; 14 | --at-apply: bg-#fff; 15 | } 16 | 17 | html { 18 | font-size: 14px; 19 | box-sizing: border-box; 20 | overflow-y: scroll; 21 | -webkit-tap-highlight-color: rgb(255 255 255 / 0%); 22 | } 23 | 24 | :root { 25 | overflow: hidden auto; 26 | } 27 | 28 | :root body { 29 | margin: 0; 30 | padding: 0; 31 | height: 100%; 32 | position: absolute; 33 | } 34 | 35 | #app, 36 | html { 37 | height: 100%; 38 | } 39 | 40 | dl, 41 | dt, 42 | dd, 43 | ul, 44 | ol, 45 | li, 46 | h1, 47 | h2, 48 | h3, 49 | h4, 50 | h5, 51 | h6, 52 | pre, 53 | code, 54 | form, 55 | fieldset, 56 | legend, 57 | input, 58 | textarea, 59 | blockquote, 60 | th, 61 | td, 62 | hr, 63 | button, 64 | article, 65 | aside, 66 | details, 67 | figcaption, 68 | figure, 69 | footer, 70 | header, 71 | menu, 72 | nav, 73 | section { 74 | margin: 0; 75 | padding: 0; 76 | } 77 | 78 | a { 79 | text-decoration: none; 80 | background-color: transparent; 81 | outline: none; 82 | } 83 | 84 | svg { 85 | box-sizing: content-box; 86 | } 87 | 88 | *, 89 | *::before, 90 | *::after { 91 | box-sizing: inherit; 92 | } 93 | 94 | // 滚动条样式 95 | 96 | // ::-webkit-scrollbar { 97 | // width: 6px; 98 | // height: 6px; 99 | // } 100 | 101 | // ::-webkit-scrollbar-thumb { 102 | // background: rgba(#000, 0.2); 103 | // border-radius: 3px; 104 | // } 105 | 106 | // ::-webkit-scrollbar-track { 107 | // background: rgba(#000, 0.06); 108 | // border-radius: 3px; 109 | // } 110 | 111 | // https://stackoverflow.com/questions/43778196/input-background-removed-by-chrome-autofill 112 | 113 | input:-webkit-autofill { 114 | -webkit-box-shadow: 0 0 0 1000px #fff inset; 115 | -moz-box-shadow: 0 0 0 100px #fff inset; 116 | box-shadow: 0 0 0 100px #fff inset; 117 | } 118 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalThemeOverrides } from 'naive-ui' 2 | import { darkTheme, lightTheme } from 'naive-ui' 3 | import { computed } from 'vue' 4 | 5 | const baseThemeOverrides: GlobalThemeOverrides = { 6 | common: { 7 | borderRadius: '6px', 8 | heightLarge: '40px', 9 | fontSizeLarge: '18px' 10 | } 11 | } 12 | 13 | const PrimaryColor = '#692ee6' 14 | 15 | export function useTheme() { 16 | const defaultTheme = computed(() => { 17 | return lightTheme 18 | }) 19 | const themeRevert = computed(() => { 20 | return darkTheme 21 | }) 22 | 23 | const themeOverrides = computed(() => { 24 | return { 25 | common: { 26 | ...baseThemeOverrides.common, 27 | primaryColor: PrimaryColor, 28 | primaryColorHover: lightenDarkenColor(PrimaryColor, 30), 29 | primaryColorPressed: lightenDarkenColor(PrimaryColor, -30), 30 | primaryColorSuppl: getComplementaryColor(PrimaryColor) 31 | }, 32 | Input: { 33 | placeholderColor: '#a8aeb8' 34 | } 35 | } 36 | }) 37 | 38 | 39 | return { 40 | defaultTheme, 41 | themeRevert, 42 | themeOverrides 43 | } 44 | } 45 | 46 | 47 | function lightenDarkenColor(col, amt) { 48 | let usePound = false 49 | 50 | if (col[0] === '#') { 51 | col = col.slice(1) 52 | usePound = true 53 | } 54 | 55 | const num = parseInt(col, 16) 56 | 57 | let r = (num >> 16) + amt 58 | 59 | if (r > 255) r = 255 60 | else if (r < 0) r = 0 61 | 62 | let b = ((num >> 8) & 0x00FF) + amt 63 | 64 | if (b > 255) b = 255 65 | else if (b < 0) b = 0 66 | 67 | let g = (num & 0x0000FF) + amt 68 | 69 | if (g > 255) g = 255 70 | else if (g < 0) g = 0 71 | 72 | return (usePound ? '#' : '') + (g | (b << 8) | (r << 16)).toString(16) 73 | } 74 | 75 | function getComplementaryColor(hex) { 76 | hex = hex.slice(1) // remove # 77 | const r = parseInt(hex.substring(0, 2), 16) 78 | const g = parseInt(hex.substring(2, 4), 16) 79 | const b = parseInt(hex.substring(4, 6), 16) 80 | 81 | // get the complementary color 82 | const compR = (255 - r).toString(16).padStart(2, '0') 83 | const compG = (255 - g).toString(16).padStart(2, '0') 84 | const compB = (255 - b).toString(16).padStart(2, '0') 85 | 86 | return `#${ compR }${ compG }${ compB }` 87 | } 88 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'extends': [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-standard-scss', 5 | 'stylelint-config-recommended-vue', 6 | 'stylelint-config-recommended-vue/scss' 7 | ], 8 | 'plugins': ['@stylistic/stylelint-plugin'], 9 | 'ignoreFiles': ['**/*.js', '**/*.ts'], 10 | 'defaultSeverity': 'error', 11 | 'rules': { 12 | 'unit-disallowed-list': [ 13 | 'rem', 14 | 'pt' 15 | ], 16 | '@stylistic/indentation': [ 17 | 2, 18 | { 19 | 'baseIndentLevel': 0 20 | } 21 | ], 22 | 'no-empty-source': null, 23 | 'block-no-empty': null, 24 | 'declaration-block-no-duplicate-custom-properties': null, 25 | 'font-family-no-missing-generic-family-keyword': null, 26 | 27 | 'selector-class-pattern': '^[a-z]([a-z0-9-]+)?(__([a-z0-9]+-?)+)?(__([a-z0-9]+-?)+)?(--([a-z0-9]+-?)+){0,2}$|^Mui.*$|^([a-z][a-z0-9]*)(_[a-z0-9]+)*$', 28 | 29 | 'scss/at-mixin-pattern': '^[a-z]([a-z0-9-]+)?(__([a-z0-9]+-?)+)?(__([a-z0-9]+-?)+)?(--([a-z0-9]+-?)+){0,2}$|^Mui.*$|^([a-z][a-z0-9]*)(_[a-z0-9]+)*$', 30 | 'scss/double-slash-comment-whitespace-inside': 'always', 31 | 'scss/dollar-variable-pattern': null, 32 | 33 | 'selector-type-no-unknown': null, 34 | 'selector-pseudo-class-no-unknown': [ 35 | true, 36 | { 37 | 'ignorePseudoClasses': [ 38 | 'export', 39 | 'deep' 40 | ] 41 | } 42 | ], 43 | 'color-function-notation': ['modern', { 44 | 'ignore': ['with-var-inside'] 45 | }], 46 | 'property-no-unknown': null, 47 | 'at-rule-empty-line-before': [ 48 | 'always', 49 | { 50 | 'except': ['first-nested', 'blockless-after-same-name-blockless'] 51 | } 52 | ], 53 | 'custom-property-empty-line-before': [ 54 | 'always', 55 | { 56 | 'except': ['after-custom-property', 'first-nested'] 57 | } 58 | ], 59 | 'declaration-empty-line-before': [ 60 | 'always', 61 | { 62 | 'except': ['after-declaration', 'first-nested'] 63 | } 64 | ], 65 | 'rule-empty-line-before': ['always-multi-line'], 66 | 67 | // 忽视 -webkit-xxxx 等兼容写法 68 | 'property-no-vendor-prefix': [ 69 | true, 70 | { 71 | ignoreProperties: ['box-shadow'] 72 | } 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/hooks/useCopyCode.ts: -------------------------------------------------------------------------------- 1 | export function useCopyCode() { 2 | const timeoutIdMap: WeakMap = new WeakMap() 3 | window.addEventListener('click', (e) => { 4 | const el = e.target as HTMLElement 5 | if (!el.matches('div[class*="language-"] button.markdown-code-copy')) return 6 | 7 | const parent = el.parentElement 8 | const sibling = parent?.nextElementSibling 9 | if (!parent || !sibling) { 10 | return 11 | } 12 | 13 | const isShell = /language-(shellscript|shell|bash|sh|zsh)/.test( 14 | parent.className 15 | ) 16 | 17 | const ignoredNodes = [] 18 | 19 | // Clone the node and remove the ignored nodes 20 | const clone = sibling.cloneNode(true) as HTMLElement 21 | if (ignoredNodes.length) { 22 | clone 23 | .querySelectorAll(ignoredNodes.join(',')) 24 | .forEach((node) => node.remove()) 25 | } 26 | 27 | let text = clone.textContent || '' 28 | 29 | if (isShell) { 30 | text = text.replace(/^ *(\$|>) /gm, '').trim() 31 | } 32 | 33 | copyToClipboard(text).then(() => { 34 | el.classList.add('copied') 35 | clearTimeout(timeoutIdMap.get(el)) 36 | const timeoutId = setTimeout(() => { 37 | el.classList.remove('copied') 38 | el.blur() 39 | timeoutIdMap.delete(el) 40 | }, 2000) 41 | timeoutIdMap.set(el, timeoutId) 42 | }) 43 | }) 44 | } 45 | 46 | async function copyToClipboard(text: string) { 47 | try { 48 | return navigator.clipboard.writeText(text) 49 | } catch { 50 | const element = document.createElement('textarea') 51 | const previouslyFocusedElement = document.activeElement 52 | 53 | element.value = text 54 | 55 | // Prevent keyboard from showing on mobile 56 | element.setAttribute('readonly', '') 57 | 58 | element.style.contain = 'strict' 59 | element.style.position = 'absolute' 60 | element.style.left = '-9999px' 61 | element.style.fontSize = '12pt' // Prevent zooming on iOS 62 | 63 | const selection = document.getSelection() 64 | const originalRange = selection 65 | ? selection.rangeCount > 0 && selection.getRangeAt(0) 66 | : null 67 | 68 | document.body.appendChild(element) 69 | element.select() 70 | 71 | // Explicit selection workaround for iOS 72 | element.selectionStart = 0 73 | element.selectionEnd = text.length 74 | 75 | document.execCommand('copy') 76 | document.body.removeChild(element) 77 | 78 | if (originalRange) { 79 | selection!.removeAllRanges() // originalRange can't be truthy when selection is falsy 80 | selection!.addRange(originalRange) 81 | } 82 | 83 | // Get the focus back on the previously focused element, if any 84 | if (previouslyFocusedElement) { 85 | (previouslyFocusedElement as HTMLElement).focus() 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/IconFont/index.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 104 | 105 | 127 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/transform/index.ts: -------------------------------------------------------------------------------- 1 | // 处理SSE格式的数据 2 | const processSSE = (buffer, controller, splitOn) => { 3 | const parts = buffer.split(splitOn) 4 | const lastPart = parts.pop() 5 | 6 | for (const part of parts) { 7 | const trimmedPart = part.trim() 8 | if (!trimmedPart) continue 9 | 10 | if (trimmedPart.startsWith('data:')) { 11 | const content = trimmedPart.replace(/^data: /, '').trim() 12 | if (content) { 13 | try { 14 | JSON.parse(content) 15 | controller.enqueue(content) 16 | } catch (e) { 17 | // 不是JSON,发送原文本 18 | controller.enqueue(content) 19 | } 20 | } 21 | } else { 22 | controller.enqueue(trimmedPart) 23 | } 24 | } 25 | 26 | return lastPart 27 | } 28 | 29 | // 处理可能包含多个JSON对象的数据 30 | const processJSON = (buffer, controller) => { 31 | let remaining = buffer 32 | let processed = false 33 | 34 | // 尝试找出所有完整的JSON对象 35 | while (remaining.trim() !== '') { 36 | let validJSON = '' 37 | let validJSONEndIndex = -1 38 | 39 | // 寻找第一个有效的JSON对象 40 | for (let i = 0; i <= remaining.length; i++) { 41 | try { 42 | const possibleJSON = remaining.substring(0, i) 43 | if (possibleJSON.endsWith('}')) { 44 | JSON.parse(possibleJSON) 45 | validJSON = possibleJSON 46 | validJSONEndIndex = i 47 | break 48 | } 49 | } catch (e) { 50 | // 继续尝试 51 | } 52 | } 53 | 54 | if (validJSON) { 55 | try { 56 | JSON.parse(validJSON) 57 | controller.enqueue(validJSON) 58 | remaining = remaining.substring(validJSONEndIndex).trim() 59 | processed = true 60 | } catch (e) { 61 | // 如果最终解析出错,跳出循环 62 | break 63 | } 64 | } else { 65 | // 没找到有效JSON,退出循环 66 | break 67 | } 68 | } 69 | 70 | return processed ? remaining : buffer 71 | } 72 | 73 | export const splitStream = (splitOn) => { 74 | let buffer = '' 75 | 76 | return new TransformStream({ 77 | transform(chunk, controller) { 78 | buffer += chunk 79 | const trimmedBuffer = buffer.trim() 80 | 81 | // 根据内容格式选择处理方法 82 | if (trimmedBuffer.startsWith('data:')) { 83 | // SSE格式 84 | buffer = processSSE(buffer, controller, splitOn) 85 | } else if (trimmedBuffer.startsWith('{') && ( 86 | trimmedBuffer.includes('"model"') || 87 | trimmedBuffer.includes('"message"') || 88 | trimmedBuffer.includes('"done"'))) { 89 | const newBuffer = processJSON(buffer, controller) 90 | 91 | // 如果JSON处理没有成功,当作普通文本处理 92 | if (newBuffer === buffer) { 93 | controller.enqueue(chunk) 94 | buffer = '' 95 | } else { 96 | buffer = newBuffer 97 | } 98 | } else { 99 | // 普通文本格式 100 | controller.enqueue(chunk) 101 | buffer = '' 102 | } 103 | }, 104 | 105 | flush(controller) { 106 | if (buffer.trim() !== '') { 107 | // 最后尝试处理为JSON 108 | try { 109 | controller.enqueue(buffer.trim()) 110 | } catch (e) { 111 | // 不是JSON,发送原文本 112 | controller.enqueue(buffer) 113 | } 114 | } 115 | } 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-vue3-light-mvp", 3 | "version": "0.0.1", 4 | "author": "Wisdom ", 5 | "license": "MIT", 6 | "type": "module", 7 | "packageManager": "pnpm@10.18.2", 8 | "scripts": { 9 | "dev": "vite --host", 10 | "build": "vite build", 11 | "build:gh-pages": "cross-env VITE_ROUTER_MODE=hash pnpm build", 12 | "preview": "vite preview --host", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint --fix .", 15 | "stylelint": "stylelint .scss, .vue ./src", 16 | "stylelint:fix": "stylelint --fix .scss, .vue ./src" 17 | }, 18 | "engines": { 19 | "node": ">= 22.12.x", 20 | "pnpm": ">= 10.x" 21 | }, 22 | "homepage": "https://github.com/pdsuwwz/chatgpt-vue3-light-mvp", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/pdsuwwz/chatgpt-vue3-light-mvp" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/issues" 29 | }, 30 | "dependencies": { 31 | "@nzoth/toolkit": "^0.0.4", 32 | "@vueuse/core": "^14.0.0", 33 | "axios": "1.13.2", 34 | "dompurify": "^3.3.0", 35 | "js-cookie": "^3.0.5", 36 | "lodash-es": "^4.17.21", 37 | "marked": "^17.0.1", 38 | "naive-ui": "^2.43.2", 39 | "nprogress": "^0.2.0", 40 | "pinia": "^3.0.4", 41 | "uuid": "^13.0.0", 42 | "vfile": "^6.0.3", 43 | "vue": "^3.5.24", 44 | "vue-router": "^4.6.3" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "^7.28.5", 48 | "@babel/preset-env": "^7.28.5", 49 | "@eslint/js": "^9.39.1", 50 | "@iconify/json": "^2.2.409", 51 | "@iconify/vue": "^5.0.0", 52 | "@stylistic/eslint-plugin": "^5.6.1", 53 | "@stylistic/stylelint-plugin": "^4.0.0", 54 | "@types/js-cookie": "^3.0.6", 55 | "@types/lodash-es": "^4.17.12", 56 | "@types/markdown-it": "^14.1.2", 57 | "@types/node": "^24.10.1", 58 | "@types/nprogress": "^0.2.3", 59 | "@typescript-eslint/eslint-plugin": "^8.47.0", 60 | "@typescript-eslint/parser": "^8.47.0", 61 | "@unocss/preset-icons": "66.5.9", 62 | "@unocss/preset-rem-to-px": "66.5.9", 63 | "@vitejs/plugin-vue": "^6.0.2", 64 | "@vitejs/plugin-vue-jsx": "^5.1.2", 65 | "@vscode/markdown-it-katex": "~1.1.2", 66 | "@vue/babel-plugin-jsx": "^2.0.1", 67 | "@vue/compiler-sfc": "^3.5.24", 68 | "cross-env": "^10.1.0", 69 | "crypto-js": "^4.2.0", 70 | "eslint": "^9.39.1", 71 | "eslint-plugin-html": "8.1.3", 72 | "eslint-plugin-import": "^2.32.0", 73 | "eslint-plugin-vue": "^10.5.1", 74 | "globals": "^16.5.0", 75 | "highlight.js": "^11.11.1", 76 | "identity-obj-proxy": "^3.0.0", 77 | "katex": "^0.16.25", 78 | "markdown-it": "^14.1.0", 79 | "markdown-it-highlightjs": "^4.2.0", 80 | "postcss": "^8.5.6", 81 | "postcss-html": "^1.8.0", 82 | "postcss-scss": "^4.0.9", 83 | "prismjs": "^1.30.0", 84 | "rollup": "^4.53.3", 85 | "sass": "1.94.2", 86 | "stylelint": "^16.25.0", 87 | "stylelint-config-recommended-scss": "16.0.2", 88 | "stylelint-config-recommended-vue": "^1.6.1", 89 | "stylelint-config-standard": "^39.0.1", 90 | "stylelint-config-standard-scss": "16.0.0", 91 | "typescript": "^5.9.3", 92 | "ua-parser-js": "^2.0.6", 93 | "unocss": "66.5.9", 94 | "unplugin-auto-import": "^20.2.0", 95 | "unplugin-icons": "^22.5.0", 96 | "unplugin-vue-components": "^30.0.0", 97 | "vite": "^7.2.4", 98 | "vite-raw-plugin": "^1.0.2", 99 | "vue-eslint-parser": "^10.2.0" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/plugins/preWrapper.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from 'markdown-it' 2 | import PrismJsComponents from 'prismjs/components' 3 | 4 | export interface Options { 5 | codeCopyButtonTitle: string 6 | hasSingleTheme: boolean 7 | } 8 | 9 | // 使用正则表达式匹配字符串的第一个字符,并将其转换为大写 10 | function capitalizeFirstLetter(str) { 11 | return str.replace(/^\w/, (match) => match.toUpperCase()) 12 | } 13 | 14 | const getBaseLanguageName = (nameOrAlias, components = PrismJsComponents) => { 15 | 16 | const _nameOrAlias = nameOrAlias.toLowerCase() 17 | 18 | const allLanguages = components.languages 19 | const allLanguageKeys = Object.keys(allLanguages) 20 | 21 | const lang = { 22 | value: capitalizeFirstLetter(nameOrAlias || 'markdown') 23 | } 24 | 25 | for (let index = 0; index < allLanguageKeys.length; index++) { 26 | const languageKey = allLanguageKeys[index] 27 | const languageItem = allLanguages[languageKey] 28 | 29 | const { title, alias, aliasTitles } = languageItem 30 | 31 | if (languageKey === _nameOrAlias) { 32 | lang.value = title 33 | break 34 | } 35 | 36 | if (!alias) { 37 | continue 38 | } 39 | 40 | if (Array.isArray(alias)) { 41 | 42 | if (aliasTitles && aliasTitles[_nameOrAlias]) { 43 | lang.value = aliasTitles[_nameOrAlias] 44 | break 45 | } 46 | 47 | if (alias.includes(_nameOrAlias)) { 48 | lang.value = title 49 | break 50 | } 51 | } else { 52 | if (alias === _nameOrAlias) { 53 | lang.value = title 54 | break 55 | } 56 | } 57 | } 58 | 59 | return lang.value 60 | } 61 | 62 | export function preWrapperPlugin(md: MarkdownIt, options: Options) { 63 | const fence = md.renderer.rules.fence! 64 | md.renderer.rules.fence = (...args) => { 65 | const [tokens, idx] = args 66 | const token = tokens[idx] 67 | 68 | // remove title from info 69 | token.info = token.info.replace(/\[.*\]/, '') 70 | 71 | const active = / active( |$)/.test(token.info) ? ' active' : '' 72 | token.info = token.info.replace(/ active$/, '').replace(/ active /, ' ') 73 | 74 | const lang = extractLang(token.info) 75 | 76 | const content = fence(...args) 77 | return ( 78 | ` 79 |
80 |
81 | ${ getBaseLanguageName(lang) } 82 | 87 |
88 | ${ content } 89 |
90 | ` 91 | ) 92 | } 93 | } 94 | 95 | export function getAdaptiveThemeMarker(options: Options) { 96 | return options.hasSingleTheme ? '' : ' xx-adaptive-theme' 97 | } 98 | 99 | export function extractTitle(info: string, html = false) { 100 | if (html) { 101 | return ( 102 | info.replace(//g, '').match(/data-title="(.*?)"/)?.[1] || '' 103 | ) 104 | } 105 | return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || 'txt' 106 | } 107 | 108 | function extractLang(info: string) { 109 | return info 110 | .trim() 111 | .replace(/=(\d*)/, '') 112 | .replace(/:(no-)?line-numbers({| |$|=\d*).*/, '') 113 | .replace(/(-vue|{| ).*$/, '') 114 | .replace(/^vue-html$/, 'template') 115 | .replace(/^ansi$/, '') 116 | } 117 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/plugins/markdown.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it' 2 | import hljs from './highlight' 3 | import markdownItHighlight from 'markdown-it-highlightjs' 4 | import { preWrapperPlugin } from './preWrapper' 5 | 6 | import markdownItKatex from '@vscode/markdown-it-katex' 7 | import splitAtDelimiters from 'katex/contrib/auto-render/splitAtDelimiters' 8 | 9 | import 'katex/dist/katex.min.css' 10 | import 'katex/dist/contrib/mhchem.min.js' 11 | 12 | import { 13 | markdownItMermaidPlugin, 14 | renderMermaidSSE, 15 | transformMermaid 16 | } from '@nzoth/toolkit' 17 | 18 | import '@nzoth/toolkit/styles' 19 | 20 | const md = new MarkdownIt({ 21 | html: true, 22 | linkify: true, 23 | typographer: true 24 | }) 25 | 26 | md.use(markdownItHighlight, { 27 | hljs 28 | }) 29 | .use(preWrapperPlugin, { 30 | hasSingleTheme: true 31 | }) 32 | .use(markdownItKatex) 33 | .use(markdownItMermaidPlugin) 34 | 35 | 36 | const transformMathMarkdown = (markdownText: string) => { 37 | const data = splitAtDelimiters(markdownText, [ 38 | { 39 | left: '\\[', 40 | right: '\\]', 41 | display: true 42 | }, 43 | { 44 | left: '\\(', 45 | right: '\\)', 46 | display: false 47 | } 48 | ]) 49 | 50 | return data.reduce((result, segment: any) => { 51 | if (segment.type === 'text') { 52 | return result + segment.data 53 | } 54 | const math = segment.display ? `$$${ segment.data }$$` : `$${ segment.data }$` 55 | return result + math 56 | }, '') 57 | } 58 | 59 | const transformThinkMarkdown = (source: string): string => { 60 | let result = '' 61 | let buffer = '' 62 | let inThinkBlock = false 63 | 64 | const classNameWrapper = 'think-wrapper' 65 | 66 | // 转义 中的 71 | escaped = escaped.replace(/<\/script>/gi, '</script>') 72 | // 65 | 66 | 84 | ``` 85 | 86 | **模拟接口说明**: 87 | - **接口请求**: 当用户发送请求“请展示一个 Vue 3 的示例组件”时,本模拟系统返回上述代码和说明。 88 | - **接口响应**: 响应中包括 Vue 3 示例组件的代码和详细说明,用于演示如何使用 `setup` 语法构建组件。 89 | 90 | **注意**: 本模拟回复仅为演示用途,真实的接口调用及其效果需在本地运行相应代码来体验。 91 | 92 | 93 | **组件细节描述**: 94 | - **功能描述**: 该组件包含一个标题和一个按钮,点击按钮会触发更新消息的功能。 95 | - **技术细节**: 使用 Vue 3 的 `setup` 语法来定义响应式数据和方法。`message` 是一个使用 `ref` 创建的响应式变量,`updateMessage` 方法用于更新该变量的值。 96 | - **样式说明**: 组件的样式使用了 `scss` 语法,并且 `scoped` 确保样式只应用于该组件。标题的颜色设置为绿色,按钮在悬停时会变为深色。 97 | 98 | **适用场景**: 99 | - **学习和演示**: 该组件是一个简单的示例,非常适合用于学习和演示 Vue 3 的基本用法和 `setup` 语法。 100 | - **实际应用**: 可以根据项目需求,对该组件进行扩展和修改,应用到实际项目中。 101 | 102 | 103 | ## 公式示例 104 | 105 | * 纳维-斯托克斯方程 106 | 107 | $$ 108 | \rho\left(\frac{\partial \vec{u}}{\partial t} + \vec{u} \cdot \nabla\vec{u}\right) = -\nabla p + \nabla \cdot \left[\mu\left(\nabla\vec{u} + (\nabla\vec{u})^T\right)\right] + \vec{f} 109 | $$ 110 | 111 | * 薛定谔波动方程 112 | 113 | $$ 114 | i\hbar\frac{\partial \psi(\vec{r},t)}{\partial t} = \left[-\frac{\hbar^2}{2m}\nabla^2 + V(\vec{r},t)\right]\psi(\vec{r},t) 115 | $$ 116 | 117 | * 薛定谔化学键积分方程 118 | 119 | $$ 120 | H\Psi = E\Psi, \quad H = -\frac{\hbar^2}{2m}\sum_{i}\nabla_i^2 - \sum_{i,I}\frac{Z_I}{r_{iI}} + \sum_{i B((圆形)) 137 | A --> C(圆角矩形) 138 | B --> D{菱形} 139 | C --> D 140 | ``` 141 | 142 | * 甘特图示例 143 | 144 | ```mermaid 145 | gantt 146 | title 开发进度 147 | dateFormat YYYY-MM-DD 148 | section 需求分析 149 | 需求收集 :done, 2025-03-20, 3d 150 | 需求评审 :active, 2025-03-23, 2d 151 | section 开发阶段 152 | 设计架构 : 2025-03-25, 3d 153 | 编码开发 : 2025-03-28, 5d 154 | section 测试阶段 155 | 单元测试 : 2025-04-02, 3d 156 | 系统测试 : 2025-04-05, 3d 157 | section 交付上线 158 | 部署上线 : 2025-04-08, 1d 159 | ``` 160 | 161 | * 饼图示例 162 | 163 | ```mermaid 164 | pie 165 | title 文件类型分布 166 | "Excel 文件": 45 167 | "CSV 文件": 30 168 | "JSON 文件": 15 169 | "其他": 10 170 | ``` 171 | 172 | * Git 分支图示例 173 | 174 | ```mermaid 175 | gitGraph 176 | commit id: "初始化" 177 | branch develop 178 | commit id: "开发新功能A" 179 | commit id: "修复Bug" 180 | checkout main 181 | merge develop 182 | commit id: "发布版本v1.0" 183 | ``` 184 | 185 | * 序列图示例 186 | 187 | ```mermaid 188 | sequenceDiagram 189 | participant 用户 190 | participant 前端 191 | participant 后端 192 | 用户->>前端: 输入用户名和密码 193 | 前端->>后端: 发送登录请求 194 | 后端-->>前端: 验证成功,返回 Token 195 | 前端-->>用户: 显示登录成功 196 | ``` 197 | 198 | **如果您有其他问题或需要进一步的帮助,请随时告知!** 199 | 200 | ## 🌹 说明 201 | 202 | > * 如果此开源对您有帮助,您可以点开源仓库右上角 \`Star\` 支持一下 谢谢! ^_^ ⭐️ 203 | > 204 | > * 或者您可以 Follow 一下, 我会不断开源更多有趣和实用的项目 205 | > 206 | > * 开发环境 MacOS Ventura, VSCode, Chrome 207 | > 208 | > * 推荐一个 `Element Plus` + `Vite6` + `Vue3` + `TS` + `UnoCSS` 开源入门模板项目, 对 Element Plus UI 库感兴趣的朋友可以去看看。地址在这里 209 | > 210 | > * 另外一个 `Naive UI` 开源版本的,基于 `Vite6` + `Vue3` + `TS` + `UnoCSS` 的入门模板项目, 非常适合入门练习和二次开发。地址在这里 211 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // biome-ignore lint: disable 4 | // oxlint-disable 5 | // ------ 6 | // Generated by unplugin-vue-components 7 | // Read more: https://github.com/vuejs/core/pull/3399 8 | import { GlobalComponents } from 'vue' 9 | 10 | export {} 11 | 12 | /* prettier-ignore */ 13 | declare module 'vue' { 14 | export interface GlobalComponents { 15 | 404: typeof import('./src/components/404.vue')['default'] 16 | ClipBoard: typeof import('./src/components/ClipBoard/index.vue')['default'] 17 | CustomTooltip: typeof import('./src/components/CustomTooltip/index.vue')['default'] 18 | IconFont: typeof import('./src/components/IconFont/index.vue')['default'] 19 | IconifyIcon: typeof import('./src/components/IconifyIcon/index.vue')['default'] 20 | LayoutCenterPanel: typeof import('./src/components/Layout/CenterPanel.vue')['default'] 21 | LayoutDefault: typeof import('./src/components/Layout/default.vue')['default'] 22 | LayoutSlotArea: typeof import('./src/components/Layout/SlotArea.vue')['default'] 23 | LayoutSlotCenterPanel: typeof import('./src/components/Layout/SlotCenterPanel.vue')['default'] 24 | LayoutSlotFrame: typeof import('./src/components/Layout/SlotFrame.vue')['default'] 25 | MarkdownPreview: typeof import('./src/components/MarkdownPreview/index.vue')['default'] 26 | NavigationNavBar: typeof import('./src/components/Navigation/NavBar.vue')['default'] 27 | NavigationNavFooter: typeof import('./src/components/Navigation/NavFooter.vue')['default'] 28 | NavigationNavOctocat: typeof import('./src/components/Navigation/NavOctocat.vue')['default'] 29 | NavigationNavSideBar: typeof import('./src/components/Navigation/NavSideBar.vue')['default'] 30 | NButton: typeof import('naive-ui')['NButton'] 31 | NConfigProvider: typeof import('naive-ui')['NConfigProvider'] 32 | NDialogProvider: typeof import('naive-ui')['NDialogProvider'] 33 | NEllipsis: typeof import('naive-ui')['NEllipsis'] 34 | NEmpty: typeof import('naive-ui')['NEmpty'] 35 | NFloatButton: typeof import('naive-ui')['NFloatButton'] 36 | NIcon: typeof import('naive-ui')['NIcon'] 37 | NInput: typeof import('naive-ui')['NInput'] 38 | NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] 39 | NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 40 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 41 | NResult: typeof import('naive-ui')['NResult'] 42 | NSelect: typeof import('naive-ui')['NSelect'] 43 | NSpace: typeof import('naive-ui')['NSpace'] 44 | NSpin: typeof import('naive-ui')['NSpin'] 45 | NTooltip: typeof import('naive-ui')['NTooltip'] 46 | Pagination: typeof import('./src/components/Pagination/index.vue')['default'] 47 | RouterLink: typeof import('vue-router')['RouterLink'] 48 | RouterView: typeof import('vue-router')['RouterView'] 49 | SideBarItem: typeof import('./src/components/SideBar/Item.vue')['default'] 50 | TableList: typeof import('./src/components/TableList/index.vue')['default'] 51 | } 52 | } 53 | 54 | // For TSX support 55 | declare global { 56 | const 404: typeof import('./src/components/404.vue')['default'] 57 | const ClipBoard: typeof import('./src/components/ClipBoard/index.vue')['default'] 58 | const CustomTooltip: typeof import('./src/components/CustomTooltip/index.vue')['default'] 59 | const IconFont: typeof import('./src/components/IconFont/index.vue')['default'] 60 | const IconifyIcon: typeof import('./src/components/IconifyIcon/index.vue')['default'] 61 | const LayoutCenterPanel: typeof import('./src/components/Layout/CenterPanel.vue')['default'] 62 | const LayoutDefault: typeof import('./src/components/Layout/default.vue')['default'] 63 | const LayoutSlotArea: typeof import('./src/components/Layout/SlotArea.vue')['default'] 64 | const LayoutSlotCenterPanel: typeof import('./src/components/Layout/SlotCenterPanel.vue')['default'] 65 | const LayoutSlotFrame: typeof import('./src/components/Layout/SlotFrame.vue')['default'] 66 | const MarkdownPreview: typeof import('./src/components/MarkdownPreview/index.vue')['default'] 67 | const NavigationNavBar: typeof import('./src/components/Navigation/NavBar.vue')['default'] 68 | const NavigationNavFooter: typeof import('./src/components/Navigation/NavFooter.vue')['default'] 69 | const NavigationNavOctocat: typeof import('./src/components/Navigation/NavOctocat.vue')['default'] 70 | const NavigationNavSideBar: typeof import('./src/components/Navigation/NavSideBar.vue')['default'] 71 | const NButton: typeof import('naive-ui')['NButton'] 72 | const NConfigProvider: typeof import('naive-ui')['NConfigProvider'] 73 | const NDialogProvider: typeof import('naive-ui')['NDialogProvider'] 74 | const NEllipsis: typeof import('naive-ui')['NEllipsis'] 75 | const NEmpty: typeof import('naive-ui')['NEmpty'] 76 | const NFloatButton: typeof import('naive-ui')['NFloatButton'] 77 | const NIcon: typeof import('naive-ui')['NIcon'] 78 | const NInput: typeof import('naive-ui')['NInput'] 79 | const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] 80 | const NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 81 | const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 82 | const NResult: typeof import('naive-ui')['NResult'] 83 | const NSelect: typeof import('naive-ui')['NSelect'] 84 | const NSpace: typeof import('naive-ui')['NSpace'] 85 | const NSpin: typeof import('naive-ui')['NSpin'] 86 | const NTooltip: typeof import('naive-ui')['NTooltip'] 87 | const Pagination: typeof import('./src/components/Pagination/index.vue')['default'] 88 | const RouterLink: typeof import('vue-router')['RouterLink'] 89 | const RouterView: typeof import('vue-router')['RouterView'] 90 | const SideBarItem: typeof import('./src/components/SideBar/Item.vue')['default'] 91 | const TableList: typeof import('./src/components/TableList/index.vue')['default'] 92 | } -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | /* global 2 | IRequestSuite 3 | */ 4 | import type { AxiosInstance } from 'axios' 5 | import axios from 'axios' 6 | import Cookie from 'js-cookie' 7 | 8 | import Router from '@/router' 9 | import { currentHost } from '@/utils/location' 10 | 11 | // redirect error 12 | function errorRedirect(url: string) { 13 | Router.push(`/${ url }`) 14 | } 15 | // code Message 16 | const codeMessage: { 17 | [key: number]: string 18 | } = { 19 | 200: '服务器成功返回请求的数据。', 20 | 201: '新建或修改数据成功。', 21 | 202: '一个请求已经进入后台排队(异步任务)。', 22 | 204: '删除数据成功。', 23 | 206: '进行范围请求成功。', 24 | 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。', 25 | 401: '用户没有权限(令牌、用户名、密码错误)。', 26 | 403: '用户得到授权,但是访问是被禁止的。', 27 | 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。', 28 | 405: '请求不允许。', 29 | 406: '请求的格式不可得。', 30 | 410: '请求的资源被永久删除,且不会再得到的。', 31 | 422: '当创建一个对象时,发生一个验证错误。', 32 | 500: '服务器发生错误,请检查服务器。', 33 | 502: '网关错误。', 34 | 503: '服务不可用,服务器暂时过载或维护。', 35 | 504: '网关超时。' 36 | } 37 | 38 | // 创建axios实例 39 | const service: AxiosInstance = axios.create({ 40 | // api 的 base_url 41 | baseURL: currentHost.baseApi, 42 | // 请求超时时间 43 | timeout: 200000 44 | }) 45 | 46 | // request拦截器 47 | service.interceptors.request.use( 48 | request => { 49 | const token: string | undefined = Cookie.get('token') 50 | 51 | // Conversion of hump nomenclature 52 | 53 | /** 54 | * 让每个请求携带自定义 token 55 | * 请根据实际情况自行修改 56 | */ 57 | if (request.url === '/login') { 58 | return request 59 | } 60 | request.headers!.Authorization = token as string 61 | return request 62 | }, 63 | error => { 64 | return Promise.reject(error) 65 | } 66 | ) 67 | 68 | // respone拦截器 69 | service.interceptors.response.use( 70 | response => { 71 | /** 72 | * response data 73 | * { 74 | * data: {}, 75 | * msg: "", 76 | * error: 0 0 success | 1 error | 5000 failed | HTTP code 77 | * } 78 | */ 79 | 80 | const data: any = response.data 81 | const msg: string = data.msg || '' 82 | if (msg.indexOf('user not log in') !== -1 && data.error === -1) { 83 | // TODO 写死的 之后要根据语言跳转 84 | errorRedirect('login') 85 | return 86 | } 87 | if (response.config.autoDownLoadFile === undefined || response.config.autoDownLoadFile) { 88 | Promise.resolve().then(() => { 89 | useResHeadersAPI(response.headers, data) 90 | }) 91 | } 92 | 93 | if ( 94 | response.request.responseType === 'blob' && 95 | /json$/gi.test(response.headers['content-type']) 96 | ) { 97 | return new Promise(resolve => { 98 | const reader = new FileReader() 99 | reader.readAsText(response.data) 100 | 101 | reader.onload = () => { 102 | if (!reader.result || typeof reader.result !== 'string') return resolve(response.data) 103 | 104 | response.data = JSON.parse(reader.result) 105 | resolve(response.data) 106 | } 107 | 108 | }) 109 | } else if (data instanceof Blob) { 110 | return { 111 | data, 112 | msg: '', 113 | error: 0 114 | } 115 | } 116 | 117 | if (data.code && data.data) { 118 | return { 119 | data: data.data, 120 | error: data.code === 200 ? 0 : -1, 121 | msg: 'ok' 122 | } 123 | } 124 | 125 | 126 | if (!data.data && !data.msg && !data.error) { 127 | return { 128 | data, 129 | error: 0, 130 | msg: 'ok' 131 | } 132 | } 133 | 134 | 135 | if (data.msg === null) { 136 | data.msg = 'Unknown error' 137 | } 138 | return data 139 | }, 140 | error => { 141 | /** 142 | * 某些特定的接口 404 500 需要跳转 143 | * 在需要重定向的接口中传入 redirect字段 值为要跳转的路由 144 | * redirect之后 调用接口的地方会继续执行 145 | * 因为此时 response error 146 | * 所以需要前端返回一个前端构造好的数据结构 避免前端业务部分逻辑出错 147 | * 不重定向的接口则不需要传 148 | */ 149 | if (error.config.redirect) { 150 | errorRedirect(error.config.redirect) 151 | } 152 | if (error.response) { 153 | return { 154 | data: {}, 155 | error: error.response.status, 156 | msg: codeMessage[error.response.status] || error.response.data.message 157 | } 158 | } else { 159 | // 某些特定的接口 failed 需要跳转 160 | console.log(error) 161 | return { 162 | data: {}, 163 | error: 5000, 164 | aborted: error.config.signal?.aborted, 165 | msg: '服务请求不可用,请重试或检查您的网络。' 166 | } 167 | } 168 | } 169 | ) 170 | 171 | export function sleep(time = 0) { 172 | return new Promise((resolve) => { 173 | setTimeout(() => { 174 | resolve({}) 175 | }, time) 176 | }) 177 | } 178 | 179 | function extractFileNameFromContentDispositionHeader(value: string) { 180 | const patterns = [ 181 | /filename\*=[^']+'\w*'"([^"]+)";?/i, 182 | /filename\*=[^']+'\w*'([^;]+);?/i, 183 | /filename="([^;]*);?"/i, 184 | /filename=([^;]*);?/i 185 | ] 186 | 187 | let responseFilename: any = null 188 | patterns.some(regex => { 189 | responseFilename = regex.exec(value) 190 | return responseFilename !== null 191 | }) 192 | 193 | if (responseFilename !== null && responseFilename.length > 1) { 194 | try { 195 | return decodeURIComponent(responseFilename[1]) 196 | } catch (e) { 197 | console.error(e) 198 | } 199 | } 200 | 201 | return null 202 | } 203 | 204 | export function downloadFile(boldData: BlobPart, filename = '预设文件名称', type: any) { 205 | const blob = boldData instanceof Blob 206 | ? boldData 207 | : new Blob([boldData], { 208 | type 209 | }) 210 | const url = window.URL.createObjectURL(blob) 211 | 212 | const link = document.createElement('a') 213 | link.style.display = 'none' 214 | link.href = url 215 | link.download = filename 216 | document.body.appendChild(link) 217 | 218 | link.click() 219 | 220 | document.body.removeChild(link) 221 | } 222 | 223 | export function useResHeadersAPI(headers: any, resData: any) { 224 | const disposition = headers['content-disposition'] 225 | if (disposition) { 226 | let filename: string | null = '' 227 | 228 | filename = extractFileNameFromContentDispositionHeader(disposition) 229 | if (filename) { 230 | downloadFile(resData, filename, headers['content-type']) 231 | } 232 | } 233 | } 234 | 235 | const requestSuite: IRequestSuite = { 236 | get(uri, params, config) { 237 | return service.get(uri, { 238 | params, 239 | ...config 240 | }) 241 | }, 242 | post(uri, data, config) { 243 | return service.post(uri, data, config) 244 | }, 245 | put(uri, data, config) { 246 | return service.put(uri, data, config) 247 | }, 248 | patch(uri, data, config) { 249 | return service.patch(uri, data, config) 250 | }, 251 | delete(uri, config) { 252 | return service.delete(uri, config) 253 | } 254 | } 255 | 256 | export default requestSuite 257 | -------------------------------------------------------------------------------- /src/assets/svg/empty-status.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 空状态插图 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/components/Pagination/index.vue: -------------------------------------------------------------------------------- 1 | 319 | 320 | 321 | 349 | 350 | 359 | -------------------------------------------------------------------------------- /.eslintrc-auto-import.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "App": true, 4 | "BusinessState": true, 5 | "Component": true, 6 | "ComponentInternalInstance": true, 7 | "ComponentPublicInstance": true, 8 | "ComputedRef": true, 9 | "DirectiveBinding": true, 10 | "EffectScope": true, 11 | "ExtractDefaultPropTypes": true, 12 | "ExtractPropTypes": true, 13 | "ExtractPublicPropTypes": true, 14 | "GlobalComponents": true, 15 | "InjectionKey": true, 16 | "MaybeRef": true, 17 | "MaybeRefOrGetter": true, 18 | "PropType": true, 19 | "Ref": true, 20 | "RouteLocationRaw": true, 21 | "RouteRecordRaw": true, 22 | "SetupContext": true, 23 | "ShallowRef": true, 24 | "Slot": true, 25 | "Slots": true, 26 | "VNode": true, 27 | "WritableComputedRef": true, 28 | "_": true, 29 | "asyncComputed": true, 30 | "autoResetRef": true, 31 | "computed": true, 32 | "computedAsync": true, 33 | "computedEager": true, 34 | "computedInject": true, 35 | "computedWithControl": true, 36 | "controlledComputed": true, 37 | "controlledRef": true, 38 | "createApp": true, 39 | "createEventHook": true, 40 | "createGlobalState": true, 41 | "createInjectionState": true, 42 | "createReactiveFn": true, 43 | "createRef": true, 44 | "createReusableTemplate": true, 45 | "createRouter": true, 46 | "createSharedComposable": true, 47 | "createTemplatePromise": true, 48 | "createUnrefFn": true, 49 | "createVNode": true, 50 | "createWebHistory": true, 51 | "customRef": true, 52 | "debouncedRef": true, 53 | "debouncedWatch": true, 54 | "defineAsyncComponent": true, 55 | "defineComponent": true, 56 | "eagerComputed": true, 57 | "effectScope": true, 58 | "extendRef": true, 59 | "getCurrentInstance": true, 60 | "getCurrentScope": true, 61 | "getCurrentWatcher": true, 62 | "h": true, 63 | "ignorableWatch": true, 64 | "inject": true, 65 | "injectLocal": true, 66 | "isDefined": true, 67 | "isProxy": true, 68 | "isReactive": true, 69 | "isReadonly": true, 70 | "isRef": true, 71 | "isShallow": true, 72 | "makeDestructurable": true, 73 | "manualResetRef": true, 74 | "markRaw": true, 75 | "nextTick": true, 76 | "onActivated": true, 77 | "onBeforeMount": true, 78 | "onBeforeRouteLeave": true, 79 | "onBeforeRouteUpdate": true, 80 | "onBeforeUnmount": true, 81 | "onBeforeUpdate": true, 82 | "onClickOutside": true, 83 | "onDeactivated": true, 84 | "onElementRemoval": true, 85 | "onErrorCaptured": true, 86 | "onKeyStroke": true, 87 | "onLongPress": true, 88 | "onMounted": true, 89 | "onRenderTracked": true, 90 | "onRenderTriggered": true, 91 | "onScopeDispose": true, 92 | "onServerPrefetch": true, 93 | "onStartTyping": true, 94 | "onUnmounted": true, 95 | "onUpdated": true, 96 | "onWatcherCleanup": true, 97 | "pausableWatch": true, 98 | "provide": true, 99 | "provideLocal": true, 100 | "reactify": true, 101 | "reactifyObject": true, 102 | "reactive": true, 103 | "reactiveComputed": true, 104 | "reactiveOmit": true, 105 | "reactivePick": true, 106 | "readonly": true, 107 | "ref": true, 108 | "refAutoReset": true, 109 | "refDebounced": true, 110 | "refDefault": true, 111 | "refManualReset": true, 112 | "refThrottled": true, 113 | "refWithControl": true, 114 | "render": true, 115 | "resolveComponent": true, 116 | "resolveRef": true, 117 | "shallowReactive": true, 118 | "shallowReadonly": true, 119 | "shallowRef": true, 120 | "syncRef": true, 121 | "syncRefs": true, 122 | "templateRef": true, 123 | "throttledRef": true, 124 | "throttledWatch": true, 125 | "toRaw": true, 126 | "toReactive": true, 127 | "toRef": true, 128 | "toRefs": true, 129 | "toValue": true, 130 | "triggerRef": true, 131 | "tryOnBeforeMount": true, 132 | "tryOnBeforeUnmount": true, 133 | "tryOnMounted": true, 134 | "tryOnScopeDispose": true, 135 | "tryOnUnmounted": true, 136 | "unref": true, 137 | "unrefElement": true, 138 | "until": true, 139 | "useActiveElement": true, 140 | "useAnimate": true, 141 | "useArrayDifference": true, 142 | "useArrayEvery": true, 143 | "useArrayFilter": true, 144 | "useArrayFind": true, 145 | "useArrayFindIndex": true, 146 | "useArrayFindLast": true, 147 | "useArrayIncludes": true, 148 | "useArrayJoin": true, 149 | "useArrayMap": true, 150 | "useArrayReduce": true, 151 | "useArraySome": true, 152 | "useArrayUnique": true, 153 | "useAsyncQueue": true, 154 | "useAsyncState": true, 155 | "useAttrs": true, 156 | "useBase64": true, 157 | "useBattery": true, 158 | "useBluetooth": true, 159 | "useBreakpoints": true, 160 | "useBroadcastChannel": true, 161 | "useBrowserLocation": true, 162 | "useBusinessStore": true, 163 | "useCached": true, 164 | "useClipText": true, 165 | "useClipboard": true, 166 | "useClipboardItems": true, 167 | "useCloned": true, 168 | "useColorMode": true, 169 | "useConfirmDialog": true, 170 | "useCopyCode": true, 171 | "useCountdown": true, 172 | "useCounter": true, 173 | "useCssModule": true, 174 | "useCssVar": true, 175 | "useCssVars": true, 176 | "useCurrentElement": true, 177 | "useCurrentInstance": true, 178 | "useCycleList": true, 179 | "useDark": true, 180 | "useDateFormat": true, 181 | "useDebounce": true, 182 | "useDebounceFn": true, 183 | "useDebouncedRefHistory": true, 184 | "useDeviceMotion": true, 185 | "useDeviceOrientation": true, 186 | "useDevicePixelRatio": true, 187 | "useDevicesList": true, 188 | "useDialog": true, 189 | "useDisplayMedia": true, 190 | "useDocumentVisibility": true, 191 | "useDraggable": true, 192 | "useDropZone": true, 193 | "useElementBounding": true, 194 | "useElementByPoint": true, 195 | "useElementHover": true, 196 | "useElementSize": true, 197 | "useElementVisibility": true, 198 | "useEventBus": true, 199 | "useEventListener": true, 200 | "useEventSource": true, 201 | "useEyeDropper": true, 202 | "useFavicon": true, 203 | "useFetch": true, 204 | "useFileDialog": true, 205 | "useFileSystemAccess": true, 206 | "useFocus": true, 207 | "useFocusWithin": true, 208 | "useFps": true, 209 | "useFullscreen": true, 210 | "useGamepad": true, 211 | "useGeolocation": true, 212 | "useId": true, 213 | "useIdle": true, 214 | "useImage": true, 215 | "useInfiniteScroll": true, 216 | "useIntersectionObserver": true, 217 | "useInterval": true, 218 | "useIntervalFn": true, 219 | "useKeyModifier": true, 220 | "useLastChanged": true, 221 | "useLink": true, 222 | "useLoadingBar": true, 223 | "useLocalStorage": true, 224 | "useMagicKeys": true, 225 | "useManualRefHistory": true, 226 | "useMediaControls": true, 227 | "useMediaQuery": true, 228 | "useMemoize": true, 229 | "useMemory": true, 230 | "useMessage": true, 231 | "useModel": true, 232 | "useMounted": true, 233 | "useMouse": true, 234 | "useMouseInElement": true, 235 | "useMousePressed": true, 236 | "useMutationObserver": true, 237 | "useNavigatorLanguage": true, 238 | "useNetwork": true, 239 | "useNotification": true, 240 | "useNow": true, 241 | "useObjectUrl": true, 242 | "useOffsetPagination": true, 243 | "useOnline": true, 244 | "usePageLeave": true, 245 | "useParallax": true, 246 | "useParentElement": true, 247 | "usePerformanceObserver": true, 248 | "usePermission": true, 249 | "usePointer": true, 250 | "usePointerLock": true, 251 | "usePointerSwipe": true, 252 | "usePreferredColorScheme": true, 253 | "usePreferredContrast": true, 254 | "usePreferredDark": true, 255 | "usePreferredLanguages": true, 256 | "usePreferredReducedMotion": true, 257 | "usePreferredReducedTransparency": true, 258 | "usePrevious": true, 259 | "useRafFn": true, 260 | "useRefHistory": true, 261 | "useResizeObserver": true, 262 | "useRoute": true, 263 | "useRouter": true, 264 | "useSSRWidth": true, 265 | "useScreenOrientation": true, 266 | "useScreenSafeArea": true, 267 | "useScriptTag": true, 268 | "useScroll": true, 269 | "useScrollLock": true, 270 | "useSessionStorage": true, 271 | "useShare": true, 272 | "useSlots": true, 273 | "useSorted": true, 274 | "useSpeechRecognition": true, 275 | "useSpeechSynthesis": true, 276 | "useStepper": true, 277 | "useStorage": true, 278 | "useStorageAsync": true, 279 | "useStyleTag": true, 280 | "useSupported": true, 281 | "useSwipe": true, 282 | "useTemplateRef": true, 283 | "useTemplateRefsList": true, 284 | "useTextDirection": true, 285 | "useTextSelection": true, 286 | "useTextareaAutosize": true, 287 | "useTheme": true, 288 | "useThrottle": true, 289 | "useThrottleFn": true, 290 | "useThrottledRefHistory": true, 291 | "useTimeAgo": true, 292 | "useTimeAgoIntl": true, 293 | "useTimeout": true, 294 | "useTimeoutFn": true, 295 | "useTimeoutPoll": true, 296 | "useTimestamp": true, 297 | "useTitle": true, 298 | "useToNumber": true, 299 | "useToString": true, 300 | "useToggle": true, 301 | "useTransition": true, 302 | "useUrlSearchParams": true, 303 | "useUserMedia": true, 304 | "useVModel": true, 305 | "useVModels": true, 306 | "useVibrate": true, 307 | "useVirtualList": true, 308 | "useWakeLock": true, 309 | "useWebNotification": true, 310 | "useWebSocket": true, 311 | "useWebWorker": true, 312 | "useWebWorkerFn": true, 313 | "useWindowFocus": true, 314 | "useWindowScroll": true, 315 | "useWindowSize": true, 316 | "uuidv4": true, 317 | "watch": true, 318 | "watchArray": true, 319 | "watchAtMost": true, 320 | "watchDebounced": true, 321 | "watchDeep": true, 322 | "watchEffect": true, 323 | "watchIgnorable": true, 324 | "watchImmediate": true, 325 | "watchOnce": true, 326 | "watchPausable": true, 327 | "watchPostEffect": true, 328 | "watchSyncEffect": true, 329 | "watchThrottled": true, 330 | "watchTriggerable": true, 331 | "watchWithFilter": true, 332 | "whenever": true 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/views/chat.vue: -------------------------------------------------------------------------------- 1 | 241 | 242 | 380 | 381 | 384 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/index.vue: -------------------------------------------------------------------------------- 1 | 332 | 333 | 432 | 433 | 576 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import { defineConfig } from 'eslint/config' 3 | 4 | import * as parserTypeScript from '@typescript-eslint/parser' 5 | import pluginTypeScript from '@typescript-eslint/eslint-plugin' 6 | 7 | import * as parserVue from 'vue-eslint-parser' 8 | import pluginVue from 'eslint-plugin-vue' 9 | import js from '@eslint/js' 10 | 11 | import stylistic from '@stylistic/eslint-plugin' 12 | 13 | function renameRules(rules, map) { 14 | return Object.fromEntries( 15 | Object.entries(rules).map(([key, value]) => { 16 | for (const [from, to] of Object.entries(map)) { 17 | if (key.startsWith(`${ from }/`)) 18 | return [to + key.slice(from.length), value] 19 | } 20 | return [key, value] 21 | }) 22 | ) 23 | } 24 | 25 | export default defineConfig([ 26 | { 27 | ignores: [ 28 | 'public', 29 | 'build', 30 | 'dist', 31 | 'node_modules', 32 | 'coverage', 33 | 'src/assets/**' 34 | ] 35 | }, 36 | { 37 | plugins: { 38 | '@stylistic': stylistic 39 | }, 40 | rules: { 41 | '@stylistic/semi': ['error', 'never'], 42 | '@stylistic/no-extra-semi': 'error', 43 | '@stylistic/template-curly-spacing': ['error', 'always'], 44 | '@stylistic/space-before-blocks': ['error', 'always'], 45 | '@stylistic/indent': ['error', 2, { 46 | SwitchCase: 1 47 | }], 48 | '@stylistic/object-curly-newline': ['error', { 49 | 'ObjectExpression': { 50 | // 如果对象有属性,则要求换行。空对象则忽略 51 | 'minProperties': 1, 52 | // 保持一致性 53 | 'consistent': true 54 | } 55 | }], 56 | '@stylistic/object-property-newline': 'error', 57 | '@stylistic/key-spacing': ['error', { 58 | 'beforeColon': false, 59 | 'afterColon': true 60 | }], 61 | '@stylistic/type-annotation-spacing': ['error', { 62 | 'before': true, 63 | 'after': true, 64 | 'overrides': { 65 | 'colon': { 66 | 'before': false, 67 | 'after': true 68 | } 69 | } 70 | }], 71 | '@stylistic/no-trailing-spaces': ['error'], 72 | '@stylistic/member-delimiter-style': ['error', { 73 | multiline: { 74 | delimiter: 'none', 75 | requireLast: false 76 | }, 77 | singleline: { 78 | delimiter: 'semi', 79 | requireLast: true 80 | } 81 | }] 82 | } 83 | }, 84 | { 85 | ...js.configs.recommended, 86 | languageOptions: { 87 | ecmaVersion: 2022, 88 | globals: { 89 | document: 'readonly', 90 | navigator: 'readonly', 91 | window: 'readonly', 92 | ...globals.node, 93 | ...globals.es2021, 94 | ...globals.browser 95 | }, 96 | parserOptions: { 97 | ecmaFeatures: { 98 | jsx: true 99 | }, 100 | ecmaVersion: 2022, 101 | sourceType: 'module' 102 | }, 103 | sourceType: 'module' 104 | }, 105 | rules: { 106 | 'accessor-pairs': ['error', { 107 | enforceForClassMembers: true, 108 | setWithoutGet: true 109 | }], 110 | 'array-callback-return': 'error', 111 | 'block-scoped-var': 'error', 112 | 'comma-spacing': ['error', { 113 | after: true, 114 | before: false 115 | }], 116 | 'constructor-super': 'error', 117 | 'default-case-last': 'error', 118 | 'dot-notation': ['error', { 119 | allowKeywords: true 120 | }], 121 | 'eqeqeq': ['error', 'always'], 122 | 'new-cap': ['error', { 123 | capIsNew: false, 124 | newIsCap: true, 125 | properties: true 126 | }], 127 | 'no-alert': 'error', 128 | 'no-array-constructor': 'error', 129 | 'no-async-promise-executor': 'error', 130 | 'no-caller': 'error', 131 | 'no-case-declarations': 'error', 132 | 'no-class-assign': 'error', 133 | 'no-compare-neg-zero': 'error', 134 | 'no-cond-assign': ['error', 'always'], 135 | 'no-console': ['error', { 136 | allow: ['log', 'warn', 'error'] 137 | }], 138 | 'no-const-assign': 'error', 139 | 'no-control-regex': 'error', 140 | 'no-debugger': 'error', 141 | 'no-delete-var': 'error', 142 | 'no-dupe-args': 'error', 143 | 'no-dupe-class-members': 'error', 144 | 'no-dupe-keys': 'error', 145 | 'no-duplicate-case': 'error', 146 | 'no-empty': ['error', { 147 | allowEmptyCatch: true 148 | }], 149 | 'no-empty-character-class': 'error', 150 | 'no-empty-pattern': 'error', 151 | 'no-eval': 'error', 152 | 'no-ex-assign': 'error', 153 | 'no-extend-native': 'error', 154 | 'no-extra-bind': 'error', 155 | 'no-extra-boolean-cast': 'error', 156 | 'no-fallthrough': 'error', 157 | 'no-func-assign': 'error', 158 | 'no-global-assign': 'error', 159 | 'no-implied-eval': 'error', 160 | 'no-import-assign': 'error', 161 | 'no-invalid-regexp': 'error', 162 | 'no-irregular-whitespace': 'error', 163 | 'no-iterator': 'error', 164 | 'no-labels': ['error', { 165 | allowLoop: false, 166 | allowSwitch: false 167 | }], 168 | 'no-lone-blocks': 'error', 169 | 'no-loss-of-precision': 'error', 170 | 'no-misleading-character-class': 'error', 171 | 'no-multi-str': 'error', 172 | 'no-new': 'off', 173 | 'no-new-func': 'error', 174 | 'no-new-native-nonconstructor': 'error', 175 | 'no-new-wrappers': 'error', 176 | 'no-obj-calls': 'error', 177 | 'no-octal': 'error', 178 | 'no-octal-escape': 'error', 179 | 'no-proto': 'error', 180 | 'no-prototype-builtins': 'error', 181 | 'no-redeclare': ['error', { 182 | builtinGlobals: false 183 | }], 184 | 'no-regex-spaces': 'error', 185 | 'no-restricted-globals': [ 186 | 'error', 187 | { 188 | message: 'Use `globalThis` instead.', 189 | name: 'global' 190 | }, 191 | { 192 | message: 'Use `globalThis` instead.', 193 | name: 'self' 194 | } 195 | ], 196 | 'no-restricted-properties': [ 197 | 'error', 198 | { 199 | message: 'Use `Object.getPrototypeOf` or `Object.setPrototypeOf` instead.', 200 | property: '__proto__' 201 | }, 202 | { 203 | message: 'Use `Object.defineProperty` instead.', 204 | property: '__defineGetter__' 205 | }, 206 | { 207 | message: 'Use `Object.defineProperty` instead.', 208 | property: '__defineSetter__' 209 | }, 210 | { 211 | message: 'Use `Object.getOwnPropertyDescriptor` instead.', 212 | property: '__lookupGetter__' 213 | }, 214 | { 215 | message: 'Use `Object.getOwnPropertyDescriptor` instead.', 216 | property: '__lookupSetter__' 217 | } 218 | ], 219 | 'no-restricted-syntax': [ 220 | 'error', 221 | 'DebuggerStatement', 222 | 'LabeledStatement', 223 | 'WithStatement', 224 | 'TSEnumDeclaration[const=true]', 225 | 'TSExportAssignment' 226 | ], 227 | 'no-self-assign': ['error', { 228 | props: true 229 | }], 230 | 'no-self-compare': 'error', 231 | 'no-sequences': 'error', 232 | 'no-shadow-restricted-names': 'error', 233 | 'no-sparse-arrays': 'error', 234 | 'no-template-curly-in-string': 'error', 235 | 'no-this-before-super': 'error', 236 | 'no-throw-literal': 'error', 237 | 'no-undef': 'error', 238 | 'no-undef-init': 'error', 239 | 'no-unexpected-multiline': 'error', 240 | 'no-unmodified-loop-condition': 'error', 241 | 'no-unneeded-ternary': ['error', { 242 | defaultAssignment: false 243 | }], 244 | 'no-unreachable': 'error', 245 | 'no-unreachable-loop': 'error', 246 | 'no-unsafe-finally': 'error', 247 | 'no-unsafe-negation': 'error', 248 | 'no-unused-expressions': ['error', { 249 | allowShortCircuit: true, 250 | allowTaggedTemplates: true, 251 | allowTernary: true 252 | }], 253 | 'no-unused-vars': ['error', { 254 | args: 'none', 255 | caughtErrors: 'none', 256 | ignoreRestSiblings: true, 257 | vars: 'all' 258 | }], 259 | 'no-use-before-define': ['error', { 260 | classes: false, 261 | functions: false, 262 | variables: false 263 | }], 264 | 'no-useless-backreference': 'error', 265 | 'no-useless-call': 'error', 266 | 'no-useless-catch': 'error', 267 | 'no-useless-computed-key': 'error', 268 | 'no-useless-constructor': 'error', 269 | 'no-useless-rename': 'error', 270 | 'no-useless-return': 'error', 271 | 'no-var': 'error', 272 | 'no-with': 'error', 273 | 'space-infix-ops': 'error', 274 | 'object-curly-spacing': ['error', 'always'], 275 | 'object-shorthand': [ 276 | 'error', 277 | 'always', 278 | { 279 | avoidQuotes: true, 280 | ignoreConstructors: false 281 | } 282 | ], 283 | 'one-var': ['error', { 284 | initialized: 'never' 285 | }], 286 | 'prefer-arrow-callback': [ 287 | 'error', 288 | { 289 | allowNamedFunctions: false, 290 | allowUnboundThis: true 291 | } 292 | ], 293 | 'prefer-const': [ 294 | 'error', 295 | { 296 | destructuring: 'all', 297 | ignoreReadBeforeAssign: true 298 | } 299 | ], 300 | 'prefer-exponentiation-operator': 'error', 301 | 'prefer-promise-reject-errors': 'error', 302 | 'prefer-regex-literals': ['error', { 303 | disallowRedundantWrapping: true 304 | }], 305 | 'prefer-rest-params': 'error', 306 | 'prefer-spread': 'error', 307 | 'prefer-template': 'error', 308 | 'sort-imports': [ 309 | 'error', 310 | { 311 | allowSeparatedGroups: false, 312 | ignoreCase: false, 313 | ignoreDeclarationSort: true, 314 | ignoreMemberSort: false, 315 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'] 316 | } 317 | ], 318 | 319 | // https://cn.eslint.org/docs/rules/no-trailing-spaces 320 | 'no-trailing-spaces': 2, // 禁用行尾空白 321 | 'comma-style': ['error', 'last'], 322 | 'comma-dangle': ['error', 'never'], 323 | 'no-multi-spaces': 1, 324 | 'no-multiple-empty-lines': [ 325 | 2, 326 | { 327 | max: 2 328 | } 329 | ], 330 | // https://cn.eslint.org/docs/rules/eol-last 331 | 'eol-last': 2, 332 | 'quotes': [ 333 | 'error', 334 | 'single', 335 | { 336 | avoidEscape: true, 337 | allowTemplateLiterals: true 338 | } 339 | ] 340 | } 341 | }, 342 | { 343 | files: ['**/*.vue', '**/*.?([cm])ts', '**/*.?([cm])tsx'], 344 | languageOptions: { 345 | parser: parserTypeScript, 346 | parserOptions: { 347 | ecmaVersion: 2022, 348 | sourceType: 'module', 349 | ecmaFeatures: { 350 | jsx: true 351 | } 352 | } 353 | }, 354 | settings: { 355 | 'import/core-modules': [ 356 | 'uno.css' 357 | ] 358 | }, 359 | plugins: { 360 | '@typescript-eslint': pluginTypeScript 361 | }, 362 | rules: { 363 | ...renameRules( 364 | pluginTypeScript.configs['eslint-recommended'].overrides[0].rules, 365 | { 366 | '@typescript-eslint': 'ts' 367 | } 368 | ), 369 | ...pluginTypeScript.configs.recommended.rules, 370 | '@typescript-eslint/ban-ts-comment': 'off', 371 | '@typescript-eslint/explicit-module-boundary-types': 'off', 372 | '@typescript-eslint/no-explicit-any': 'off', 373 | '@typescript-eslint/no-unused-vars': 1, 374 | '@typescript-eslint/no-empty-function': 0, 375 | '@typescript-eslint/no-non-null-assertion': 0, 376 | '@typescript-eslint/consistent-type-imports': ['error', { 377 | fixStyle: 'separate-type-imports', 378 | disallowTypeAnnotations: false 379 | }] 380 | } 381 | }, 382 | { 383 | files: ['*.d.ts'], 384 | rules: { 385 | 'eslint-comments/no-unlimited-disable': 'off', 386 | 'import/no-duplicates': 'off', 387 | 'unused-imports/no-unused-vars': 'off' 388 | } 389 | }, 390 | 391 | ...pluginVue.configs['flat/base'], 392 | ...pluginVue.configs['flat/essential'], 393 | ...pluginVue.configs['flat/strongly-recommended'], 394 | ...pluginVue.configs['flat/recommended'], 395 | { 396 | files: ['**/*.vue'], 397 | languageOptions: { 398 | parser: parserVue, 399 | parserOptions: { 400 | parser: parserTypeScript, 401 | ecmaVersion: 2022, 402 | sourceType: 'module', 403 | jsxPragma: 'React', 404 | ecmaFeatures: { 405 | jsx: true 406 | }, 407 | extraFileExtensions: ['.vue'] 408 | } 409 | }, 410 | plugins: { 411 | vue: pluginVue 412 | }, 413 | processor: pluginVue.processors['.vue'], 414 | rules: { 415 | 'vue/no-v-html': 'off', 416 | 'vue/multi-word-component-names': 0, 417 | 'vue/singleline-html-element-content-newline': 'off', 418 | 'vue/require-default-prop': 'off', 419 | 'vue/html-closing-bracket-spacing': 'error', 420 | 'vue/no-unused-components': 1, 421 | 'vue/no-mutating-props': 0, 422 | 'vue/v-on-event-hyphenation': ['warn', 'always', { 423 | autofix: true 424 | }], 425 | 'vue/block-order': ['error', { 426 | 'order': ['script', 'template', 'style'] 427 | }], 428 | 'vue/padding-line-between-blocks': ['error', 'always'], 429 | 'vue/html-self-closing': ['error', { 430 | html: { 431 | void: 'never', 432 | normal: 'never', 433 | component: 'always' 434 | }, 435 | svg: 'always', 436 | math: 'always' 437 | }] 438 | } 439 | } 440 | ]) 441 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview/models/index.ts: -------------------------------------------------------------------------------- 1 | import { mockEventStreamText } from '@/data' 2 | import { sleep } from '@/utils/request' 3 | 4 | /** 5 | * 转义处理响应值为 data: 的 json 字符串 6 | * 如: 科大讯飞星火、Kimi Moonshot 等大模型的 response 7 | */ 8 | export const createParser = () => { 9 | let keepAliveShown = false 10 | 11 | const resetKeepAliveParser = () => { 12 | keepAliveShown = false 13 | } 14 | 15 | const parseJsonLikeData = (content) => { 16 | 17 | // 若是终止信号,则直接结束 18 | if (content === '[DONE]') { 19 | // 重置 keepAlive 标志 20 | keepAliveShown = false 21 | return { 22 | done: true 23 | } 24 | } 25 | 26 | if (content.startsWith('data: ')) { 27 | keepAliveShown = false 28 | const dataString = content.substring(6).trim() 29 | if (dataString === '[DONE]') { 30 | return { 31 | done: true 32 | } 33 | } 34 | try { 35 | return JSON.parse(dataString) 36 | } catch (error) { 37 | console.error('JSON 解析错误:', error) 38 | } 39 | } 40 | 41 | // 尝试直接解析 JSON 字符串 42 | try { 43 | const trimmedContent = content.trim() 44 | 45 | if (trimmedContent === ': keep-alive') { 46 | // 如果还没有显示过 keep-alive 提示,则显示 47 | if (!keepAliveShown) { 48 | keepAliveShown = true 49 | return { 50 | isWaitQueuing: true 51 | } 52 | } else { 53 | return null 54 | } 55 | } 56 | 57 | if (!trimmedContent) { 58 | return null 59 | } 60 | 61 | if (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')) { 62 | return JSON.parse(trimmedContent) 63 | } 64 | if (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')) { 65 | return JSON.parse(trimmedContent) 66 | } 67 | } catch (error) { 68 | console.error('尝试直接解析 JSON 失败:', error) 69 | } 70 | 71 | return null 72 | } 73 | return { 74 | resetKeepAliveParser, 75 | parseJsonLikeData 76 | } 77 | } 78 | 79 | export const createStreamThinkTransformer = () => { 80 | let isThinking = false 81 | 82 | const resetThinkTransformer = () => { 83 | isThinking = false 84 | } 85 | 86 | const transformStreamThinkData = (content) => { 87 | const stream = parseJsonLikeData(content) 88 | 89 | if (stream && stream.done) { 90 | return { 91 | done: true 92 | } 93 | } 94 | 95 | // DeepSeek 存在限速问题,这里做一个简单处理 96 | // https://api-docs.deepseek.com/zh-cn/quick_start/rate_limit 97 | if (stream && stream.isWaitQueuing) { 98 | return { 99 | isWaitQueuing: stream.isWaitQueuing 100 | } 101 | } 102 | 103 | if (!stream || !stream.choices || stream.choices.length === 0) { 104 | return { 105 | content: '' 106 | } 107 | } 108 | 109 | const delta = stream.choices[0].delta 110 | const contentText = delta.content || '' 111 | const reasoningText = delta.reasoning_content || '' 112 | 113 | let transformedContent = '' 114 | 115 | // 开始处理推理过程 116 | if (delta.content === null && delta.reasoning_content !== null) { 117 | if (!isThinking) { 118 | transformedContent += '' 119 | isThinking = true 120 | } 121 | transformedContent += reasoningText 122 | } 123 | // 当 content 出现时,说明推理结束 124 | else if (delta.content !== null && delta.reasoning_content === null) { 125 | if (isThinking) { 126 | transformedContent += '\n\n' 127 | isThinking = false 128 | } 129 | transformedContent += contentText 130 | } 131 | // 当为普通模型,即不包含推理字段时,直接追加 content 132 | else if (delta.content !== null && delta.reasoning_content === undefined) { 133 | isThinking = false 134 | transformedContent += contentText 135 | } 136 | 137 | return { 138 | content: transformedContent 139 | } 140 | } 141 | 142 | return { 143 | resetThinkTransformer, 144 | transformStreamThinkData 145 | } 146 | } 147 | 148 | const { resetKeepAliveParser, parseJsonLikeData } = createParser() 149 | const { resetThinkTransformer, transformStreamThinkData } = createStreamThinkTransformer() 150 | 151 | 152 | /** 153 | * 处理大模型调用暂停、异常或结束后触发的操作 154 | */ 155 | export const triggerModelTermination = () => { 156 | resetKeepAliveParser() 157 | resetThinkTransformer() 158 | } 159 | 160 | type ContentResult = { 161 | content: any 162 | } | { 163 | done: boolean 164 | } 165 | 166 | type DoneResult = { 167 | content: any 168 | isWaitQueuing?: any 169 | } & { 170 | done: boolean 171 | } 172 | 173 | export type CrossTransformFunction = (readValue: Uint8Array | string, textDecoder: TextDecoder) => DoneResult 174 | 175 | export type TransformFunction = (readValue: Uint8Array | string, textDecoder: TextDecoder) => ContentResult 176 | 177 | interface TypesModelLLM { 178 | // 模型昵称 179 | label: string 180 | // 模型标识符 181 | modelName: string 182 | // Stream 结果转换器 183 | transformStreamValue: TransformFunction 184 | // 每个大模型调用的 API 请求 185 | chatFetch: (text: string) => Promise 186 | } 187 | 188 | 189 | /** ---------------- 大模型映射列表 & Response Transform 用于处理不同类型流的值转换器 ---------------- */ 190 | 191 | /** 192 | * Mock 模拟模型的 name 193 | */ 194 | export const defaultMockModelName = 'standard' 195 | 196 | /** 197 | * 项目默认使用模型,按需修改此字段即可 198 | */ 199 | 200 | // export const defaultModelName = 'spark' 201 | export const defaultModelName = defaultMockModelName 202 | 203 | export const modelMappingList: TypesModelLLM[] = [ 204 | { 205 | label: '🧪 模拟数据模型', 206 | modelName: 'standard', 207 | transformStreamValue(readValue, textDecoder) { 208 | let content = '' 209 | if (readValue instanceof Uint8Array) { 210 | content = textDecoder.decode(readValue, { 211 | stream: true 212 | }) 213 | } else { 214 | content = readValue 215 | } 216 | return { 217 | content 218 | } 219 | }, 220 | // Mock Event Stream 用于模拟读取大模型接口 Mock 数据 221 | async chatFetch(text): Promise { 222 | // 模拟 res.body 的数据 223 | // 将 mockData 转换为 ReadableStream 224 | 225 | const mockReadableStream = new ReadableStream({ 226 | start(controller) { 227 | // 将每一行数据作为单独的 chunk 228 | mockEventStreamText.split('\n').forEach(line => { 229 | controller.enqueue(new TextEncoder().encode(`${ line }\n`)) 230 | }) 231 | controller.close() 232 | } 233 | }) 234 | await sleep(500) 235 | 236 | return new Promise((resolve) => { 237 | resolve({ 238 | body: mockReadableStream 239 | } as Response) 240 | }) 241 | } 242 | }, 243 | { 244 | label: '🐋 DeepSeek-V3', 245 | modelName: 'deepseek-v3', 246 | transformStreamValue(readValue) { 247 | const stream = transformStreamThinkData(readValue) 248 | if (stream.done) { 249 | return { 250 | done: true 251 | } 252 | } 253 | if (stream.isWaitQueuing) { 254 | return { 255 | isWaitQueuing: stream.isWaitQueuing 256 | } 257 | } 258 | return { 259 | content: stream.content 260 | } 261 | }, 262 | // Event Stream 调用大模型接口 DeepSeek 深度求索 (Fetch 调用) 263 | chatFetch(text) { 264 | const url = new URL(`${ location.origin }/deepseek/chat/completions`) 265 | const params = { 266 | } 267 | Object.keys(params).forEach(key => { 268 | url.searchParams.append(key, params[key]) 269 | }) 270 | 271 | const req = new Request(url, { 272 | method: 'post', 273 | headers: { 274 | 'Content-Type': 'application/json', 275 | 'Authorization': `Bearer ${ import.meta.env.VITE_DEEPSEEK_KEY }` 276 | }, 277 | body: JSON.stringify({ 278 | // 普通模型 V3 279 | 'model': 'deepseek-chat', 280 | stream: true, 281 | messages: [ 282 | { 283 | 'role': 'user', 284 | 'content': text 285 | } 286 | ] 287 | }) 288 | }) 289 | return fetch(req) 290 | } 291 | }, 292 | { 293 | label: '🐋 DeepSeek-R1 (推理模型)', 294 | modelName: 'deepseek-deep', 295 | transformStreamValue(readValue) { 296 | const stream = transformStreamThinkData(readValue) 297 | if (stream.done) { 298 | return { 299 | done: true 300 | } 301 | } 302 | if (stream.isWaitQueuing) { 303 | return { 304 | isWaitQueuing: stream.isWaitQueuing 305 | } 306 | } 307 | return { 308 | content: stream.content 309 | } 310 | }, 311 | // Event Stream 调用大模型接口 DeepSeek 深度求索 (Fetch 调用) 312 | chatFetch(text) { 313 | const url = new URL(`${ location.origin }/deepseek/chat/completions`) 314 | const params = { 315 | } 316 | Object.keys(params).forEach(key => { 317 | url.searchParams.append(key, params[key]) 318 | }) 319 | 320 | const req = new Request(url, { 321 | method: 'post', 322 | headers: { 323 | 'Content-Type': 'application/json', 324 | 'Authorization': `Bearer ${ import.meta.env.VITE_DEEPSEEK_KEY }` 325 | }, 326 | body: JSON.stringify({ 327 | // 推理模型 328 | 'model': 'deepseek-reasoner', 329 | stream: true, 330 | messages: [ 331 | { 332 | 'role': 'user', 333 | 'content': text 334 | } 335 | ] 336 | }) 337 | }) 338 | return fetch(req) 339 | } 340 | }, 341 | { 342 | label: '🦙 Ollama 3 大模型', 343 | modelName: 'ollama3', 344 | transformStreamValue(readValue) { 345 | const stream = parseJsonLikeData(readValue) 346 | if (stream.done) { 347 | return { 348 | done: true 349 | } 350 | } 351 | return { 352 | content: stream.message.content 353 | } 354 | }, 355 | // Event Stream 调用大模型接口 Ollama3 (Fetch 调用) 356 | chatFetch(text) { 357 | const url = new URL(`http://localhost:11434/api/chat`) 358 | const params = { 359 | } 360 | Object.keys(params).forEach(key => { 361 | url.searchParams.append(key, params[key]) 362 | }) 363 | 364 | const req = new Request(url, { 365 | mode: 'cors', 366 | method: 'post', 367 | headers: { 368 | 'Content-Type': 'application/json' 369 | }, 370 | body: JSON.stringify({ 371 | // 'model': 'deepseek-r1', // 内置深度思考响应 372 | 'model': 'llama3', 373 | stream: true, 374 | messages: [ 375 | { 376 | role: 'system', 377 | content: '你的名字叫做小O, 全程使用中文回答我的问题。' 378 | }, 379 | { 380 | role: 'user', 381 | content: text 382 | } 383 | ] 384 | }) 385 | }) 386 | return fetch(req) 387 | } 388 | }, 389 | { 390 | label: '⚡ Spark 星火大模型', 391 | modelName: 'spark', 392 | transformStreamValue(readValue) { 393 | const stream = parseJsonLikeData(readValue) 394 | if (stream.done) { 395 | return { 396 | done: true 397 | } 398 | } 399 | return { 400 | content: stream.choices[0].delta.content || '' 401 | } 402 | }, 403 | // Event Stream 调用大模型接口 Spark 星火认知大模型 (Fetch 调用) 404 | chatFetch(text) { 405 | const url = new URL(`${ location.origin }/spark/v1/chat/completions`) 406 | const params = { 407 | } 408 | Object.keys(params).forEach(key => { 409 | url.searchParams.append(key, params[key]) 410 | }) 411 | 412 | const req = new Request(url, { 413 | method: 'post', 414 | headers: { 415 | 'Content-Type': 'application/json', 416 | 'Authorization': `Bearer ${ import.meta.env.VITE_SPARK_KEY }` 417 | }, 418 | body: JSON.stringify({ 419 | 'model': '4.0Ultra', 420 | stream: true, 421 | messages: [ 422 | { 423 | role: 'system', 424 | content: '你叫小明同学,喜欢探索新的前端知识,目前正在学习 AI 大模型。你可以解决任何前端方面的问题。' 425 | }, 426 | { 427 | 'role': 'user', 428 | 'content': text 429 | } 430 | ] 431 | }) 432 | }) 433 | return fetch(req) 434 | } 435 | }, 436 | { 437 | label: '⚡ SiliconFlow 硅基流动大模型', 438 | modelName: 'siliconflow', 439 | transformStreamValue(readValue) { 440 | const stream = parseJsonLikeData(readValue) 441 | if (stream.done) { 442 | return { 443 | done: true 444 | } 445 | } 446 | return { 447 | content: stream.choices[0].delta.content || '' 448 | } 449 | }, 450 | // Event Stream 调用大模型接口 SiliconFlow 硅基流动大模型 (Fetch 调用) 451 | chatFetch(text) { 452 | const url = new URL(`${ location.origin }/siliconflow/v1/chat/completions`) 453 | const params = { 454 | } 455 | Object.keys(params).forEach(key => { 456 | url.searchParams.append(key, params[key]) 457 | }) 458 | 459 | const req = new Request(url, { 460 | method: 'post', 461 | headers: { 462 | 'Content-Type': 'application/json', 463 | 'Authorization': `Bearer ${ import.meta.env.VITE_SILICONFLOW_KEY }` 464 | }, 465 | body: JSON.stringify({ 466 | // 集成了大部分模型,可以免费使用 467 | 'model': 'THUDM/glm-4-9b-chat', 468 | stream: true, 469 | messages: [ 470 | { 471 | 'role': 'user', 472 | 'content': text 473 | } 474 | ] 475 | }) 476 | }) 477 | return fetch(req) 478 | } 479 | }, 480 | { 481 | label: '⚡ Kimi Moonshot 月之暗面大模型', 482 | modelName: 'moonshot', 483 | transformStreamValue(readValue) { 484 | const stream = parseJsonLikeData(readValue) 485 | if (stream.done) { 486 | return { 487 | done: true 488 | } 489 | } 490 | return { 491 | content: stream.choices[0].delta.content || '' 492 | } 493 | }, 494 | // Event Stream 调用大模型接口 Kimi Moonshot 月之暗面大模型 (Fetch 调用) 495 | chatFetch (text) { 496 | const url = new URL(`${ location.origin }/moonshot/v1/chat/completions`) 497 | const params = { 498 | } 499 | Object.keys(params).forEach(key => { 500 | url.searchParams.append(key, params[key]) 501 | }) 502 | 503 | const req = new Request(url, { 504 | method: 'post', 505 | headers: { 506 | 'Content-Type': 'application/json', 507 | 'Authorization': `Bearer ${ import.meta.env.VITE_MOONSHOT_KEY }` 508 | }, 509 | body: JSON.stringify({ 510 | 'model': 'moonshot-v1-8k', 511 | stream: true, 512 | messages: [ 513 | { 514 | role: 'system', 515 | content: '你是 Kimi,由 Moonshot AI 提供的人工智能助手,你更擅长中文和英文的对话。你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。Moonshot AI 为专有名词,不可翻译成其他语言。' 516 | }, 517 | { 518 | role: 'user', 519 | content: text 520 | } 521 | ] 522 | }) 523 | }) 524 | return fetch(req) 525 | } 526 | } 527 | ] 528 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | color mode 5 |

6 | 7 | 8 | # chatgpt-vue3-light-mvp 9 | 10 | [![Deploy](https://img.shields.io/badge/passing-black?style=flat&logo=Netlify&label=Netlify&color=3bb92c&labelColor=black)](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/deployments) 11 | [![GitHub Workflow Status (branch)](https://img.shields.io/badge/passing-black?style=flat&label=build&color=3bb92c)](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/deployments/Production) 12 | [![thanks](https://badgen.net/badge/thanks/♥/pink)](https://github.com/pdsuwwz) 13 | [![License](https://img.shields.io/github/license/pdsuwwz/chatgpt-vue3-light-mvp?color=466fe8)](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/LICENSE) 14 | 15 | 💭 一个可二次开发 Chat Bot 对话 Web 端原型模板, 基于 Vue3、Vite 6、TypeScript、Naive UI、Pinia、UnoCSS 等主流技术构建, 🧤简单集成大模型 API, 采用单轮 AI 问答对话模式, 每次提问独立响应, 无需上下文, 支持打字机效果流式输出, 集成 markdown-it, highlight.js, 数学公式, Mermaid 图表语法高亮预览, 💼 易于定制和快速搭建 Chat 类大语言模型产品 16 | 17 | 18 | __[🌈 Live Demo 在线体验](https://pdsuwwz.github.io/chatgpt-vue3-light-mvp)__ 19 | 20 | > [!IMPORTANT] 21 | > 本项目为最小可行产品 `(MVP)`, 仅支持**单轮对话模式**(每次提问独立响应,不保留上下文) 22 | > 23 | > 未来有机会支持多轮对话,目前暂无具体计划。💡 如有需求,可基于此项目自行扩展 ~ 24 | 25 | ## 🎉 特性 26 | 27 | * 🛠️ **核心技术栈**:__Vite 6 + Vue 3 + TypeScript + Pinia(v3) + ESLint (v9)__ 28 | * 🎨 **UI 框架**:__Naive UI 2.x__ 29 | * 🪄 **解放双手**:内置 **Unplugin Auto Import**,支持组件按需自动导入,提升开发效率 30 | * 🌟 **图标支持**:内置 **UnoCSS + Iconify**,实现原子化样式内联和图标按需自动导入 31 | * 💬 **AI 对话**:支持单轮对话,用户输入即得 AI 独立响应回复,无需上下文 32 | * 📝 **Markdown 预览**:支持多种编程语言代码高亮,集成 `markdown-it` 和 `highlight.js` 33 | * 📊 **可视化支持**:内置 `Mermaid` 解析,轻松绘制流程图和时序图等;支持 KaTex/LaTeX 数学公式渲染,助力技术文档编写 34 | * 🧪 **模拟开发模式**:提供本地模拟开发模式,无需真实 API 即可开始开发 35 | * 🔑 **环境变量管理**:通过 `.env` 文件管理 API 密钥,支持不同大模型的配置 36 | * 🌍 **大语言模型 API**:兼容 Deepseek V3/R1, Spark 星火认知大模型、Kimi Moonshot 月之暗面大模型、SiliconFlow、Ollama 等,允许自由扩展 37 | * 🚀 **灵活扩展**:轻量级模块化 MVP 设计,纯前端开发,项目结构清晰,快速搭建 AI 对话原型 38 | 39 | ### 🧠 已支持的模型 40 | 41 | 详见 [这里](#-大模型响应处理) 42 | 43 | | 模型名称 | 模型标识符 | 需要 API Key | 可否本地运行 | 备注 | 44 | |----------|----------|----------|----------|----------| 45 | | (默认类型)模拟数据模型 | `standard` | × | ✅ | 开发环境默认使用 | 46 | | Ollama (Llama 3) 大模型 | `ollama3` | × | ✅ | 需本地安装运行 Ollama 服务 | 47 | | DeepSeek-V3 | `deepseek-v3` | ✅ | × | 需配置 `VITE_DEEPSEEK_KEY` | 48 | | DeepSeek-R1 (推理模型) | `deepseek-deep` | ✅ | × | 需配置 `VITE_DEEPSEEK_KEY` | 49 | | Spark 星火大模型 | `spark` | ✅ | × | 需配置 `VITE_SPARK_KEY` | 50 | | SiliconFlow 硅基流动大模型 | `siliconflow` | ✅ | × | 需配置 `VITE_SILICONFLOW_KEY` | 51 | | Kimi Moonshot 月之暗面大模型 | `moonshot` | ✅ | × | 需配置 `VITE_MOONSHOT_KEY` | 52 | 53 | 54 | ## 前置条件 55 | 56 | * Vue 3.x 57 | * Node >= 22.12.x 58 | * Pnpm 10.x 59 | * **VS Code 插件 `dbaeumer.vscode-eslint` >= v3.0.5 (pre-release)** 60 | 61 | ## 运行效果 62 | 63 | ![image](https://github.com/user-attachments/assets/95b6c478-2522-4b6d-997f-6dabe29cf9d5) 64 | ![image](https://github.com/user-attachments/assets/4f0b250b-beab-4076-a5a1-d2fe447f0a50) 65 | 66 | * Deepseek 深度思考响应结果 67 | 68 | ![image](https://github.com/user-attachments/assets/9309fafc-c1b7-4cd3-95ed-def1275072b7) 69 | 70 | 71 | 72 | https://github.com/user-attachments/assets/01063217-13ae-4ecd-b451-5b2e4e954afc 73 | 74 | 75 | 76 | 77 | ## 安装和运行 78 | 79 | * 安装依赖 80 | 81 | ```bash 82 | pnpm i 83 | ``` 84 | 85 | * 本地开发 86 | 87 | ```bash 88 | pnpm dev 89 | ``` 90 | 91 | 本地运行后,可以通过访问 `http://localhost:2048` 来查看应用。 92 | 93 | 94 | ## 🔑 配置 API 密钥 95 | 96 | 在运行项目之前,需要设置大语言模型的 API 密钥: 97 | 98 | 1. 执行以下命令,自动创建环境变量模板文件 `.env` 文件: 99 | ```sh 100 | cp .env.template .env 101 | ``` 102 | 103 | 2. 编辑 `.env` 文件,填入你的 API 密钥 104 | 105 | ```sh 106 | VITE_SPARK_KEY=你的_星火_API_Key # 需要用冒号拼接key和secret,格式如 `key123456:secret123456` 107 | VITE_SILICONFLOW_KEY=你的_SiliconFlow_API_Key # 通常以 `sk-` 开头,如 `sk-xxxxxx` 108 | VITE_MOONSHOT_KEY=你的_Moonshot_API_Key # 通常以 `sk-` 开头,如 `sk-xxxxxx` 109 | VITE_DEEPSEEK_KEY=你的_DeepSeek_API_Key # 通常以 `sk-` 开头,如 `sk-xxxxxx` 110 | ``` 111 | 112 | 113 | ## 🛠️ API 代理配置说明 114 | 115 | 本项目采用纯前端架构,所有后端服务均由外部或本地其他服务提供。为解决开发环境中的跨域问题,项目使用了 `Vite` 的代理功能 `server.proxy`(详见[官方文档](https://vite.dev/config/server-options.html#server-proxy)) 116 | 117 | 以下是当前仓库的[代理配置](./vite.config.ts#L23) 118 | 119 | ```ts 120 | server: { 121 | // ... 122 | proxy: { 123 | '/spark': { 124 | target: 'https://spark-api-open.xf-yun.com', 125 | changeOrigin: true, 126 | ws: true, 127 | rewrite: (path) => path.replace(/^\/spark/, '') 128 | }, 129 | '/siliconflow': { 130 | target: 'https://api.siliconflow.cn', 131 | changeOrigin: true, 132 | ws: true, 133 | rewrite: (path) => path.replace(/^\/siliconflow/, '') 134 | }, 135 | '/moonshot': { 136 | target: 'https://api.moonshot.cn', 137 | changeOrigin: true, 138 | ws: true, 139 | rewrite: (path) => path.replace(/^\/moonshot/, '') 140 | }, 141 | '/deepseek': { 142 | target: 'https://api.deepseek.com', 143 | changeOrigin: true, 144 | ws: true, 145 | rewrite: (path) => path.replace(/^\/deepseek/, '') 146 | } 147 | } 148 | }, 149 | // ... 150 | ``` 151 | 152 | ### 注意事项 153 | 154 | 1. **环境限制**: 该代理配置仅在开发环境(`development`)中生效。若生产环境部署时请根据实际情况调整服务器配置 155 | 156 | 2. **路径匹配**: 请求路径需要与配置的代理路径前缀匹配,例如本地访问 `/spark/v1/chat/completions` 会被直接代理到 `https://spark-api-open.xf-yun.com/v1/chat/completions` 157 | 158 | ### 生产环境部署 159 | 160 | 生产环境建议使用以下方案之一: 161 | 162 | - 配置正确的 `CORS` 响应头 163 | - 使用 `Nginx` 反向代理 164 | - 统一域名和端口,避免跨域问题 165 | 166 | 167 | ## 🌍 模拟/真实 API 模式切换 168 | 169 | 本项目提供了一个模拟开发模式,用于在本地开发环境或 Github 等部署环境中模拟调用大模型相关策略,无需调用真实 API 接口。该模式在 [src/config/env.ts](src/config/env.ts) 文件中定义,由以下代码控制: 170 | 171 | ```ts 172 | // src/config/env.ts 173 | 174 | /** 175 | * TODO: 若是 Github 演示部署环境,则仅模拟大模型相关策略,不调接口 176 | */ 177 | export const isGithubDeployed = process.env.VITE_ROUTER_MODE === 'hash' 178 | 179 | ``` 180 | ### 默认配置 181 | 182 | 默认情况下,在开发环境,`isGithubDeployed` 会被设置为 `false`, 这意味着应用将默认使用模拟数据,但也可按照需求自行切换其他大模型 API 接口。 183 | 184 | 当部署在演示环境时,也就是本项目在线预览地址中,则使用 `hash` 路由模式, `isGithubDeployed` 会被设置为 `true`, 这意味着真实的大模型 API 接口将被禁用。 185 | 186 | ### 切换至真实 API 187 | 188 | 如果想在所有环境中使用真实的 API,你有两个选择: 189 | 190 | 1. **取消注释**:将最后一行的代码注释取消,设置 `isGithubDeployed = false` 191 | 192 | 2. **修改逻辑**:全局搜索 `isGithubDeployed`, 并修改相应的 if 判断逻辑,使其默认使用真实接口 193 | 194 | **请注意,无论选择哪种方式,都需要确保项目已经正确配置了 `.env` API 密钥** 195 | 196 | ### 接口函数修改 197 | 198 | 请求的函数已经针对目前项目内置的所有模型的响应结果做了统一处理,在([src/store/business/index.ts](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/src/store/business/index.ts#L30))的 [`createAssistantWriterStylized`](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/src/store/business/index.ts#L30) 函数,一般情况下,不需要修改此函数,除非遇到极个别模型比较特殊的响应结果格式,需要再额外处理下。 199 | 200 | 201 | --- 202 | 203 | ## 🦙 接入大语言模型 API 204 | 205 |
206 | 国内在线大模型配置
207 | 208 | 209 | **1. DeepSeek 深度求索大模型**: 210 | - **官方开放平台**:访问 [DeepSeek 官方文档](https://api-docs.deepseek.com/zh-cn) 查看使用手册 211 | - **注册**:访问 [DeepSeek 开放平台控制台](https://platform.deepseek.com/usage) 进行注册登录 212 | - **模型 & 价格**:访问 [模型 & 价格](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 查看模型价格 213 | - **Token 购买**:访问 [账户信息 - Top up 管理](https://platform.deepseek.com/top_up) 请按需购买 API 所需 Token(一般 10 块就够了,能用好久) 214 | - **创建 API 密钥**:访问 [账户信息 - API Key 管理](https://platform.deepseek.com/api_keys) 新建 API 密钥 215 | 216 | ![image](https://github.com/user-attachments/assets/f3ad036f-9938-4ff5-b301-7ca645346517) 217 | 218 | - **接口说明**:[首次调用 API](https://api-docs.deepseek.com/zh-cn) 219 | - **在线调试**:[官方 Chat Completions 在线调试](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion) 220 | - **配置到本仓库**:将创建的 API 密钥填入 `.env` 文件中的 `VITE_DEEPSEEK_KEY` 环境变量 221 | - **DeepSeek 现已支持的大模型**:[模型 & 价格](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 222 | - **DeepSeek 现已支持的大模型-接口调用查看**:[通过接口查看](https://api-docs.deepseek.com/zh-cn/api/list-models) 223 | 224 | ![image](https://github.com/user-attachments/assets/8aa98691-94ac-4516-a9c4-18ac2da92c01) 225 | 226 | 227 | **2. Spark 星火认知大模型**: 228 | 229 | - **注册**:访问 [星火大模型 API](https://xinghuo.xfyun.cn/sparkapi) 进行注册并登录 230 | - **获取 API 密钥**:访问 [控制台](https://console.xfyun.cn/services/bm4) 获取 `APIKey` 和 `APISecret` 231 | 232 | ![image](https://github.com/user-attachments/assets/8761d59d-b4c3-41d5-9c58-14a5b0f4389c) 233 | 234 | - **接口说明**:[spark HTTP 调用文档](https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html#_1-%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E) 235 | 236 | ![image](https://github.com/user-attachments/assets/71334353-b6c1-4778-ae21-95e98860d2b1) 237 | 238 | - **配置到本仓库**:将创建的 `APIKey` 和 `APISecret` 密钥用冒号 `:` 拼接填入到 `.env` 文件中的 `VITE_SPARK_KEY` 环境变量 239 | 240 | 241 | **3. SiliconFlow 大模型**: 242 | - **注册**:访问 [SiliconFlow 官网](https://siliconflow.cn/zh-cn/siliconcloud) 进行注册登录并创建 API 密钥 243 | - **创建 API 密钥**:访问 [账户管理 - API 密钥](https://cloud.siliconflow.cn/account/ak) 创建新 API 密钥 244 | 245 | ![image](https://github.com/user-attachments/assets/31e1ef13-869a-4695-a7c0-054d2c3e877f) 246 | 247 | - **接口说明**:[官方 Chat Completions 在线调试](https://docs.siliconflow.cn/reference/chat-completions-3) 248 | - **配置到本仓库**:将创建的 API 密钥填入 `.env` 文件中的 `VITE_SILICONFLOW_KEY` 环境变量 249 | - **SiliconFlow现已支持的大模型**:[模型列表](https://siliconflow.cn/zh-cn/models) 250 | 251 | ![image](https://github.com/user-attachments/assets/f320f495-cb17-48ff-99c4-aaedbf87fc84) 252 | 253 | 254 | **4. Kimi Moonshot 月之暗面大模型**: 255 | - **官方开放平台**:访问 [Moonshot 开放平台](https://platform.moonshot.cn/docs/intro) 查看使用手册 256 | - **注册**:访问 [Moonshot 开放平台控制台](https://platform.moonshot.cn/console) 进行注册登录 257 | - **创建 API 密钥**:访问 [账户信息 - API Key 管理](https://platform.moonshot.cn/console/api-keys) 新建 API 密钥 258 | 259 | ![image](https://github.com/user-attachments/assets/718994f4-c05f-49e3-af4f-f36358413215) 260 | 261 | 262 | - **接口说明**:[官方示例代码 Chat Completion](https://platform.moonshot.cn/docs/api/chat#chat-completion) 263 | - **配置到本仓库**:将创建的 API 密钥填入 `.env` 文件中的 `VITE_MOONSHOT_KEY` 环境变量 264 | - **Moonshot现已支持的大模型**:[模型列表](https://platform.moonshot.cn/docs/api/chat#list-models) 265 | 266 | ![image](https://github.com/user-attachments/assets/5d615123-20c3-44cd-a7cb-17f4ed42ced9) 267 | 268 |
269 | 270 |
271 | 使用本地 Ollama 大模型
272 | 273 | **Ollama 大模型**: 274 | - **安装**:Ollama3 不需要 API 密钥,只需要在本地安装并运行 Ollama 即可。请参考 Ollama 官方文档进行安装:[Ollama GitHub](https://github.com/ollama/ollama) 275 | - **Ollama现已支持的大模型**:[模型列表](https://ollama.com/search) 276 | - **运行**:运行 Ollama3 服务,直接执行 `ollama run <模型名称>`, 如: `ollama run llama3`, 运行后确保其在 `http://localhost:11434` 运行 277 | 278 | ![image](https://github.com/user-attachments/assets/f3955060-a22d-4db8-b162-7393c10403f6) 279 | 280 | - **查看运行状态**:执行 `ollama list`命令可查看当前正在运行的 Ollama 模型 281 | 282 | ![image](https://github.com/user-attachments/assets/8c6cf637-fd5b-45b5-93c2-f58927b7110c) 283 | 284 | 285 |
286 | 287 | --- 288 | 289 | ## 🔌 大模型响应处理 290 | 291 | 由于不同大模型的 API 响应数据结构存在差异,本项目通过**统一的模型映射机制**和**响应转换函数**实现了多模型的无缝集成。核心逻辑封装在 [详见代码](src/components/MarkdownPreview/models/index.ts#L85) 中,支持灵活扩展和定制 292 | 293 | ### 核心设计 294 | 295 |
296 | 1、模型映射机制
297 | 298 | 通过 `modelMappingList` 定义支持的模型列表,每个模型包含以下关键属性: 299 | 300 | | 属性名称 | 类型 | 说明 301 | |----------|----------|----------| 302 | | label | string | 模型展示名称(仅用于 UI 显示) | 303 | | modelName | string | 模型唯一标识符(用于逻辑判断)| 304 | | transformStreamValue | TransformFunction | 流式响应数据的转换函数,用于解析不同模型的响应结构 | 305 | | chatFetch | (text: string) => Promise | 模型 API 请求函数,封装了请求参数和调用逻辑 | 306 | 307 |
308 | 309 |
310 | 2、响应转换函数
311 | 312 | 每个模型通过 `transformStreamValue` 函数处理流式响应数据,核心逻辑包括: 313 | 314 | * 解析原始响应数据(`Uint8Array` 或 `string`) 315 | * 提取有效内容字段(如 `content`) 316 | * 处理特殊终止信号(如 `[DONE]`) 317 | 318 |
319 | 320 | 321 |
322 | 3、统一接口
323 | 324 | 通过 `createAssistantWriterStylized` 方法封装模型调用逻辑,不用太关心底层实现细节,只需通过 `modelName` 切换模型。 325 | 326 | * 解析原始响应数据(`Uint8Array` 或 `string`) 327 | * 提取有效内容字段(如 `content`) 328 | * 处理特殊终止信号(如 `[DONE]`) 329 | 330 |
331 | 332 | 👉 可在 [src/store/business/index.ts](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/src/store/business/index.ts) 中查看更多实现细节 333 | 334 | ### 🧠 已支持的模型 335 | 336 | | 模型名称 | 模型标识符 | 需要 API Key | 可否本地运行 | 备注 | 337 | |----------|----------|----------|----------|----------| 338 | | (默认类型)模拟数据模型 | `standard` | × | ✅ | 开发环境默认使用 | 339 | | Ollama (Llama 3) 大模型 | `ollama3` | × | ✅ | 需本地安装运行 Ollama 服务 | 340 | | DeepSeek-V3 | `deepseek-v3` | ✅ | × | 需配置 `VITE_DEEPSEEK_KEY` | 341 | | DeepSeek-R1 (推理模型) | `deepseek-deep` | ✅ | × | 需配置 `VITE_DEEPSEEK_KEY` | 342 | | Spark 星火大模型 | `spark` | ✅ | × | 需配置 `VITE_SPARK_KEY` | 343 | | SiliconFlow 硅基流动大模型 | `siliconflow` | ✅ | × | 需配置 `VITE_SILICONFLOW_KEY` | 344 | | Kimi Moonshot 月之暗面大模型 | `moonshot` | ✅ | × | 需配置 `VITE_MOONSHOT_KEY` | 345 | 346 | 347 | ### 🔬 主要实现 348 | 349 | - **modelMappingList**: 定义了支持的每个大模型的 modelName, 响应结果的处理以及请求 API 函数,[详见代码](src/components/MarkdownPreview/models/index.ts#L199) 350 | - **transformStreamValue**: 包含了针对各种模型的响应结果转换函数,[详见代码](src/components/MarkdownPreview/models/index.ts#L199) 351 | - **MarkdownPreview 组件**: 接收 `model` 和 `transformStreamFn` props 属性,根据不同模型类型处理流式响应,[详见代码](src/components/MarkdownPreview/index.vue#L9) 352 | 353 | > 本项目的 `MarkdownPreview` 组件接收 `model` props 属性是为了回显不同的 `Placeholder`,如果你不需要可直接删掉该 props 参数及对应的回显逻辑 354 | 355 | ### 📚 使用示例 356 | 357 | 在使用 [`MarkdownPreview`](src/components/MarkdownPreview/index.vue) 组件时,通过设置 `model` 和 `transformStreamFn` 属性来指定当前使用的大模型类型: 358 | 359 | ```html 360 | 368 | ``` 369 | 370 | 其中 `model` 和 `transformStreamFn` 的值会根据用户选择的下拉框选项自动映射到对应的模型,并实时由全局 pinia [src/store/business/index.ts](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/src/store/business/index.ts#L22) 状态管理来管控: 371 | 372 | ```ts 373 | export const useBusinessStore = defineStore('business-store', { 374 | state: (): BusinessState => { 375 | return { 376 | systemModelName: defaultModelName 377 | } 378 | }, 379 | getters: { 380 | currentModelItem (state) { 381 | return modelMappingList.find(v => v.modelName === state.systemModelName) 382 | } 383 | }, 384 | actions: { 385 | // ... 386 | } 387 | }) 388 | ``` 389 | 390 | 在模拟开发环境下,默认使用 `standard` 模型,同时也可以自定义修改为指定模型(尝试基于本项目二次开发的话,可以重点看下这个文件 [models/index.ts](src/components/MarkdownPreview/models/index.ts#L190)),具体的模型类型可以根据需求进行自己二次配置: 391 | 392 | ```ts 393 | /** 394 | * Mock 模拟模型的 name 395 | */ 396 | export const defaultMockModelName = 'standard' 397 | 398 | /** 399 | * 项目默认使用模型,按需修改此字段即可 400 | */ 401 | 402 | // export const defaultModelName = 'spark' 403 | export const defaultModelName = defaultMockModelName 404 | ``` 405 | 406 | 407 | #### 💡 提示 408 | 409 | > `currentModelItem` 计算属性会根据模型映射自动选择对应的模型,也可以手动指定模型 410 | > 411 | > 如果后端返回的是可直接渲染的纯字符串(而非 JSON),`standard` 模型将适用于所有这种情况 412 | 413 | ## 🌹 支持 414 | 415 | 如果你喜欢这个项目或发现有用,可以点右上角 [`Star`](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp) 支持一下,你的支持是我们不断改进的动力,感谢! ^_^ 416 | 417 | 418 | ## 🌟 相关项目 419 | 420 | 以下是一些开发者和团队正在使用、参考或受本项目启发的项目: 421 | 422 | | 项目名 | 简介 | 423 | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | 424 | | [大模型数据助手](https://github.com/apconw/sanic-web) | 一个轻量级的开源大模型应用项目,支持多模型适配、数据问答和 RAG 检索,旨在简化大模型应用开发。 | 425 | 426 | ### 📢 社区贡献 427 | 428 | 💡 如果您的项目也在使用或借鉴了本项目,我们诚挚欢迎您: 429 | 430 | - 通过提交 [Issue](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/issues) 分享您的项目链接 431 | - 提交 Pull Request (PR) 将您的项目添加到列表中 432 | 433 | 434 | ## 🚨 免责声明 435 | 436 | 本模板作为 AI 对话原型技术参考方案,使用者需知悉以下风险及义务: 437 | 438 | - **技术风险**:依赖框架(Vue3/Vite/Naive UI等)存在版本迭代风险,第三方组件(MarkdownIt/Mermaid/KaTex等)以原始仓库规范为准,API 服务商条款变更可能导致功能异常 439 | - **技术局限性**:当前实现方案存在功能边界(如对话模式限制),技术选型需根据实际场景评估 440 | - **使用限制**:禁止用于违反大模型服务条款或数据隐私法规的场景,使用者需自行完成 API 密钥安全管理 441 | - **责任免除**:不承诺大模型输出准确性及业务场景适配性,因使用/二次开发导致的后果由使用者自行承担 442 | 443 | 使用本 AI 模板即视为已阅读并同意上述条款,且自愿承担所有技术及法律风险 444 | 445 | ## License 446 | 447 | [MIT](./LICENSE) License | Copyright © 2020-PRESENT [Wisdom](https://github.com/pdsuwwz) 448 | 449 | -------------------------------------------------------------------------------- /src/assets/fonts/iconfont.js: -------------------------------------------------------------------------------- 1 | !function(o){var h,l,t,c,i,v,e='',a=(a=document.getElementsByTagName("script"))[a.length-1].getAttribute("data-injectcss");if(a&&!o.__iconfont__svg__cssinject__){o.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(o){console&&console.log(o)}}function m(){i||(i=!0,t())}h=function(){var o,h,l,t;(t=document.createElement("div")).innerHTML=e,e=null,(l=t.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",o=l,(h=document.body).firstChild?(t=o,(l=h.firstChild).parentNode.insertBefore(t,l)):h.appendChild(o))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(h,0):(l=function(){document.removeEventListener("DOMContentLoaded",l,!1),h()},document.addEventListener("DOMContentLoaded",l,!1)):document.attachEvent&&(t=h,c=o.document,i=!1,(v=function(){try{c.documentElement.doScroll("left")}catch(o){return void setTimeout(v,50)}m()})(),c.onreadystatechange=function(){"complete"==c.readyState&&(c.onreadystatechange=null,m())})}(window); --------------------------------------------------------------------------------