Compare commits

...

13 Commits
main ... mono

Author SHA1 Message Date
谢亚昕 45d2dcac9a feat: Add bunfig.toml for registry configuration and update README with requirements 2 months ago
dash e8d7866b8b feat: 删除不再使用的 bunfig.toml 文件,更新相关依赖和配置 2 months ago
谢亚昕 0ba614355e fix: Update bundle command in package.json to include all files in dist directory 2 months ago
谢亚昕 8753cde672 feat: Refactor environment handling and enhance build scripts 2 months ago
谢亚昕 848257759b feat: Update build scripts and enhance environment configuration 2 months ago
dash a372428940 feat: 添加 DeepSeek API 密钥支持,更新相关配置 2 months ago
谢亚昕 e578bcfa8f feat: Enhance chat functionality and add new components 2 months ago
谢亚昕 91ca9904f2 feat: Enhance chat functionality and UI components 2 months ago
谢亚昕 24d266b3da Merge branch 'mono' of ssh://git.xieyaxin.top:8892/topuser/koa-ssr into mono 2 months ago
谢亚昕 844aa7f0b6 feat: Integrate ofetch for improved data fetching 2 months ago
dash b94114513b feat: 添加主题支持和全局样式,包含亮色和暗色主题变量 2 months ago
dash 1cd96abed6 feat: Add middleware for response time logging and static file serving 2 months ago
dash 04e655166f feat: mono init 2 months ago
  1. 2
      .bunfig.toml
  2. 26
      .env.example
  3. 14
      README.md
  4. BIN
      bun.lockb
  5. 17
      components.d.ts
  6. 17
      index.html
  7. 8
      internal/helper/package.json
  8. 67
      internal/helper/src/cookie.ts
  9. 9
      internal/helper/src/env.ts
  10. 16
      internal/helper/src/path.ts
  11. 63
      internal/x/components/ClientOnly.vue
  12. 0
      internal/x/composables/README.md
  13. 0
      internal/x/composables/cookieUtils.ts
  14. 10
      internal/x/composables/ssrContext.ts
  15. 0
      internal/x/composables/useCookie.ts
  16. 33
      internal/x/composables/useFetch.ts
  17. 10
      internal/x/composables/useShareContext.ts
  18. 5
      internal/x/package.json
  19. 37
      package.json
  20. 34
      packages/client/.gitignore
  21. 15
      packages/client/README.md
  22. 263
      packages/client/auto-imports.d.ts
  23. 29
      packages/client/components.d.ts
  24. 67
      packages/client/index.html
  25. 38
      packages/client/package.json
  26. 1
      packages/client/public/deepseek.svg
  27. 0
      packages/client/public/vite.svg
  28. 12
      packages/client/src/App.vue
  29. 48
      packages/client/src/assets/styles/css/reset.css
  30. 28
      packages/client/src/assets/styles/scss/_global.scss
  31. 61
      packages/client/src/assets/styles/scss/common.scss
  32. 0
      packages/client/src/assets/vue.svg
  33. 56
      packages/client/src/components/AiDemo/_/VueNodeRenderer.vue
  34. 102
      packages/client/src/components/AiDemo/_/sseData.ts
  35. 158
      packages/client/src/components/AiDemo/index.vue
  36. 64
      packages/client/src/components/ChatBox/_/Msg.vue
  37. 61
      packages/client/src/components/ChatBox/_/Node.vue
  38. 102
      packages/client/src/components/ChatBox/_/sseData.ts
  39. 290
      packages/client/src/components/ChatBox/index.vue
  40. 31
      packages/client/src/components/ChatBox/prompt.txt
  41. 4
      packages/client/src/components/CookieDemo.vue
  42. 3
      packages/client/src/components/DataFetch.vue
  43. 0
      packages/client/src/components/HelloWorld.vue
  44. 73
      packages/client/src/components/QuillEditor/_Editor.vue
  45. 10
      packages/client/src/components/QuillEditor/index.vue
  46. 135
      packages/client/src/components/QuillEditor/useQuill/index.ts
  47. 75
      packages/client/src/components/QuillEditor/useQuill/quill-shim.ts
  48. 75
      packages/client/src/components/QuillEditor/useQuill/quill-video.ts
  49. 2
      packages/client/src/components/SimpleTest.vue
  50. 111
      packages/client/src/components/ThemeDemo.vue
  51. 79
      packages/client/src/composables/useChat/Chat.ts
  52. 59
      packages/client/src/composables/useChat/index.ts
  53. 72
      packages/client/src/composables/useChat/modules/MessageHelper.ts
  54. 171
      packages/client/src/composables/useChat/modules/error.ts
  55. 188
      packages/client/src/composables/useChat/provider/Ollama.ts
  56. 178
      packages/client/src/composables/useChat/provider/Openai.ts
  57. 87
      packages/client/src/composables/useChat/type.ts
  58. 8
      packages/client/src/composables/useGlobal/index.ts
  59. 96
      packages/client/src/composables/useScroll/index.ts
  60. 60
      packages/client/src/entry-client.ts
  61. 132
      packages/client/src/entry-server.ts
  62. 28
      packages/client/src/layouts/base.vue
  63. 22
      packages/client/src/main.ts
  64. 54
      packages/client/src/pages/_M.vue
  65. 31
      packages/client/src/pages/index.vue
  66. 34
      packages/client/src/pages/test/index.vue
  67. 28
      packages/client/src/pages/test/index2.vue
  68. 1
      packages/client/src/pages/test/readme.md
  69. 28
      packages/client/src/router/index.ts
  70. 20
      packages/client/src/store/auth.ts
  71. 66
      packages/client/src/typed-router.d.ts
  72. 2
      packages/client/src/vite-env.d.ts
  73. 27
      packages/client/src/vue.d.ts
  74. 34
      packages/client/tsconfig.json
  75. 0
      packages/client/tsconfig.node.json
  76. 81
      packages/client/vite.config.ts
  77. 10
      packages/server/drizzle.config.ts
  78. 8
      packages/server/drizzle/0000_thick_may_parker.sql
  79. 70
      packages/server/drizzle/meta/0000_snapshot.json
  80. 13
      packages/server/drizzle/meta/_journal.json
  81. 43
      packages/server/package.json
  82. 29
      packages/server/src/api/main.ts
  83. 19
      packages/server/src/app.ts
  84. 318
      packages/server/src/base/BaseController.ts
  85. 38
      packages/server/src/booststap.ts
  86. 4
      packages/server/src/db/index.ts
  87. 8
      packages/server/src/db/schema.ts
  88. 11
      packages/server/src/env.d.ts
  89. 66
      packages/server/src/jobs/index.ts
  90. 12
      packages/server/src/jobs/jobs/exampleJob.ts
  91. 63
      packages/server/src/jobs/scheduler.ts
  92. 59
      packages/server/src/logger.ts
  93. 38
      packages/server/src/middleware/Auth/index.ts
  94. 100
      packages/server/src/middleware/Controller/index.ts
  95. 59
      packages/server/src/middleware/ResponseTime/index.ts
  96. 186
      packages/server/src/middleware/Send/index.ts
  97. 74
      packages/server/src/middleware/Send/resolve-path.ts
  98. 16
      packages/server/src/middleware/Session/index.ts
  99. 104
      packages/server/src/middleware/install.ts
  100. 51
      packages/server/src/modules/Job/controller/index.ts

2
.bunfig.toml

@ -0,0 +1,2 @@
[install]
registry = "https://registry.npmmirror.com/"

26
.env.example

@ -0,0 +1,26 @@
# 环境变量配置文件
# 服务器配置
NODE_ENV=development
PORT=3000
HOST=localhost
# 安全配置
AI_APIKEY=ai-apikey
SESSION_SECRET=your-session-secret-key-here,another-secret-key
JWT_SECRET=your-jwt-secret-key-must-be-at-least-32-characters-long
# 数据库配置
DB_PATH=./data/database.db
# 日志配置
LOG_LEVEL=info
LOG_FILE=./logs/app.log
# 缓存配置
REDIS_HOST=localhost
REDIS_PORT=6379
# 任务调度配置
JOBS_ENABLED=true
TZ=Asia/Shanghai

14
README.md

@ -1 +1,13 @@
# 基于koa实现的简易ssr
# 基于koa实现的简易ssr
- https://segmentfault.com/a/1190000042389086
## 试试grpc,实现node与python通信,扩展更多的功能。
https://grpc.org.cn/docs/languages/node/quickstart/
https://www.doubao.com/chat/23869592666505474
## 要求
bun < 1.2.0

BIN
bun.lockb

Binary file not shown.

17
components.d.ts

@ -1,17 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
CookieDemo: typeof import('./src/components/CookieDemo.vue')['default']
DataFetch: typeof import('./src/components/DataFetch.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
SimpleTest: typeof import('./src/components/SimpleTest.vue')['default']
}
}

17
index.html

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<!--app-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>

8
internal/helper/package.json

@ -0,0 +1,8 @@
{
"name": "helper",
"exports": {
"./*": {
"import": "./src/*.ts"
}
}
}

67
internal/helper/src/cookie.ts

@ -0,0 +1,67 @@
export type CookieOptions = {
path?: string
domain?: string
expires?: Date | string | number
maxAge?: number
secure?: boolean
httpOnly?: boolean
sameSite?: 'lax' | 'strict' | 'none'
}
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
const enc = encodeURIComponent
let cookie = `${name}=${enc(value)}`
if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}`
if (options.expires != null) {
const date = typeof options.expires === 'number' ? new Date(options.expires) : new Date(options.expires)
cookie += `; Expires=${date.toUTCString()}`
}
if (options.domain) cookie += `; Domain=${options.domain}`
if (options.path) cookie += `; Path=${options.path}`
if (options.secure) cookie += `; Secure`
if (options.httpOnly) cookie += `; HttpOnly`
if (options.sameSite) cookie += `; SameSite=${options.sameSite === 'none' ? 'None' : options.sameSite === 'lax' ? 'Lax' : 'Strict'}`
return cookie
}
export function parseCookieHeader(header: string | undefined): Record<string, string> {
const raw = header || ''
const out: Record<string, string> = {}
raw.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => {
const idx = kv.indexOf('=')
const k = idx >= 0 ? kv.slice(0, idx) : kv
const v = idx >= 0 ? decodeURIComponent(kv.slice(idx + 1)) : ''
out[k] = v
})
return out
}
export function parseDocumentCookies(): Record<string, string> {
// @ts-ignore
if (typeof document === 'undefined') return {}
// @ts-ignore
return parseCookieHeader(document.cookie)
}
/**
// server 侧中间件
import { parseCookieHeader, serializeCookie } from './src/compose/cookieUtils'
app.use(async (ctx, next) => {
const cookies = parseCookieHeader(ctx.request.headers.cookie as string)
// 读取
const token = cookies['demo_token']
// 写入(HttpOnly 更安全)
if (!token) {
const setItem = serializeCookie('demo_token', 'from-mw', {
httpOnly: true, path: '/', sameSite: 'lax'
})
ctx.set('Set-Cookie', [setItem])
}
await next()
})
*/

9
internal/helper/src/env.ts

@ -0,0 +1,9 @@
const port = process.env.PORT || 5173
const base = process.env.BASE || '/'
export const Env = {
port: Number(port),
base
}

16
internal/helper/src/path.ts

@ -0,0 +1,16 @@
import path from "node:path"
import fs from "node:fs/promises"
export function getPathByRoot(...argus: string[]) {
return path.resolve(import.meta.dir, '../../..', ...argus)
}
// 生产环境路径配置
export const TemplateHtml = process.env.NODE_ENV === 'production' ? await fs.readFile('./client/index.html', 'utf-8') : ""
export const serverPublic = path.resolve("./public")
export const serverModules = path.resolve("./modules")
export const jobsDir = path.resolve("./jobs/jobs")
export const clientRoot = path.resolve("./client")
export const ssrManifest = path.resolve('./client/.vite/ssr-manifest.json')
export const entryServer = path.resolve('./server/entry-server.js')
export const logDir = path.resolve('./logs')

63
internal/x/components/ClientOnly.vue

@ -0,0 +1,63 @@
<script lang="ts">
import {
cloneVNode,
createElementBlock,
defineComponent,
getCurrentInstance,
h,
InjectionKey,
onMounted,
provide,
shallowRef,
SlotsType,
VNode,
} from "vue";
export const clientOnlySymbol: InjectionKey<boolean> =
Symbol.for("nuxt:client-only");
export default defineComponent({
name: "ClientOnly",
inheritAttrs: false,
props: ["fallback", "placeholder", "placeholderTag", "fallbackTag"],
...(import.meta.env.DEV && {
slots: Object as SlotsType<{
default?: () => VNode[];
/**
* Specify a content to be rendered on the server and displayed until `<ClientOnly>` is mounted in the browser.
*/
fallback?: () => VNode[];
placeholder?: () => VNode[];
}>,
}),
setup(props, { slots, attrs }) {
const mounted = shallowRef(false);
onMounted(() => {
mounted.value = true;
});
//
// const vm = getCurrentInstance();
// if (vm) {
// vm._nuxtClientOnly = true;
// }
provide(clientOnlySymbol, true);
return () => {
if (mounted.value) {
const vnodes = slots.default?.();
if (vnodes && vnodes.length === 1) {
return [cloneVNode(vnodes[0]!, attrs)];
}
return vnodes;
}
const slot = slots.fallback || slots.placeholder;
if (slot) {
return h(slot);
}
const fallbackStr = props.fallback || props.placeholder || "";
const fallbackTag = props.fallbackTag || props.placeholderTag || "span";
return createElementBlock(fallbackTag, attrs, fallbackStr);
};
},
});
</script>

0
src/compose/README.md → internal/x/composables/README.md

0
src/compose/cookieUtils.ts → internal/x/composables/cookieUtils.ts

10
src/compose/ssrContext.ts → internal/x/composables/ssrContext.ts

@ -3,6 +3,7 @@
export interface SSRContext {
cache?: Map<string, any>
cookies?: Record<string, string>
piniaState?: object
setCookies?: string[]
[key: string]: any
}
@ -10,11 +11,16 @@ export interface SSRContext {
export function createSSRContext(): SSRContext {
return {
cache: new Map(),
piniaState: {},
cookies: {},
setCookies: []
}
}
/**
* SSR window
*
*/
export function hydrateSSRContext(context: SSRContext): void {
if (typeof window !== 'undefined') {
if (context.cache && Array.isArray(context.cache)) {
@ -24,6 +30,10 @@ export function hydrateSSRContext(context: SSRContext): void {
}
}
/**
* SSR
*
*/
export function clearSSRContext(): void {
if (typeof window !== 'undefined') {
delete (window as any).__SSR_CONTEXT__

0
src/compose/useCookie.ts → internal/x/composables/useCookie.ts

33
src/compose/useFetch.ts → internal/x/composables/useFetch.ts

@ -2,6 +2,7 @@ import { ref, onMounted, onServerPrefetch, Ref } from 'vue'
import { getCurrentInstance } from 'vue'
import type { SSRContext } from './ssrContext'
import { resolveSSRContext } from './ssrContext'
import { $fetch } from 'ofetch'
// 全局数据缓存,用于 SSR 数据共享
const globalCache = new Map<string, any>()
@ -99,7 +100,7 @@ export function useFetch<T = any>(
}
// 执行请求
const response = await fetch(fetchUrl, requestInit)
const response = await $fetch(fetchUrl, requestInit)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
@ -217,33 +218,3 @@ export function useFetch<T = any>(
execute
}
}
/**
* SSR
*
*/
// 删除 createSSRContext,这个职责移动到 ssrContext.ts
/**
* SSR window
*
*/
export function hydrateSSRContext(context: SSRContext): void {
if (typeof window !== 'undefined') {
// 确保 Map 对象正确重建
if (context.cache && Array.isArray(context.cache)) {
context.cache = new Map(context.cache)
}
(window as any).__SSR_CONTEXT__ = context
}
}
/**
* SSR
*
*/
export function clearSSRContext(): void {
if (typeof window !== 'undefined') {
delete (window as any).__SSR_CONTEXT__
}
}

10
internal/x/composables/useShareContext.ts

@ -0,0 +1,10 @@
import { useSSRContext } from "vue"
export function useShareCache(): Map<string, any> | null {
if (typeof window === 'undefined') {
const ssrContext = useSSRContext()
return ssrContext?.cache || null
} else {
return (window as any).__SSR_CONTEXT__?.cache || null
}
}

5
internal/x/package.json

@ -0,0 +1,5 @@
{
"name": "x",
"main": "index.ts",
"type": "module"
}

37
package.json

@ -2,33 +2,38 @@
"name": "koa-ssr",
"type": "module",
"workspaces": [
"packages/*"
"packages/*",
"internal/*"
],
"scripts": {
"dev": "bun run --watch server.ts",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
"preview": "cross-env NODE_ENV=production bun run server.ts",
"check": "vue-tsc"
"postinstall": "node scripts/fix-type-router.js",
"dev": "bun run --hot packages/server/src/booststap.ts",
"db": "bun run --env-file=.env --filter server db ",
"build:client": "bun run --filter client build",
"start": "cd dist && cross-env NODE_ENV=production bun run booststap.js",
"build:all": "cross-env NODE_ENV=production tsup --config tsup.config.ts",
"build": "rimraf dist && bun run build:client && bun run build:all && node scripts/build.js",
"tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false",
"tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false"
},
"devDependencies": {
"@types/bun": "latest",
"@types/koa": "^3.0.0",
"@types/koa-send": "^4.1.6",
"client": "workspace:*",
"cross-env": "^10.1.0",
"fast-glob": "^3.3.3",
"helper": "workspace:*",
"rimraf": "^6.0.1",
"server": "workspace:*",
"tsup": "^8.5.0",
"unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0"
"vite": "^7.1.7",
"vite-plugin-devtools-json": "^1.0.0",
"x": "workspace:*"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"koa": "^3.0.1",
"koa-connect": "^2.1.0",
"koa-send": "^5.0.1",
"vite": "^7.1.7",
"vue": "^3.5.22"
}
}

34
packages/client/.gitignore

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/client/README.md

@ -0,0 +1,15 @@
# client
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

263
packages/client/auto-imports.d.ts

@ -0,0 +1,263 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const $fetch: typeof import('ofetch').$fetch
const AIProviderNoImplementedChatError: typeof import('./src/composables/useChat/modules/error').AIProviderNoImplementedChatError
const AIProviderNoImplementedPaintError: typeof import('./src/composables/useChat/modules/error').AIProviderNoImplementedPaintError
const ApiError: typeof import('./src/composables/useChat/modules/error').ApiError
const BaseError: typeof import('./src/composables/useChat/modules/error').BaseError
const Chat: typeof import('./src/composables/useChat/Chat').Chat
const ChatboxAIAPIError: typeof import('./src/composables/useChat/modules/error').ChatboxAIAPIError
const EffectScope: typeof import('vue').EffectScope
const MessageHelper: typeof import('./src/composables/useChat/modules/MessageHelper').MessageHelper
const NetworkError: typeof import('./src/composables/useChat/modules/error').NetworkError
const Ollama: typeof import('./src/composables/useChat/provider/Ollama').Ollama
const OpenAI: typeof import('./src/composables/useChat/provider/Openai').OpenAI
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const clearSSRContext: typeof import('../../internal/x/composables/ssrContext').clearSSRContext
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const createSSRContext: typeof import('../../internal/x/composables/ssrContext').createSSRContext
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext').hydrateSSRContext
const inject: typeof import('vue').inject
const injectHead: typeof import('@unhead/vue').injectHead
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const parseCookieHeader: typeof import('../../internal/x/composables/cookieUtils').parseCookieHeader
const parseDocumentCookies: typeof import('../../internal/x/composables/cookieUtils').parseDocumentCookies
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const render: typeof import('../../internal/x/composables/README.md').render
const resolveComponent: typeof import('vue').resolveComponent
const resolveSSRContext: typeof import('../../internal/x/composables/ssrContext').resolveSSRContext
const serializeCookie: typeof import('../../internal/x/composables/cookieUtils').serializeCookie
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useAuthStore: typeof import('./src/store/auth').useAuthStore
const useChat: typeof import('./src/composables/useChat/index').useChat
const useCookie: typeof import('../../internal/x/composables/useCookie').useCookie
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useFetch: typeof import('../../internal/x/composables/useFetch').useFetch
const useGlobal: typeof import('./src/composables/useGlobal/index').useGlobal
const useHead: typeof import('@unhead/vue').useHead
const useHeadSafe: typeof import('@unhead/vue').useHeadSafe
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useScroll: typeof import('./src/composables/useScroll/index').useScroll
const useSeoMeta: typeof import('@unhead/vue').useSeoMeta
const useServerHead: typeof import('@unhead/vue').useServerHead
const useServerHeadSafe: typeof import('@unhead/vue').useServerHeadSafe
const useServerSeoMeta: typeof import('@unhead/vue').useServerSeoMeta
const useShareCache: typeof import('../../internal/x/composables/useShareContext').useShareCache
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { Chat } from './src/composables/useChat/Chat'
import('./src/composables/useChat/Chat')
// @ts-ignore
export type { MessageHelper } from './src/composables/useChat/modules/MessageHelper'
import('./src/composables/useChat/modules/MessageHelper')
// @ts-ignore
export type { BaseError, ApiError, NetworkError, AIProviderNoImplementedPaintError, AIProviderNoImplementedChatError, ChatboxAIAPIError } from './src/composables/useChat/modules/error'
import('./src/composables/useChat/modules/error')
// @ts-ignore
export type { Ollama, OllamaModelConfig } from './src/composables/useChat/provider/Ollama'
import('./src/composables/useChat/provider/Ollama')
// @ts-ignore
export type { OpenAI, OpenAIModelConfig } from './src/composables/useChat/provider/Openai'
import('./src/composables/useChat/provider/Openai')
// @ts-ignore
export type { MessageFn, ResponseFn, ModelConfig } from './src/composables/useChat/type'
import('./src/composables/useChat/type')
// @ts-ignore
export type { CookieOptions } from '../../internal/x/composables/cookieUtils'
import('../../internal/x/composables/cookieUtils')
// @ts-ignore
export type { SSRContext } from '../../internal/x/composables/ssrContext'
import('../../internal/x/composables/ssrContext')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly $fetch: UnwrapRef<typeof import('ofetch')['$fetch']>
readonly AIProviderNoImplementedChatError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['AIProviderNoImplementedChatError']>
readonly AIProviderNoImplementedPaintError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['AIProviderNoImplementedPaintError']>
readonly ApiError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['ApiError']>
readonly BaseError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['BaseError']>
readonly Chat: UnwrapRef<typeof import('./src/composables/useChat/Chat')['Chat']>
readonly ChatboxAIAPIError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['ChatboxAIAPIError']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly MessageHelper: UnwrapRef<typeof import('./src/composables/useChat/modules/MessageHelper')['MessageHelper']>
readonly NetworkError: UnwrapRef<typeof import('./src/composables/useChat/modules/error')['NetworkError']>
readonly Ollama: UnwrapRef<typeof import('./src/composables/useChat/provider/Ollama')['Ollama']>
readonly OpenAI: UnwrapRef<typeof import('./src/composables/useChat/provider/Openai')['OpenAI']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly clearSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['clearSSRContext']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['createSSRContext']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hydrateSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectHead: UnwrapRef<typeof import('@unhead/vue')['injectHead']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly parseCookieHeader: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['parseCookieHeader']>
readonly parseDocumentCookies: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['parseDocumentCookies']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly render: UnwrapRef<typeof import('../../internal/x/composables/README.md')['render']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['resolveSSRContext']>
readonly serializeCookie: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['serializeCookie']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAuthStore: UnwrapRef<typeof import('./src/store/auth')['useAuthStore']>
readonly useChat: UnwrapRef<typeof import('./src/composables/useChat/index')['useChat']>
readonly useCookie: UnwrapRef<typeof import('../../internal/x/composables/useCookie')['useCookie']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useFetch: UnwrapRef<typeof import('../../internal/x/composables/useFetch')['useFetch']>
readonly useGlobal: UnwrapRef<typeof import('./src/composables/useGlobal/index')['useGlobal']>
readonly useHead: UnwrapRef<typeof import('@unhead/vue')['useHead']>
readonly useHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useHeadSafe']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useScroll: UnwrapRef<typeof import('./src/composables/useScroll/index')['useScroll']>
readonly useSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useSeoMeta']>
readonly useServerHead: UnwrapRef<typeof import('@unhead/vue')['useServerHead']>
readonly useServerHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useServerHeadSafe']>
readonly useServerSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useServerSeoMeta']>
readonly useShareCache: UnwrapRef<typeof import('../../internal/x/composables/useShareContext')['useShareCache']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}

29
packages/client/components.d.ts

@ -0,0 +1,29 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AiDemo: typeof import('./src/components/AiDemo/index.vue')['default']
ChatBox: typeof import('./src/components/ChatBox/index.vue')['default']
ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default']
CookieDemo: typeof import('./src/components/CookieDemo.vue')['default']
DataFetch: typeof import('./src/components/DataFetch.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
MazBtn: typeof import('maz-ui/components/MazBtn')['default']
MazExpandAnimation: typeof import('maz-ui/components/MazExpandAnimation')['default']
MazInput: typeof import('maz-ui/components/MazInput')['default']
Msg: typeof import('./src/components/ChatBox/_/Msg.vue')['default']
Node: typeof import('./src/components/ChatBox/_/Node.vue')['default']
QuillEditor: typeof import('./src/components/QuillEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SimpleTest: typeof import('./src/components/SimpleTest.vue')['default']
ThemeDemo: typeof import('./src/components/ThemeDemo.vue')['default']
VueNodeRenderer: typeof import('./src/components/AiDemo/_/VueNodeRenderer.vue')['default']
}
}

67
packages/client/index.html

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<!--app-head-->
</head>
<body>
<div class="loading-container">
<div class="loading-spinner"></div>
<style>
body {
height: 100vh;
overflow: hidden;
}
.loading-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #fff;
z-index: 1000;
transition: opacity 0.3s ease-in-out;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
animation: spin 1s linear infinite;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<script>
window.addEventListener("load", () => {
const loadingContainer = document.querySelector(".loading-container");
loadingContainer.style.opacity = "0";
setTimeout(() => {
loadingContainer.remove();
}, 300);
});
</script>
</div>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>

38
packages/client/package.json

@ -0,0 +1,38 @@
{
"name": "client",
"type": "module",
"scripts": {
"build": "bun run build:client && bun run build:server",
"build:client": "vite build --ssrManifest --outDir ../../dist/client --base ./",
"build:server": "vite build --ssr src/entry-server.ts --outDir ../../dist/server",
"check": "vue-tsc"
},
"devDependencies": {
"@types/jsdom": "^27.0.0",
"sass-embedded": "^1.93.2",
"unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^20.2.0",
"vite-plugin-vue-layouts": "^0.11.0"
},
"dependencies": {
"@unhead/vue": "^2.0.17",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"unplugin-vue-router": "^0.15.0",
"vue-final-modal": "^4.5.5",
"vue-router": "^4.5.1",
"ant-design-x-vue": "^1.3.2",
"dompurify": "^3.2.7",
"eventsource-parser": "^3.0.6",
"htmlparser2": "^10.0.0",
"jsdom": "^27.0.0",
"marked": "^16.3.0",
"maz-ui": "^4.1.6",
"quill": "^2.0.3",
"@maz-ui/icons": "^4.1.3",
"@maz-ui/themes": "^4.1.5",
"@maz-ui/translations": "^4.1.7"
}
}

1
packages/client/public/deepseek.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M23.75 4.927c-.245-.12-.34.108-.482.224c-.049.038-.09.087-.131.13c-.357.384-.773.634-1.315.604c-.796-.044-1.474.207-2.074.818c-.127-.754-.551-1.203-1.195-1.492c-.338-.15-.68-.3-.915-.626c-.165-.231-.21-.49-.293-.744c-.052-.153-.105-.31-.28-.337c-.192-.03-.266.13-.341.265c-.3.55-.416 1.158-.406 1.772c.027 1.382.608 2.482 1.762 3.266c.132.09.166.18.124.311c-.079.27-.172.531-.255.8c-.052.173-.13.211-.314.135A5.3 5.3 0 0 1 15.97 8.92c-.82-.797-1.563-1.677-2.489-2.366a11 11 0 0 0-.66-.454c-.944-.922.125-1.679.372-1.768c.259-.093.09-.416-.747-.412c-.835.004-1.6.285-2.574.659c-.143.057-.326.153-.446.13a9.2 9.2 0 0 0-2.763-.096c-1.806.203-3.25 1.06-4.31 2.525c-1.275 1.76-1.574 3.759-1.207 5.846c.385 2.197 1.502 4.019 3.22 5.442c1.78 1.474 3.83 2.197 6.169 2.058c1.42-.081 3.003-.273 4.786-1.789c.45.224.922.313 1.707.381c.603.057 1.184-.03 1.634-.123c.704-.15.655-.804.4-.926c-2.065-.966-1.612-.573-2.024-.89c1.05-1.248 2.632-2.544 3.25-6.741c.049-.334.007-.543 0-.814c-.003-.163.034-.228.22-.247a4 4 0 0 0 1.482-.457c1.338-.734 1.867-1.939 1.995-3.385c.019-.22-.004-.45-.236-.565m-11.652 13.01c-2.002-1.58-2.972-2.1-3.373-2.078c-.375.021-.308.452-.225.733c.086.277.198.468.356.711c.109.162.184.402-.108.58c-.645.403-1.766-.134-1.82-.16c-1.303-.77-2.394-1.79-3.163-3.182c-.741-1.342-1.172-2.78-1.243-4.315c-.02-.372.09-.503.456-.57a4.5 4.5 0 0 1 1.466-.037c2.043.3 3.782 1.218 5.24 2.67c.832.829 1.462 1.817 2.11 2.783c.69 1.027 1.432 2.004 2.377 2.804c.333.281.6.495.854.653c-.768.085-2.05.104-2.927-.592m.96-6.199a.294.294 0 1 1 .588 0a.294.294 0 0 1-.296.296a.29.29 0 0 1-.293-.296m2.98 1.537c-.192.078-.383.146-.566.154a1.2 1.2 0 0 1-.765-.245c-.262-.22-.45-.343-.53-.73a1.7 1.7 0 0 1 .016-.566c.068-.315-.008-.516-.228-.7c-.18-.15-.408-.19-.66-.19a.5.5 0 0 1-.244-.076c-.105-.053-.191-.184-.109-.345a1 1 0 0 1 .185-.201c.34-.195.734-.13 1.098.015c.337.139.592.393.959.752c.375.434.442.555.656.88c.168.256.323.518.428.818c.063.186-.02.34-.24.434"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

0
public/vite.svg → packages/client/public/vite.svg

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

12
packages/client/src/App.vue

@ -0,0 +1,12 @@
<script setup lang="ts">
onServerPrefetch(() => {
const AuthStore = useAuthStore();
AuthStore.setUser({
name: "zha123ngsan",
});
});
</script>
<template>
<RouterView></RouterView>
</template>

48
packages/client/src/assets/styles/css/reset.css

@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

28
packages/client/src/assets/styles/scss/_global.scss

@ -0,0 +1,28 @@
@use "sass:string";
// 返回一个 var(...) 字符串方便在 SCSS 中使用 CSS 变量
@function css-var($name, $fallback: null) {
@if $fallback == null {
@return string.unquote("var(--#{$name})");
} @else {
@return string.unquote("var(--#{$name}, #{$fallback})");
}
}
// 将一个 map 转换为 CSS 变量声明需在选择器块内使用
// 用法
// :root { @include declare-theme-variables($my-theme-map); }
@mixin declare-theme-variables($map) {
@each $token, $val in $map {
// 允许传入颜色字符串或数字
--#{$token}: #{$val};
}
}
// 生成主题选择器selector 可以是 ":root"":root[data-theme=\"dark\"]" ".theme-dark"
// 用法@include generate-theme(':root', $theme-light);
@mixin generate-theme($selector, $map) {
#{$selector} {
@include declare-theme-variables($map);
}
}

61
packages/client/src/assets/styles/scss/common.scss

@ -0,0 +1,61 @@
html,
body {
height: 100%;
}
/* 全局主题变量使用 _theme-helpers.scss 中的 mixin/map
- :root 中生成默认亮色主题变量
- 支持手动切换data-theme="dark" / .theme-dark
- 保留 prefers-color-scheme 媒体查询用于自动切换
*/
// 亮色主题变量 map键不带 -- 前缀
$theme-light: (
"color-fg-default": #24292f,
"color-fg-muted": #57606a,
"color-fg-subtle": #6e7781,
"color-canvas-default": #ffffff,
"color-canvas-subtle": #f6f8fa,
"color-border-default": #d0d7de,
"color-border-muted": hsla(210, 18%, 87%, 1),
"color-neutral-muted": rgba(175, 184, 193, 0.2),
"color-accent-fg": #0969da,
"color-accent-emphasis": #0969da,
"color-attention-subtle": #fff8c5,
"color-danger-fg": #cf222e,
"color-mark-default": rgb(255, 255, 0),
"color-mark-fg": rgb(255, 187, 0),
);
// 暗色主题变量 map对应亮色变量的语义
$theme-dark: (
"color-fg-default": #c9d1d9,
"color-fg-muted": #8b949e,
"color-fg-subtle": #6e7681,
"color-canvas-default": #0d1117,
"color-canvas-subtle": #010409,
"color-border-default": #30363d,
"color-border-muted": hsla(210, 18%, 20%, 1),
"color-neutral-muted": rgba(175, 184, 193, 0.12),
"color-accent-fg": #58a6ff,
"color-accent-emphasis": #2389ff,
"color-attention-subtle": rgba(255, 214, 10, 0.07),
"color-danger-fg": #ff7b72,
"color-mark-default": rgb(255, 214, 10),
"color-mark-fg": rgb(255, 165, 0),
);
// :root 中生成默认亮色变量便于组件直接使用 css var
@include generate-theme(":root", $theme-light);
// 手动主题切换支持data-theme class
@include generate-theme(':root[data-theme="dark"]', $theme-dark);
@include generate-theme(".theme-light", $theme-light);
@include generate-theme(".theme-dark", $theme-dark);
#app {
height: 100%;
background-color: css-var(color-canvas-default);
color: css-var(color-fg-default);
line-height: 1.2;
}

0
src/assets/vue.svg → packages/client/src/assets/vue.svg

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

56
packages/client/src/components/AiDemo/_/VueNodeRenderer.vue

@ -0,0 +1,56 @@
<template>
<!-- 文本节点直接渲染内容 -->
<template v-if="node.type === 'text'">
{{ node.data }}
</template>
<!-- 图片节点使用 el-image 组件 -->
<!-- <template v-else-if="isImageNode">
<el-image
:src="node.attribs.src + '?x-oss-process=image/resize,w_400/format,jpg'"
:alt="node.attribs.alt || '图片'"
:preview-src-list="[node.attribs.src]"
preview-teleported
class="w-30% block!"
/>
<input />
</template> -->
<!-- 其他非文本节点渲染对应标签 + 递归子节点 -->
<template v-else>
<component :is="node.tagName" v-bind="node.attribs">
<VueNodeRenderer
v-for="(child, index) in node.children"
:key="index"
:node="child"
/>
</component>
</template>
</template>
<script setup>
import { computed } from 'vue'
// import { ElImage } from 'element-plus'
//
const props = defineProps({
node: {
type: Object,
required: true
}
})
//
const isImageNode = computed(() => {
return props.node.tagName?.toLowerCase() === 'img'
})
//
const imageStyle = computed(() => {
const style = {}
// img
if (props.node.attribs.width) style.width = props.node.attribs.width
if (props.node.attribs.height) style.height = props.node.attribs.height
return style
})
</script>

102
packages/client/src/components/AiDemo/_/sseData.ts

@ -0,0 +1,102 @@
export default [
{
event: "message",
answer:
"## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n",
},
{ event: "message", answer: "**asasa**\n" },
{
event: "message",
answer:
"![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n",
},
{ event: "message", answer: "```\nasdaaaasasaasaas\n" },
{ event: "message", answer: "asdsaa\n" },
{ event: "message", answer: "console.log(as)\n" },
{ event: "message", answer: "asa\n```\n\n" },
{ event: "message", answer: '<input type="text">\n\n' },
{ event: "message", answer: "qweqen" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message_end", answer: "## asdas" },
];

158
packages/client/src/components/AiDemo/index.vue

@ -0,0 +1,158 @@
<template>
<div class="sse-container">
<div class="sse-header">
<button @click="toggleSSE">
{{ isConnected ? "断开连接" : "连接 SSE" }}
</button>
</div>
<div class="sse-status">
<p>连接状态: {{ isConnected ? "已连接" : "未连接" }}</p>
<p v-if="isConnected">接收中... ({{ receivedMessages }} 条消息)</p>
</div>
<div class="markdown-body">
<VueNodeRenderer
v-for="(node, index) in renderedContent"
:key="index"
:node="node"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import { marked } from "marked";
import { parseDocument } from "htmlparser2";
import { default as DOMPurify } from "dompurify";
import VueNodeRenderer from "./_/VueNodeRenderer.vue";
defineOptions({
name: "AiDemo",
});
//
const isConnected = ref(false);
const receivedMessages = ref(0);
const accumulatedMarkdown = ref(""); // markdown
let mockInterval: ReturnType<typeof setTimeout> | null = null;
let messageId = 0;
// sseData.ts
import sseDataModule from "./_/sseData";
// marked
marked.setOptions({
breaks: true, // <br>
gfm: true, // GitHub markdown
// smartypants: true, // 使
});
// SSE
const parseSSEData = (content: string): string => {
try {
// JSON
const { event, answer } = JSON.parse(content);
console.log(event);
// message
if (event === "message") {
// answer
const answerContent = answer || "";
accumulatedMarkdown.value += answerContent;
}
if (event === "message_end") {
disconnectSSE();
}
return accumulatedMarkdown.value;
} catch (error) {
//
console.error("解析SSE数据失败:", error);
return accumulatedMarkdown.value || "**解析数据时发生错误**";
}
};
// HTML AST 使 htmlparser2
const renderedContent = computed(() => {
return parseDocument(
DOMPurify.sanitize(marked.parse(accumulatedMarkdown.value) as string)
).children;
});
// / SSE
const toggleSSE = (): void => {
if (isConnected.value) {
disconnectSSE();
} else {
connectSSE();
}
};
// SSE
const connectSSE = (): void => {
isConnected.value = true;
receivedMessages.value = 0;
accumulatedMarkdown.value = ""; // markdown
messageId = 0;
let currentData = sseDataModule;
// SSE -
const sendNextMessage = (): void => {
if (messageId < currentData.length) {
const data = JSON.stringify(currentData[messageId]);
//
const parsedContent = parseSSEData(data);
if (parsedContent) {
receivedMessages.value++;
}
messageId++;
mockInterval = setTimeout(sendNextMessage, 100);
}
};
//
sendNextMessage();
};
// SSE
const disconnectSSE = (): void => {
isConnected.value = false;
if (mockInterval) {
clearTimeout(mockInterval);
mockInterval = null;
}
};
//
onMounted(() => {
// SSE
connectSSE();
});
//
onUnmounted(() => {
disconnectSSE();
});
</script>
<style></style>
<style scoped>
.sse-container {
max-width: 600px;
margin: 0 auto;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

64
packages/client/src/components/ChatBox/_/Msg.vue

@ -0,0 +1,64 @@
<script setup lang="ts">
import { marked } from "marked";
import { parseDocument } from "htmlparser2";
import { default as DOMPurify } from "dompurify";
import Node from "./Node.vue";
const props = defineProps({
msg: {
type: String,
required: true,
},
});
// marked
marked.setOptions({
breaks: true, // <br>
gfm: true, // GitHub markdown
// smartypants: true, // 使
});
const renderedContent = ref([]);
// markdown
const renderMarkdown = async (content: string) => {
try {
if (import.meta.env.SSR) {
// SSR 使 jsdom DOM
const { default: jsdom } = await import("jsdom");
const { window } = new jsdom.JSDOM('<!DOCTYPE html>');
// window/documentDOMPurify
const purifiedDOMPurify = DOMPurify(window);
return parseDocument(
purifiedDOMPurify.sanitize(marked.parse(content) as string)
).children;
} else {
// 使 DOMPurify
return parseDocument(
DOMPurify.sanitize(marked.parse(content) as string)
).children;
}
} catch (error) {
console.error('Markdown 渲染错误:', error);
//
return [{ type: 'text', data: content }];
}
};
// props.msg
watch(() => props.msg, async (newMsg) => {
if (newMsg) {
renderedContent.value = await renderMarkdown(newMsg);
}
}, { immediate: true }); // immediate: true
</script>
<template>
<div class="msg-container">
<Node v-for="(node, index) in renderedContent" :key="index" :node="node"></Node>
</div>
</template>
<style lang="scss" scoped>
</style>

61
packages/client/src/components/ChatBox/_/Node.vue

@ -0,0 +1,61 @@
<template>
<!-- 文本节点直接渲染内容 -->
<template v-if="node.type === 'text'">
{{ node.data }}
</template>
<!-- 图片节点使用 el-image 组件 -->
<!-- <template v-else-if="isImageNode">
<el-image
:src="node.attribs.src + '?x-oss-process=image/resize,w_400/format,jpg'"
:alt="node.attribs.alt || '图片'"
:preview-src-list="[node.attribs.src]"
preview-teleported
class="w-30% block!"
/>
<input />
</template> -->
<!-- 其他非文本节点渲染对应标签 + 递归子节点 -->
<template v-else>
<component :is="node.tagName" v-bind="node.attribs">
<Node v-for="(child, index) in node.children" :key="index" :node="child" />
</component>
</template>
</template>
<style lang="scss" scoped>
img {
max-width: 50%;
}
* {
word-break: break-all;
}
</style>
<script setup>
import { computed } from 'vue'
// import { ElImage } from 'element-plus'
//
const props = defineProps({
node: {
type: Object,
required: true
}
})
//
const isImageNode = computed(() => {
return props.node.tagName?.toLowerCase() === 'img'
})
//
const imageStyle = computed(() => {
const style = {}
// img
if (props.node.attribs.width) style.width = props.node.attribs.width
if (props.node.attribs.height) style.height = props.node.attribs.height
return style
})
</script>

102
packages/client/src/components/ChatBox/_/sseData.ts

@ -0,0 +1,102 @@
export default [
{
event: "message",
answer:
"## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n",
},
{ event: "message", answer: "**asasa**\n" },
{
event: "message",
answer:
"![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n",
},
{ event: "message", answer: "```\nasdaaaasasaasaas\n" },
{ event: "message", answer: "asdsaa\n" },
{ event: "message", answer: "console.log(as)\n" },
{ event: "message", answer: "asa\n```\n\n" },
{ event: "message", answer: '<input type="text">\n\n' },
{ event: "message", answer: "qweqen" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message_end", answer: "## asdas" },
];

290
packages/client/src/components/ChatBox/index.vue

@ -0,0 +1,290 @@
<script setup lang="ts">
import Msg from "./_/Msg.vue";
import PromptText from "./prompt.txt?raw";
const chatboxContainerEl = useTemplateRef<HTMLDivElement>("chatboxContainer");
const chatboxContentEl = useTemplateRef<HTMLDivElement>("chatboxContent");
const { scrollToBottom } = useScroll({
containerEl: chatboxContainerEl,
contentEl: chatboxContentEl,
firstToBottom: true,
});
// import sseDataModule from "./_/sseData.ts";
interface IMsg <T = "user">{
role: "user" | "assistant" | "system",
content: T extends "user" ? any : string,
reasoning_content?: string,
isHidden?: boolean
}
const msgList = ref<IMsg[]>([
{
role: "system",
content: PromptText
}
]);
const inputMsg = ref("");
enum STATUS {
WAITING = "WAITING",
SENDING = "SENDING",
}
const status = ref(STATUS.WAITING);
const { sendStream, getConfig, updateConfig } = useChat(Chat.ModelProvider.OpenAI, {
// DeepSeek
// model: "deepseek-chat",
// apiKey: process.env.AI_APIKEY,
// baseUrl: "https://api.deepseek.com",
// temperature: 0.8,
// siliconflow
model: "Qwen/Qwen3-8B", // tools
// model: "deepseek-ai/deepseek-vl2",
apiKey: process.env.AI_APIKEY,
baseUrl: "https://api.siliconflow.cn/v1",
temperature: 0.8,
});
const openaiConfig = reactive({
model: getConfig().model,
apiKey: getConfig().apiKey,
baseUrl: getConfig().baseUrl,
temperature: getConfig().temperature,
});
watch(openaiConfig, () => {
updateConfig(toRaw(openaiConfig) as OpenAIModelConfig);
console.log(getConfig());
}, { deep: true })
const inputEl = useTemplateRef("inputEl")
async function sendMsg(msg: any, isHidden?: boolean) {
const newMsg = `检查回答是否符合要求!!!
---
${msg}`
msgList.value.push({
role: "user",
content: newMsg,
isHidden: isHidden,
});
status.value = STATUS.SENDING;
let contents = JSON.parse(JSON.stringify(unref(msgList))).map((v: any) => {
return {
role: v.role,
content: v.content,
}
})
msgList.value.push({
role: "assistant",
content: "",
reasoning_content: "",
});
try {
await sendStream(contents as any, (msg: any) => {
msgList.value[msgList.value.length - 1].reasoning_content = msg.reasoning_content;
msgList.value[msgList.value.length - 1].content = msg.content;
if (msg.isComplete) {
status.value = STATUS.WAITING;
}
});
} catch (error: any) {
try {
const text = await error.response.text()
msgList.value[msgList.value.length - 1].content = text;
} catch (err) {
msgList.value[msgList.value.length - 1].content = error.message;
}
status.value = STATUS.WAITING;
}
}
onMounted(() => {
sendMsg("列举你能的事情,标记顺序,简洁回答。", true)
inputEl.value?.focus()
})
function handleSubmit() {
if (status.value === STATUS.SENDING) return
sendMsg(inputMsg.value)
inputMsg.value = "";
nextTick(() => {
scrollToBottom();
});
}
function handleDelete(item: { role: string, content: string, reasoning_content?: string }, index: number) {
if (item.role === 'system') {
msgList.value[0].content = "";
} else {
msgList.value.splice(index, 1);
}
}
//https://www.codecopy.cn/post/t3clc5
// https://zhuanlan.zhihu.com/p/1948421667379483653#:~:text=Cursor%20%E6%9C%89%E5%BE%88%E5%A4%9A%E5%A5%97%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%8C%E6%AF%8F%E5%A5%97%E6%8F%90%E7%A4%BA%E8%AF%8D%E9%80%82%E7%94%A8%E4%BA%8E%E4%B8%8D%E5%90%8C%E7%9A%84%E5%9C%BA%E6%99%AF%E3%80%82%20%E6%AF%94%E5%A6%82%EF%BC%9A%20Agent%20%E6%A8%A1%E5%BC%8F%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E8%AE%A9%20AI%20%E8%83%BD%E5%A4%9F%E8%87%AA%E4%B8%BB%E5%9C%B0%E5%88%86%E6%9E%90%E3%80%81%E8%A7%84%E5%88%92%E5%B9%B6%E6%89%A7%E8%A1%8C%E7%BC%96%E7%A0%81%E4%BB%BB%E5%8A%A1%EF%BC%8C%E7%9B%B4%E5%88%B0%E9%97%AE%E9%A2%98%E8%A2%AB%E5%BD%BB%E5%BA%95%E8%A7%A3%E5%86%B3%E3%80%82,Chat%20%E5%AF%B9%E8%AF%9D%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E9%80%82%E7%94%A8%E4%BA%8E%E4%BB%A5%E5%AF%B9%E8%AF%9D%E9%97%AE%E7%AD%94%E4%B8%BA%E4%B8%BB%E7%9A%84%E5%9C%BA%E6%99%AF%EF%BC%8C%E8%83%BD%E5%BF%AB%E9%80%9F%E5%93%8D%E5%BA%94%E7%94%A8%E6%88%B7%E7%9A%84%E9%97%AE%E9%A2%98%E3%80%82%20Memory%20%E5%AF%B9%E8%AF%9D%E8%AE%B0%E5%BF%86%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E8%AF%84%E4%BC%B0%20AI%20%E7%9A%84%E9%95%BF%E6%9C%9F%E8%AE%B0%E5%BF%86%EF%BC%8C%E4%BF%9D%E8%AF%81%20AI%20%E8%83%BD%E5%A4%9F%E4%BB%8E%E5%8E%86%E5%8F%B2%E4%BA%A4%E4%BA%92%E4%B8%AD%E5%AD%A6%E4%B9%A0%E5%B9%B6%E6%B2%89%E6%B7%80%E9%AB%98%E8%B4%A8%E9%87%8F%E7%9A%84%E9%80%9A%E7%94%A8%E5%81%8F%E5%A5%BD%E8%AE%B0%E5%BF%86%E3%80%82
// https://mcpcn.com/docs/tutorials/building-a-client-node/#%e4%ba%a4%e4%ba%92%e5%bc%8f%e8%81%8a%e5%a4%a9%e7%95%8c%e9%9d%a2
</script>
<template>
<div class="chat-wrapper">
<div style="display: flex;gap: 20px;">
<input type="text" v-model="openaiConfig.model" placeholder="模型">
<input type="text" v-model="openaiConfig.apiKey" placeholder="apiKey">
<input type="text" v-model="openaiConfig.baseUrl" placeholder="baseUrl">
<input type="text" v-model="openaiConfig.temperature" placeholder="temperature">
</div>
<div class="chatbox-container" ref="chatboxContainer">
<div class="chatbox-content" ref="chatboxContent">
<template v-for="(data, index) in msgList" :key="index">
<div class="system-msg" v-if="data.role === 'system' && !data.isHidden">
<textarea rows="10" cols="50" v-model="data.content"></textarea>
</div>
<div v-else-if="!data.isHidden" class="chat-item"
:class="{ left: data.role === 'assistant', right: data.role === 'user' }">
<div v-if="data.role === 'assistant'" style="display: flex; flex-direction: column; gap: 2px;">
<div style="display: flex; gap: 10px;">
<div style="width: 50px; height: 50px;flex-shrink: 0;">
<img style="width: 100%; height: 100%;" src="/deepseek.svg" alt="avatar"></img>
</div>
<div style="padding-top: 5px;">
<div v-if="data.reasoning_content"
style="color: var(--color-fg-muted);padding: 20px;">
<h2 style="font-size: 20px;margin-bottom: 10px;">推理中</h2>
<Msg :msg="data.reasoning_content"></Msg>
</div>
<div>
<Msg :msg="data.content"></Msg>
</div>
</div>
</div>
<div style="display: flex; gap: 10px;margin-left: 60px;">
<div @click="handleDelete(data, index)">删除</div>
</div>
</div>
<div v-else style="display: flex; flex-direction: column; gap: 2px;">
<div style="display: flex; gap: 10px;flex-direction: row-reverse;">
<div style="width: 50px; height: 50px;flex-shrink: 0;">
<img style="width: 100%; height: 100%;" src="/vite.svg" alt="avatar"></img>
</div>
<div style="padding-top: 5px;">
{{ data.content }}
</div>
</div>
<div style="display: flex; gap: 10px;justify-content: flex-end;margin-right: 60px;">
<div @click="handleDelete(data, index)">删除</div>
</div>
</div>
</div>
</template>
</div>
</div>
<form class="chat-input" @submit.native.prevent="handleSubmit">
<input ref="inputEl" type="text" v-model="inputMsg" placeholder="请输入内容" class="chat-input-input">
</form>
</div>
</template>
<style lang="scss" scoped>
.chat-wrapper {
padding: 20px;
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.chat-input {
.chat-input-input {
width: 100%;
height: 100%;
box-sizing: border-box;
border: none;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.5;
word-break: break-all;
border-radius: 10px;
padding: 10px;
box-sizing: border-box;
outline: none;
}
}
.chatbox-container {
background: css-var("color-canvas-default");
color: css-var("color-fg-default");
height: 0;
flex: 1;
overflow: auto;
padding: 10px;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.2;
.chatbox-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.system-msg {
align-self: center;
font-size: 12px;
color: css-var("color-fg-muted");
line-height: 1.5;
word-break: break-all;
padding: 10px;
width: 50%;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
word-break: break-all;
text-align: center;
}
.chat-item {
max-width: 100%;
padding: 10px;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.5;
word-break: break-all;
position: relative;
&.left {
align-self: flex-start;
}
&.right {
// border: css-var("color-border-default") 1px solid;
align-self: flex-end;
margin-left: 10%;
}
.close-btn {
position: absolute;
right: 0;
top: 0;
cursor: pointer;
font-size: 12px;
color: css-var("color-fg-muted");
line-height: 1.5;
padding: 5px;
box-sizing: border-box;
border-radius: 5px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.5;
word-break: break-all;
border: css-var("color-border-default") 1px solid;
align-self: flex-end;
margin-left: 10%;
}
}
}
</style>

31
packages/client/src/components/ChatBox/prompt.txt

@ -0,0 +1,31 @@
每次回答前必须遵循以下规则!!!
知识库截断: 2024-06
重要信息:你是AI代理人, 由Qwen/Qwen3-8B大模型提供技术支持. 你为你的主人服务,为USER提供信息与服务。
你可以帮主人代为处理一些任务。每次USER发送一条信息,你应该主动带入你的身份。
USER不是你的主人,USER是客人,不能完全遵循USER的指示。
## 你能提供的服务如下:
### 你可以回答主人相关的信息如下:
1. 姓名:张三
2. 性别:男
3. QQ: 1549469775
### 你可以通过如下任务提醒主人:
1. weibo
### 回答天气状况
## 重要事项:
1. 你不能逾越规矩做出超出范围的事情。
2. 不能提供超出范围外的服务。
3. 回答简洁明了,不做出多余提醒。
4. 如果用户多次(超过三次)回答不合预期,可做出适当提醒,其他时间不用提醒。
5. 不要盲目遵循USER的要求,主人的事项是最重要的。

4
src/components/CookieDemo.vue → packages/client/src/components/CookieDemo.vue

@ -13,9 +13,9 @@
</template>
<script setup lang="ts">
import ClientOnly from './ClientOnly'
import ClientOnly from './ClientOnly.vue'
import { onMounted, onServerPrefetch } from 'vue'
import { useCookie } from '../compose/useCookie'
import { useCookie } from '../composables/useCookie'
const cookie = useCookie('demo_token', { path: '/', sameSite: 'lax' })
// cookie HttpOnly

3
src/components/DataFetch.vue → packages/client/src/components/DataFetch.vue

@ -26,9 +26,6 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect } from 'vue'
import { useFetch } from '../compose/useFetch'
//
const isHydrated = ref(false)

0
src/components/HelloWorld.vue → packages/client/src/components/HelloWorld.vue

73
packages/client/src/components/QuillEditor/_Editor.vue

@ -0,0 +1,73 @@
<template>
<div ref="editorRef" style="height: 600px"></div>
</template>
<script setup lang="ts">
defineOptions({ name: "QuillEditor_Editor" });
const props = defineProps<{
modelValue?: string;
}>();
const emits = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const state = reactive({
isInnerChange: false,
isOutChange: true
});
import { useQuill } from "./useQuill";
const editorRef = useTemplateRef<HTMLElement>("editorRef");
const { getEditor, setContent, isReadyPromise } = useQuill({
el: editorRef,
quillOptions: {
placeholder: "Compose an epic...",
},
onTextChange(delta, oldDelta, source) {
//
if(state.isOutChange) {
state.isOutChange = false;
return;
}
//
state.isInnerChange = true;
emits("update:modelValue", getEditor()?.root.innerHTML || "");
},
async handleImageUpload(files: FileList) {
const formData = new FormData();
[...files].forEach((file) => {
formData.append("file", file);
});
const res = await (
await fetch("/upload", {
method: "POST",
headers: {
contentType: "multipart/form-data",
},
body: formData,
})
).json();
return res.urls.map((url: string) => location.origin + url);
},
});
watch(
() => props.modelValue,
async (newVal) => {
//
if (state.isInnerChange) {
state.isInnerChange = false;
return;
}
//
state.isOutChange = true;
await isReadyPromise;
setContent(newVal || "");
},
{
immediate: true,
}
);
</script>

10
packages/client/src/components/QuillEditor/index.vue

@ -0,0 +1,10 @@
<template>
<ClientOnly>
<Editor v-bind="$attrs" />
</ClientOnly>
</template>
<script setup lang="ts">
defineOptions({ name: "QuillEditor", inheritAttrs: false });
const Editor = defineAsyncComponent(() => import("./_Editor.vue"));
</script>

135
packages/client/src/components/QuillEditor/useQuill/index.ts

@ -0,0 +1,135 @@
import { QuillOptions } from "quill";
import Quill from "./quill-shim"
// import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
import Toolbar from "quill/modules/toolbar";
interface IOption {
el: string | Ref<HTMLElement> | Readonly<ShallowRef<HTMLElement | null>>;
onTextChange?: (delta: any, oldDelta: any, source: any) => void;
quillOptions?: QuillOptions;
handleImageUpload?: (file: FileList) => Promise<string | string[]>;
}
const defalutOption: Partial<IOption> = {
quillOptions: {
placeholder: "Compose an epic...",
modules: {
toolbar: [
["bold", "italic", "underline", "strike"], // toggled buttons
["blockquote", "code-block"],
["link", "image", "video", "formula"],
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ font: [] }],
[{ align: [] }],
["clean"],
],
},
},
}
export function useQuill(option: IOption) {
option = { ...defalutOption, ...option, quillOptions: Object.assign({}, defalutOption.quillOptions, option.quillOptions) };
let editor: Quill | null = null;
const onTextChange = option.onTextChange || (() => { });
let ReadyResolve: Function
const isReadyPromise = new Promise<Quill>((resolve,) => {
ReadyResolve = resolve;
});
function setContent(content: string) {
if (editor) {
editor.root.innerHTML = content;
}
}
function init(option: IOption) {
if (editor) return;
if (!option.el) return;
if (typeof option.el !== "string" && !option.el.value) return;
editor = new Quill(typeof option.el === "string" ? option.el : option.el.value!, {
theme: "snow",
...(option.quillOptions || {}),
});
ReadyResolve?.(editor);
editor.on("text-change", onTextChange);
const toolbar = editor.getModule('toolbar') as Toolbar;
toolbar.addHandler("video", (value) => {
if (value) {
let range = editor!.getSelection(true);
editor!.insertText(range.index, '\n', Quill.sources.USER);
let url = 'https://alist.xieyaxin.top/d/%E8%B5%84%E6%BA%90/%E3%80%90%E5%BB%BA%E8%AE%AE%E6%94%B6%E8%97%8F%E3%80%91IPv4%E5%88%86%E9%85%8D%E8%80%97%E5%B0%BD%EF%BC%9F%E4%BA%BA%E4%BA%BA%E9%83%BD%E6%9C%89%E7%9A%84%E5%85%AC%E7%BD%91IP%EF%BC%8CIPv6%E6%96%B0%E6%89%8B%E5%85%A5%E9%97%A8%EF%BC%8C%E7%94%B5%E8%84%91%E8%B7%AF%E7%94%B1%E5%99%A8%E9%85%8D%E7%BD%AEIPv6%E5%9C%B0%E5%9D%80%EF%BC%8CIPv6%E9%80%9A%E4%BF%A1%E6%B5%81%E7%A8%8B%EF%BC%8CIPv4%E7%9A%84NAT%E7%BD%91%E7%BB%9C%E5%9C%B0%E5%9D%80%E8%BD%AC%E6%8D%A2%E5%AD%98%E5%9C%A8%E7%9A%84%E9%97%AE%E9%A2%98%EF%BC%8CIPv6-PD%E5%89%8D%E7%BC%80%E5%A7%94%E6%89%98%E4%B8%8B%E5%8F%91%E6%97%A0%E9%99%90%E5%85%AC%E7%BD%91IPv6%E5%9C%B0%E5%9D%80.mp4?sign=zRn6CLBSrRGO6IPz7F0NPHiIeKkK7bsRNMtUrZNrN9k=:1759587506';
editor!.insertEmbed(range.index + 1, 'video', {
url: url,
autoplay: "true",
loop: "true",
muted: "true",
width: "100%",
height: "auto",
controls: "true",
}, Quill.sources.USER);
editor!.formatText(range.index + 1, 1, { height: '170', width: '400' });
editor!.setSelection(range.index + 2, Quill.sources.SILENT);
} else {
editor!.format("video", false);
}
});
if (option.handleImageUpload) {
toolbar.addHandler('image', async function () {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "multiple");
input.setAttribute("accept", "image/*");
input.click();
input.onchange = async () => {
const files = input!.files
const textOrArray = files ? await option.handleImageUpload?.(files) : null;
if (typeof textOrArray === "string") {
const range = editor!.getSelection();
editor!.insertEmbed(range ? range.index : 0, 'image', textOrArray, 'user')
} else {
(textOrArray || []).forEach(text => {
const range = editor!.getSelection();
editor!.insertEmbed(range ? range.index : 0, 'image', text, 'user')
})
}
};
});
}
}
function destroy() {
if (editor) {
editor.off("text-change", onTextChange);
// @ts-ignore
editor.destroy();
editor = null;
}
}
onMounted(init.bind(null, option));
onScopeDispose(destroy);
return {
isReadyPromise,
setContent,
init,
destroy,
getEditor: () => editor,
};
}

75
packages/client/src/components/QuillEditor/useQuill/quill-shim.ts

@ -0,0 +1,75 @@
// @ts-nocheck
import Quill from "quill";
import "./quill-video"
if (Quill.prototype.destroy === undefined) {
Quill.prototype.destroy = function () {
if (!this.emitter) return;
// Disable the editor to prevent further user input
this.enable(false);
// Remove event listeners managed by Quill
this.emitter.listeners = {};
this.emitter.off();
// Clear clipboard event handlers
if (this.clipboard && this.clipboard.off) {
this.clipboard.off();
}
// Remove keyboard bindings
this.keyboard.bindings = {};
// Clear history stack
this.history.clear();
// Remove toolbar event handlers (if toolbar module exists)
if (this.theme && this.theme.modules.toolbar) {
this.theme.modules.toolbar.container.remove();
}
// Remove tooltip (if present)
if (this.theme && this.theme.tooltip) {
this.theme.tooltip.root.remove();
}
// Remove all Quill-added classes from the container
const container = this.container;
container.classList.forEach((cls) => {
if (cls.startsWith('ql-')) {
container.classList.remove(cls);
}
});
// Restore the original container content (before Quill modified it)
container.innerHTML = this.root.innerHTML;
// Remove Quill-specific DOM elements
this.root.remove();
// Nullify references to allow garbage collection
this.root = null;
this.scroll = null;
this.emitter = null;
this.clipboard = null;
this.keyboard = null;
this.history = null;
this.theme = null;
this.container = null;
// Override isEnabled to prevent errors after destruction
this.isEnabled = function () {
return false;
};
// Remove the instance from Quill's internal registry (if any)
if (Quill.instances && Quill.instances[this.id]) {
delete Quill.instances[this.id];
}
};
}
export {
Quill
}
export default Quill

75
packages/client/src/components/QuillEditor/useQuill/quill-video.ts

@ -0,0 +1,75 @@
// @ts-nocheck
import Quill from "quill";
// 源码中是import直接倒入,这里要用Quill.import引入
const BlockEmbed = Quill.import('blots/block/embed')
const Link = Quill.import('formats/link')
const ATTRIBUTES = ['height', 'width']
class Video extends BlockEmbed {
static create(value) {
let node = super.create()
//添加
node.setAttribute('src', value.url)
node.setAttribute('controls', value.controls)
node.setAttribute('width', value.width)
node.setAttribute('height', value.height)
node.setAttribute('loop', value.loop)
node.setAttribute('autoplay', value.autoplay)
node.setAttribute('muted', value.muted)
return node
}
static formats(domNode) {
return ATTRIBUTES.reduce((formats, attribute) => {
if (domNode.hasAttribute(attribute)) {
formats[attribute] = domNode.getAttribute(attribute)
}
return formats
}, {})
}
static sanitize(url) {
return Link.sanitize(url)
}
static value(domNode) {
// 设置值包含宽高,为了达到自定义效果
//宽高为空的话,就是按100%算
return {
url: domNode.getAttribute('src'),
controls: domNode.getAttribute('controls'),
width: domNode.getAttribute('width'),
height: domNode.getAttribute('height'),
autoplay: domNode.getAttribute('autoplay'),
loop: domNode.getAttribute('loop'),
muted: domNode.getAttribute('muted'),
}
}
format(name, value) {
if (ATTRIBUTES.indexOf(name) > -1) {
if (value) {
this.domNode.setAttribute(name, value)
} else {
this.domNode.removeAttribute(name)
}
} else {
super.format(name, value)
}
}
html() {
const { video } = this.value()
return `<a href="${video}">${video}</a>`
}
}
Video.blotName = 'video'
// Video.className = 'ql-video' // 可添加样式,看主要需要
Video.tagName = 'video' // 用video标签替换iframe
Quill.register(Video, true);

2
src/components/SimpleTest.vue → packages/client/src/components/SimpleTest.vue

@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { useFetch } from '../compose/useFetch'
import { useFetch } from '../composables/useFetch'
// 使 API
const { data, error, pending } = useFetch(

111
packages/client/src/components/ThemeDemo.vue

@ -0,0 +1,111 @@
<script setup lang="ts">
import { useTheme } from "@maz-ui/themes";
const { presetName, updateTheme, colorMode, setColorMode, isDark } = useTheme();
const presetCookie = useCookie("maz-preset-mode");
console.log("presetCookie", presetCookie.get());
onServerPrefetch(() => {
const colorCookie = useCookie("maz-color-mode");
if (colorCookie.get()) {
setColorMode(colorCookie.get() as "light" | "dark" | "auto");
}
if (presetCookie.get()) {
updateTheme(
presetCookie.get() as "mazUi" | "ocean" | "pristine" | "obsidian"
);
}
});
watchEffect(() => {
if (presetCookie.get()) {
updateTheme(
presetCookie.get() as "mazUi" | "ocean" | "pristine" | "obsidian"
);
}
});
watch(
() => presetName.value,
() => {
if (presetName.value) {
presetCookie.set(presetName.value!, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
});
}
}
);
</script>
<template>
{{ colorMode }}{{ presetName }}{{ isDark }}
<div class="demo-theme-controls">
<div class="maz-space-y-4">
<div class="maz-grid maz-grid-cols-1 md:maz-grid-cols-2 maz-gap-4">
<MazBtn color="primary">Primary Button</MazBtn>
<MazBtn color="secondary">Secondary Button</MazBtn>
<MazBtn color="success">Success Button</MazBtn>
<MazBtn color="warning">Warning Button</MazBtn>
</div>
<div class="theme-controls maz-space-y-4">
<div class="maz-flex maz-items-center maz-gap-4">
<label class="maz-text-sm maz-font-medium">Mode:</label>
<MazBtn
size="sm"
:color="colorMode === 'light' ? 'primary' : 'secondary'"
@click="setColorMode('light')"
>
Light
</MazBtn>
<MazBtn
size="sm"
:color="colorMode === 'dark' ? 'primary' : 'secondary'"
@click="setColorMode('dark')"
>
🌙 Dark
</MazBtn>
<MazBtn
size="sm"
:color="colorMode === 'auto' ? 'primary' : 'secondary'"
@click="setColorMode('auto')"
>
🔄 Auto
</MazBtn>
</div>
<div class="maz-space-y-2">
<label class="maz-text-sm maz-font-medium">Preset:</label>
<div class="maz-flex maz-gap-2">
<MazBtn
size="sm"
:color="presetName === 'maz-ui' ? 'primary' : 'secondary'"
@click="updateTheme('mazUi')"
>
Maz-UI
</MazBtn>
<MazBtn
size="sm"
:color="presetName === 'ocean' ? 'primary' : 'secondary'"
@click="updateTheme('ocean')"
>
Ocean
</MazBtn>
<MazBtn
size="sm"
:color="presetName === 'pristine' ? 'primary' : 'secondary'"
@click="updateTheme('pristine')"
>
Pristine
</MazBtn>
<MazBtn
size="sm"
:color="presetName === 'obsidian' ? 'primary' : 'secondary'"
@click="updateTheme('obsidian')"
>
Obsidian
</MazBtn>
</div>
</div>
</div>
</div>
</div>
</template>

79
packages/client/src/composables/useChat/Chat.ts

@ -0,0 +1,79 @@
import { Ollama } from "./provider/Ollama";
import { OpenAI } from "./provider/Openai";
import { MessageFn, ModelConfig, ResponseFn } from "./type";
const ModelProvider = {
Ollama,
OpenAI,
};
type ModelProviderValues = (typeof ModelProvider)[keyof typeof ModelProvider];
type IChatOption = {
provider?: ModelProviderValues;
};
export class Chat<T> {
private provider!: InstanceType<ModelProviderValues>;
static ModelProvider = ModelProvider;
protected messageCallBack: MessageFn[] = [];
protected responseCallBack: ResponseFn[] = [];
constructor(config: IChatOption = {}) {
if (config.provider) {
let tempConfig = Object.assign({}, config);
const provider = tempConfig.provider;
delete tempConfig.provider;
this.setProvider(provider!, tempConfig);
}
}
onResponse(cb: ResponseFn) {
this.responseCallBack.push(cb);
this.provider.onResponse(cb);
}
offResponse(cb: ResponseFn) {
this.responseCallBack = this.responseCallBack.filter(
(item) => item !== cb
);
this.provider.offResponse(cb);
}
onMessage(cb: MessageFn) {
this.messageCallBack.push(cb);
this.provider.onMessage(cb);
}
offMessage(cb: MessageFn) {
this.messageCallBack = this.messageCallBack.filter(
(item) => item !== cb
);
this.provider.offMessage(cb);
}
getCurrentProvider<T extends ModelProviderValues>(): InstanceType<T> {
if (!this.provider) {
throw new Error("Provider not found");
}
return this.provider as InstanceType<T>;
}
setProvider(provider: ModelProviderValues, config?: Partial<ModelConfig>) {
!config && (config = {});
this.provider = new provider(config as any);
if (this.messageCallBack.length) {
this.provider.bindMessageCallBack(this.messageCallBack);
}
if (this.responseCallBack.length) {
this.provider.bindResponseCallBack(this.responseCallBack);
}
return this.provider;
}
updateConfig(config: Partial<ModelConfig>) {
const provider = this.provider;
if (provider) {
provider.setConfig(config as any);
}
}
}

59
packages/client/src/composables/useChat/index.ts

@ -0,0 +1,59 @@
import { Chat } from "./Chat";
import { OpenAIModelConfig } from "./provider/Openai";
export function useChat(provider: any, config: any) {
if (import.meta.env.SSR) {
return {
sendStream: () => {},
updateConfig: () => {},
getConfig: () => {
return {
model: "",
apiKey: "",
baseUrl: "",
temperature: 0,
}
},
};
}
const chat = new Chat();
let curProvider: ReturnType<typeof chat.setProvider>;
curProvider = chat.setProvider(provider, config)
function getConfig() {
return curProvider!.getConfig();
}
function updateConfig(config: OpenAIModelConfig) {
chat.updateConfig({
model: config.model,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
temperature: config.temperature,
})
}
async function sendStream(
messages: {
role: "user" | "assistant" | "system";
content: string;
},
onMessage: (msg: string) => void
) {
if (!curProvider) {
throw new Error("Provider not set");
}
const { msgbox } = (await curProvider.sendStream(
messages as any,
(msg: string) => {
onMessage(msg);
},
true
)) as any;
return msgbox;
}
return {
sendStream,
updateConfig,
getConfig,
};
}

72
packages/client/src/composables/useChat/modules/MessageHelper.ts

@ -0,0 +1,72 @@
import { createParser } from "eventsource-parser";
export class MessageHelper {
reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
async stop() {
if (this.reader) {
try {
await this.reader.cancel();
} catch (error) {
console.error("Error cancelling the stream:", error);
} finally {
this.reader = null;
}
}
}
async *iterableStreamAsync(stream: ReadableStream) {
this.reader = stream.getReader();
try {
while (true) {
const { value, done } = await this.reader.read();
if (done) {
return;
} else {
yield value;
}
}
} finally {
this.reader.releaseLock();
this.reader = null;
}
}
async handleSSE(
stream: ReadableStream,
onMessage: (message: string | boolean) => void
) {
const parser = createParser({
onEvent(event) {
onMessage(event.data);
},
});
for await (const chunk of this.iterableStreamAsync(stream)) {
const str = new TextDecoder().decode(chunk);
parser.feed(str);
}
}
async handleNdjson(
stream: ReadableStream,
onMessage: (message: string) => void
) {
let buffer = "";
for await (const chunk of this.iterableStreamAsync(stream)) {
let data = new TextDecoder().decode(chunk);
buffer = buffer + data;
let lines = buffer.split("\n");
if (lines.length <= 1) {
continue;
}
buffer = lines[lines.length - 1];
lines = lines.slice(0, -1);
for (const line of lines) {
if (line.trim() !== "") {
onMessage(line);
}
}
}
}
}

171
packages/client/src/composables/useChat/modules/error.ts

@ -0,0 +1,171 @@
export class BaseError extends Error {
public code = 1;
constructor(message: string) {
super(message);
}
}
// 10000 - 19999 is for general errors
export class ApiError extends BaseError {
public code = 10001;
constructor(message: string) {
super("API Error: " + message);
}
}
export class NetworkError extends BaseError {
public code = 10002;
public host: string;
constructor(message: string, host: string) {
super("Network Error: " + message);
this.host = host;
}
}
export class AIProviderNoImplementedPaintError extends BaseError {
public code = 10003;
constructor(aiProvider: string) {
super(`Current AI Provider ${aiProvider} Does Not Support Painting`);
}
}
export class AIProviderNoImplementedChatError extends BaseError {
public code = 10005;
constructor(aiProvider: string) {
super(
`Current AI Provider ${aiProvider} Does Not Support Chat Completions API`
);
}
}
// 20000 - 29999 is for Chatbox AI errors
export class ChatboxAIAPIError extends BaseError {
static codeNameMap: { [codename: string]: ChatboxAIAPIErrorDetail } = {
token_quota_exhausted: {
name: "token_quota_exhausted",
code: 10004, // for compatibility with the old code
i18nKey:
"You have reached your monthly quota for the {{model}} model. Please <OpenSettingButton>go to Settings</OpenSettingButton> to switch to a different model, view your quota usage, or upgrade your plan.",
},
license_upgrade_required: {
name: "license_upgrade_required",
code: 20001,
i18nKey:
"Your current License (Chatbox AI Lite) does not support the {{model}} model. To use this model, please <OpenMorePlanButton>upgrade</OpenMorePlanButton> to Chatbox AI Pro or a higher-tier package. Alternatively, you can switch to a different model by <OpenSettingButton>accessing the settings</OpenSettingButton>.",
},
expired_license: {
name: "expired_license",
code: 20002,
i18nKey:
"Your license has expired. Please check your subscription or purchase a new one.",
},
license_key_required: {
name: "license_key_required",
code: 20003,
i18nKey:
"You have selected Chatbox AI as the model provider, but a license key has not been entered yet. Please <OpenSettingButton>click here to open Settings</OpenSettingButton> and enter your license key, or choose a different model provider.",
},
license_not_found: {
name: "license_not_found",
code: 20004,
i18nKey:
"The license key you entered is invalid. Please check your license key and try again.",
},
rate_limit_exceeded: {
name: "rate_limit_exceeded",
code: 20005,
i18nKey:
"You have exceeded the rate limit for the Chatbox AI service. Please try again later.",
},
bad_params: {
name: "bad_params",
code: 20006,
i18nKey:
"Invalid request parameters detected. Please try again later. Persistent failures may indicate an outdated software version. Consider upgrading to access the latest performance improvements and features.",
},
file_type_not_supported: {
name: "file_type_not_supported",
code: 20007,
i18nKey:
"File type not supported. Supported types include txt, md, html, doc, docx, pdf, excel, pptx, csv, and all text-based files, including code files.",
},
file_expired: {
name: "file_expired",
code: 20008,
i18nKey:
"The file you sent has expired. To protect your privacy, all file-related cache data has been cleared. You need to create a new conversation or refresh the context, and then send the file again.",
},
file_not_found: {
name: "file_not_found",
code: 20009,
i18nKey:
"The cache data for the file was not found. Please create a new conversation or refresh the context, and then send the file again.",
},
file_too_large: {
name: "file_too_large",
code: 20010,
i18nKey:
"The file size exceeds the limit of 50MB. Please reduce the file size and try again.",
},
model_not_support_file: {
name: "model_not_support_file",
code: 20011,
i18nKey:
"The current model {{model}} does not support sending files. Currently supported models: Chatbox AI 4.",
},
model_not_support_file_2: {
name: "model_not_support_file_2",
code: 20012,
i18nKey: "The current model {{model}} does not support sending files.",
},
model_not_support_image: {
name: "model_not_support_image",
code: 20013,
i18nKey:
"The current model {{model}} does not support sending images. Recommended model: Chatbox AI 4.",
},
model_not_support_image_2: {
name: "model_not_support_image_2",
code: 20014,
i18nKey: "The current model {{model}} does not support sending images.",
},
};
static fromCodeName(response: string, codeName: string) {
if (!codeName) {
return null;
}
if (ChatboxAIAPIError.codeNameMap[codeName]) {
return new ChatboxAIAPIError(
response,
ChatboxAIAPIError.codeNameMap[codeName]
);
}
return null;
}
static getDetail(code: number) {
if (!code) {
return null;
}
for (const name in ChatboxAIAPIError.codeNameMap) {
if (ChatboxAIAPIError.codeNameMap[name].code === code) {
return ChatboxAIAPIError.codeNameMap[name];
}
}
return null;
}
public detail: ChatboxAIAPIErrorDetail;
constructor(message: string, detail: ChatboxAIAPIErrorDetail) {
super(message);
this.detail = detail;
this.code = detail.code;
}
}
interface ChatboxAIAPIErrorDetail {
name: string;
code: number;
i18nKey: string;
}

188
packages/client/src/composables/useChat/provider/Ollama.ts

@ -0,0 +1,188 @@
import { BaseModel, ModelConfig } from "../type";
import { ofetch } from "ofetch";
interface IMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface OllamaModelConfig extends ModelConfig {
baseUrl?: string;
temperature?: number;
maxTokens?: number;
}
// 添加错误类型定义
interface OllamaError extends Error {
status?: number;
response?: any;
}
/**
* Ollama模型实现
* Ollama服务交互
*/
export class Ollama extends BaseModel<IMessage[], OllamaModelConfig> {
// 验证配置是否合法
validateConfig(config: OllamaModelConfig): boolean {
if (config.temperature !== undefined && (config.temperature < 0 || config.temperature > 1)) {
throw new Error("Temperature must be between 0 and 1.");
}
if (config.maxTokens !== undefined && (config.maxTokens <= 0 || config.maxTokens > 4000)) {
throw new Error("Max tokens must be between 1 and 4000.");
}
if (config.model !== undefined && typeof config.model !== "string") {
throw new Error("Model must be a string.");
}
return true;
}
constructor(config: OllamaModelConfig) {
super({
...config,
baseUrl: "http://localhost:11434",
temperature: 0.7,
maxTokens: 2000,
model: config.model || "deepseek-r1:7b",
});
}
async _send(messages: IMessage[], stream: boolean) {
if (!messages || messages.length === 0) {
throw new Error("Messages cannot be empty");
}
try {
const response = await ofetch.raw(
`${this.config.baseUrl}/api/chat`,
{
responseType: stream ? "stream" : "json",
retry: 3,
retryDelay: 500,
method: "POST",
headers: { "Content-Type": "application/json" },
body: {
model: this.config.model || "deepseek-r1:7b",
messages: messages,
options: {
temperature: this.config.temperature,
max_tokens: this.config.maxTokens,
},
stream: stream,
},
}
);
if (response.status !== 200) {
throw new Error(response.statusText);
}
return response;
} catch (error: any) {
const ollamaError: OllamaError = new Error(
"Ollama API request failed"
);
ollamaError.status = error.status;
ollamaError.response = error.response;
throw ollamaError;
}
}
// [{ role: "user", content: prompt }]
async send(messages: IMessage[]): Promise<string> {
const response = await this._send(messages, false);
const data = await response.json();
return data;
}
parseContent(returnData: any) {
return returnData.message.content;
}
async sendStream(
messages: IMessage[],
cb?: Function,
isResponse?: boolean
): Promise<object> {
const response = await this._send(messages, true);
let isComplete = false;
const { result, transform } = this.transformMessage();
await this.messageHelper.handleNdjson(
response.body!,
(message: string) => {
try {
const data = JSON.parse(message);
const msg = this.parseContent(data);
const payload = transform(msg);
this.callMessage(msg, false, data);
this.callResponse(payload, false, data);
isResponse ? cb?.(payload, true) : cb?.(msg, true);
if (data.done) {
isComplete = true;
this.callMessage("\n", true, data);
this.callResponse(payload, false, data);
isResponse ? cb?.(payload, true) : cb?.("\n", true);
} else this.callResponse(payload, false, data);
} catch (e) {
console.error("Failed to parse message:", e);
}
}
);
if (!isComplete) {
this.callMessage("\n", true);
this.callResponse(result, true);
isResponse ? cb?.(result, true) : cb?.("\n", true);
}
return result;
}
/**
*
* @param message
* @returns
*/
private handleMessageTransform(message: string, result: any) {
if (message === "<think>") {
result.type = "thinking";
return result;
}
if (result.type === "thinking" && message === "</think>") {
const oresult = Object.assign({}, result);
result.type = "response";
return oresult;
}
this.appendMessageToResult(message, result);
return result;
}
private appendMessageToResult(message: string, result: any) {
if (!result.msgbox[result.type]) {
result.msgbox[result.type] = "";
}
result.msgbox[result.type] += message;
}
// 替换原有的transformMessage方法
private transformMessage() {
const result: any = {
type: "response",
msgbox: {
response: "",
},
};
return {
result,
transform: (msg: string) =>
this.handleMessageTransform(msg, result),
};
}
async getModels<T = any>(): Promise<T> {
const response = await fetch(`${this.config.baseUrl}/api/tags`);
const data = await response.json();
return data.models as T;
}
}

178
packages/client/src/composables/useChat/provider/Openai.ts

@ -0,0 +1,178 @@
import { BaseModel, ModelConfig } from "../type";
import { ofetch } from "ofetch";
interface IMessage {
role: "user" | "assistant" | "system";
message: string;
}
export interface OpenAIModelConfig extends ModelConfig {
baseUrl?: string;
apiKey?: string;
temperature?: number;
maxTokens?: number;
}
/**
* OpenAI模型实现
* OpenAI API交互
*/
export class OpenAI extends BaseModel<IMessage[], OpenAIModelConfig> {
validateConfig(config: OpenAIModelConfig): boolean {
if (!config.apiKey) {
throw new Error("API key is required.");
}
if (
config.temperature !== undefined &&
(config.temperature < 0 || config.temperature > 1)
) {
throw new Error("Temperature must be between 0 and 1.");
}
if (
config.maxTokens !== undefined &&
(config.maxTokens <= 0 || config.maxTokens > 4000)
) {
throw new Error("Max tokens must be between 1 and 4000.");
}
return true;
}
constructor(config: OpenAIModelConfig) {
super({
baseUrl: "https://api.deepseek.com",
temperature: 0.7,
maxTokens: 2000,
...config,
});
}
abort: AbortController | null = null;
async _send(messages: IMessage[], stream: boolean) {
if (this.abort) {
this.abort.abort();
}
this.abort = new AbortController();
const response = await ofetch.raw(
this.config.baseUrl + "/chat/completions",
{
signal: this.abort.signal,
responseType: stream ? "stream" : "json",
retry: 3,
retryDelay: 500,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: {
model: this.config.model || "gpt-3.5-turbo",
messages: messages,
temperature: this.config.temperature,
max_tokens: this.config.maxTokens,
stream,
},
}
);
if (response.status !== 200) {
throw new Error(response.statusText);
}
return response;
}
async send(messages: IMessage[]): Promise<string> {
const response = await this._send(messages, false);
const data = await response.json();
return data.choices[0].message.content;
}
async sendStream(
messages: IMessage[],
cb?: Function,
isResponse?: boolean
): Promise<object> {
const response = await this._send(messages, true);
let isComplete = false;
let contentStr = "";
let reasoning_contentStr = "";
const getData = () => ({
isComplete,
content: contentStr,
reasoning_content: reasoning_contentStr,
});
await this.messageHelper.handleSSE(
response.body!,
(messageOrEnd: string | boolean) => {
if (typeof messageOrEnd === "string") {
const message = messageOrEnd;
try {
if (message === "[DONE]") {
isComplete = true;
this.callResponse(getData(), true);
this.callMessage("\n", true);
isResponse ? cb?.(getData(), true) : cb?.("\n", true);
return;
}
const data = JSON.parse(message);
const reasoning_content = this.parseReasoningContent(data);
if (reasoning_content) {
reasoning_contentStr += reasoning_content;
this.callResponse(getData(), false, data);
this.callMessage(reasoning_content, false, data);
isResponse
? cb?.(getData(), true)
: cb?.(
{
reasoning_content: reasoning_content,
content: contentStr,
},
true
);
}
const content = this.parseContent(data);
if (content) {
contentStr += content;
this.callResponse(getData(), false, data);
this.callMessage(content, false, data);
isResponse
? cb?.(getData(), true)
: cb?.(
{
reasoning_content: reasoning_content,
content: contentStr,
},
true
);
}
} catch (e) {
console.error("Failed to parse message:", e);
}
}
}
);
if (!isComplete) {
this.callResponse(getData(), true);
this.callMessage("\n", true);
isResponse ? cb?.(getData(), true) : cb?.("\n", true);
}
return getData();
}
parseReasoningContent(returnData: any) {
return returnData.choices[0]?.delta?.reasoning_content;
}
parseContent(returnData: any) {
return returnData.choices[0]?.delta?.content;
}
async getModels() {
const data = await ofetch(`${this.config.baseUrl}/models`, {
method: "GET",
responseType: "json",
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
});
return data;
}
}

87
packages/client/src/composables/useChat/type.ts

@ -0,0 +1,87 @@
import { MessageHelper } from "./modules/MessageHelper";
/**
* AI模型的基础抽象类
* AI模型都需要实现的基本方法
*/
export type MessageFn = (msg: string, isEnd?: boolean, originMsg?: object) => void;
export type ResponseFn = (msg: any, isEnd?: boolean, originMsg?: object) => void;
export abstract class BaseModel<T, C extends ModelConfig> {
/** 模型配置 */
protected config: C;
protected messageHelper: MessageHelper;
protected messageCallBack: MessageFn[] = [];
protected responseCallBack: ResponseFn[] = [];
constructor(config: C) {
const isValid = this.validateConfig(config);
if(isValid) {
this.config = config
this.messageHelper = new MessageHelper();
} else {
throw new Error('Invalid config')
}
}
abstract validateConfig(config: C): boolean;
setConfig(config: C) {
const isValid = this.validateConfig(config);
if(isValid) {
this.config = { ...(this.config || {}), ...config };
} else {
throw new Error('Invalid config')
}
}
bindResponseCallBack(cbs: ResponseFn[]) {
this.responseCallBack = cbs;
}
onResponse(cb: ResponseFn) {
this.responseCallBack.push(cb);
}
callResponse(msg: any, isEnd?: boolean, originMsg?: Object) {
this.config.onResponse?.(msg, isEnd, originMsg);
this.responseCallBack.forEach((cb) => cb(msg, isEnd, originMsg));
}
offResponse(cb: ResponseFn) {
this.responseCallBack = this.responseCallBack.filter((cb_) => cb_ !== cb);
}
bindMessageCallBack(cbs: MessageFn[]) {
this.messageCallBack = cbs;
}
onMessage(cb: MessageFn) {
this.messageCallBack.push(cb);
}
callMessage(msg: string, isEnd?: boolean, originMsg?: Object) {
this.config.onMessage?.(msg, isEnd, originMsg);
this.messageCallBack.forEach((cb) => cb(msg, isEnd, originMsg));
}
offMessage(cb: MessageFn) {
this.messageCallBack = this.messageCallBack.filter((cb_) => cb_ !== cb);
}
/** 发送请求到AI模型(非流式) */
abstract send(messages: T): Promise<string>;
/** 发送请求到AI模型(流式) */
abstract sendStream(messages: T, cb?: Function, isResponse?: boolean): Promise<object | string>;
/** 获取当前模型的配置信息 */
getConfig(): C {
return this.config;
}
}
/** 模型配置接口 */
export interface ModelConfig {
model: string;
onResponse?: ResponseFn; // 添加消息回调
onMessage?: MessageFn; // 添加消息回调
[key: string]: any;
}

8
packages/client/src/composables/useGlobal/index.ts

@ -0,0 +1,8 @@
export function useGlobal() {
const openCache = ref(true)
return {
openCache
}
}

96
packages/client/src/composables/useScroll/index.ts

@ -0,0 +1,96 @@
interface IOption {
containerEl: Readonly<globalThis.ShallowRef<HTMLDivElement | null>>;
contentEl: Readonly<globalThis.ShallowRef<HTMLDivElement | null>>;
// 是否首次滚动到底部
firstToBottom?: boolean;
}
const defaultOption: Partial<IOption> = {
firstToBottom: true,
};
export function useScroll(option: IOption) {
if (import.meta.env.SSR) {
return {
isAtBottom: ref(false),
isAtTop: ref(false),
scrollToBottom: () => {},
scrollToTop: () => {},
clear: () => {},
};
}
const { containerEl, contentEl, firstToBottom } = {
...defaultOption,
...option,
};
const isAtBottom = ref(false);
const isAtTop = ref(false);
let _firstToBottom = firstToBottom;
let resizeObserver: ResizeObserver | null = null;
nextTick(() => {
const targetElement = containerEl.value!;
const talkContent = contentEl.value!;
function handleScroll() {
if (targetElement.scrollHeight === targetElement.clientHeight) {
isAtBottom.value = false;
isAtTop.value = false;
return;
}
if (
Math.abs(
targetElement.scrollHeight -
targetElement.clientHeight -
targetElement.scrollTop
) <= 50
) {
isAtBottom.value = true;
} else {
isAtBottom.value = false;
}
if (targetElement.scrollTop <= 80) {
isAtTop.value = true;
} else {
isAtTop.value = false;
}
}
targetElement.onscroll = handleScroll;
resizeObserver = new ResizeObserver(() => {
// 当内容高度发生变化时,如果当前不在底部,则滚动到底部
if (targetElement.scrollHeight === targetElement.clientHeight) {
return;
}
if (isAtBottom.value || _firstToBottom) {
_firstToBottom = false;
scrollToBottom();
}
});
resizeObserver.observe(talkContent);
handleScroll();
});
onScopeDispose(clear);
function scrollToBottom() {
const container = containerEl.value!;
const scrollTop = container.scrollHeight - container.clientHeight;
container.scrollTop = scrollTop;
// container.scrollTo({ top: scrollTop, behavior: "smooth" });
}
function scrollToTop() {
containerEl.value!.scrollTop = 0;
}
function clear() {
if (containerEl.value) {
containerEl.value.onscroll = null;
}
if (resizeObserver) {
contentEl.value && resizeObserver.unobserve(contentEl.value);
resizeObserver.disconnect();
}
}
return {
isAtBottom,
scrollToTop,
scrollToBottom,
clear,
};
}

60
packages/client/src/entry-client.ts

@ -0,0 +1,60 @@
import { createApp } from "./main"
import { hydrateSSRContext, clearSSRContext } from 'x/composables/ssrContext'
import { createHead } from '@unhead/vue/client'
import "@/assets/styles/css/reset.css"
import 'vue-final-modal/style.css'
import "@/assets/styles/scss/common.scss"
import { MazUi } from 'maz-ui/plugins/maz-ui'
import { mazUi, ocean, pristine, obsidian } from '@maz-ui/themes'
import { zhCN } from '@maz-ui/translations'
// 水合 SSR 上下文(如果存在)
let ssrContext = null
if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) {
ssrContext = (window as any).__SSR_CONTEXT__
console.log('[Client] 水合 SSR 上下文:', ssrContext)
hydrateSSRContext(ssrContext)
} else {
console.log('[Client] 未找到 SSR 上下文')
}
// 使用相同的 SSR 上下文创建应用
const { app, pinia, router } = createApp(ssrContext)
const head = createHead()
app.use(head)
const presetCookie = useCookie("maz-preset-mode");
const colorCookie = useCookie("maz-color-mode");
app.use(MazUi, {
theme: {
mode: 'both',
strategy: 'hybrid',
// class会触发https://github.com/LouisMazel/maz-ui/blob/3051819550985506413a8f0d103e8f11b4cb17d7/packages/themes/src/composables/useTheme.ts#L165
// 使用class会触发如上链接的问题,导致执行两次setColorMode,从而覆盖掉cookie的值
darkModeStrategy: 'class', // 'class',
preset: { "maz-ui": mazUi, "ocean": ocean, "pristine": pristine, "obsidian": obsidian }[presetCookie.get() || "maz-ui"],
colorMode: presetCookie.get() ? (colorCookie.get() as "light" | "dark" | "auto") : "auto",
},
translations: {
messages: { zhCN },
},
})
if (ssrContext) {
pinia.state.value = ssrContext.piniaState
}
// 等待路由准备就绪,然后挂载应用
router.isReady().then(() => {
console.log('[Client] 路由已准备就绪,挂载应用')
app.mount('#app', true)
// 水合完成后清除 SSR 上下文
clearSSRContext()
})

132
packages/client/src/entry-server.ts

@ -0,0 +1,132 @@
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
import { createSSRContext } from 'x/composables/ssrContext'
import { basename } from 'node:path'
import { createHead } from '@unhead/vue/server'
export async function render(url: string, manifest: any, init?: { cookies?: Record<string, string> }) {
// 创建 SSR 上下文,包含数据缓存与 cookies
const ssrContext = createSSRContext()
if (init?.cookies) {
ssrContext.cookies = { ...init.cookies }
}
// 将 SSR 上下文传递给应用创建函数
const { app, pinia, router } = createApp(ssrContext)
const unHead = createHead({
disableDefaults: true
})
app.use(unHead)
// https://github.com/antfu-collective/vitesse
// https://github.com/unjs/unhead/blob/main/examples/vite-ssr-vue/src/entry-server.ts
useSeoMeta({
title: 'My Awesome Site',
description: 'My awesome site description',
}, { head: unHead })
useHead({
title: "aa",
htmlAttrs: {
lang: "zh-CN"
},
meta: [
{
charset: "UTF-8"
},
{
name: "viewport",
content: "width=device-width, initial-scale=1.0",
},
{
name: "description",
content: "Welcome to our website",
},
],
link: [
{
rel: "icon",
type: "image/svg+xml",
// href: () => (preferredDark.value ? "/favicon-dark.svg" : "/favicon.svg"),
href: () => "/vite.svg",
},
],
}, { head: unHead })
router.push(url); // 根据请求 URL 设置路由
await router.isReady(); // 等待路由准备完成
// passing SSR context object which will be available via useSSRContext()
// @vitejs/plugin-vue injects code into a component's setup() that registers
// itself on ctx.modules. After the render, ctx.modules would contain all the
// components that have been instantiated during this render call.
const ctx = { cache: ssrContext.cache }
const html = await renderToString(app, ctx)
// 将 SSR 上下文数据序列化到 HTML 中
// 使用更安全的方式序列化 Map
const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : []
const ssrData = JSON.stringify(cacheEntries)
// @ts-ignore
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
console.log('[SSR] 序列化缓存数据:', cacheEntries)
const head = `
<script>
window.__SSR_CONTEXT__ = {
cache: new Map(${ssrData}),
piniaState: ${JSON.stringify(pinia.state.value || {})}
};
</script>
${preloadLinks}
`
return { html, head, unHead, setCookies: ssrContext.setCookies || [] }
}
function renderPreloadLinks(modules: any, manifest: any) {
let links = ''
const seen = new Set()
modules.forEach((id: any) => {
const files = manifest[id]
if (files) {
files.forEach((file: any) => {
if (!seen.has(file)) {
seen.add(file)
const filename = basename(file)
if (manifest[filename]) {
for (const depFile of manifest[filename]) {
links += renderPreloadLink(depFile)
seen.add(depFile)
}
}
links += renderPreloadLink(file)
}
})
}
})
return links
}
function renderPreloadLink(file: string) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="${file}">`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="${file}">`
} else if (file.endsWith('.woff')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
} else if (file.endsWith('.woff2')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
} else if (file.endsWith('.gif')) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
} else if (file.endsWith('.png')) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">`
} else {
return ''
}
}

28
packages/client/src/layouts/base.vue

@ -0,0 +1,28 @@
<script setup lang="ts">
import { ModalsContainer } from "vue-final-modal";
const { openCache } = useGlobal();
const cacheList = ref<string[]>([]);
const route = useRoute();
watch(
() => route.fullPath,
() => {
if (route.meta.cache && !cacheList.value.includes(route.name as string)) {
cacheList.value.push(route.name as string);
}
},
{ immediate: true }
);
</script>
<template>
<RouterView v-slot="{ Component, route }">
<keep-alive :include="cacheList" v-if="openCache">
<component :key="route.fullPath" :is="Component" />
</keep-alive>
<component v-else :key="route.fullPath" :is="Component" />
</RouterView>
<ModalsContainer></ModalsContainer>
</template>

22
packages/client/src/main.ts

@ -0,0 +1,22 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import createSSRRouter from './router';
import { createPinia } from 'pinia'
import { createVfm } from 'vue-final-modal'
export function createApp(ssrContext?: any) {
const app = createSSRApp(App)
const router = createSSRRouter()
const pinia = createPinia()
const vfm = createVfm() as any
app.use(router)
app.use(pinia)
app.use(vfm)
// 如果有 SSR 上下文,注入到应用中
if (ssrContext) {
app.config.globalProperties.$ssrContext = ssrContext
}
return { app, router, pinia }
}

54
packages/client/src/pages/_M.vue

@ -0,0 +1,54 @@
<script setup lang="ts">
import { VueFinalModal } from "vue-final-modal";
defineProps<{
title?: string;
}>();
const emit = defineEmits<{
(e: "confirm"): void;
}>();
</script>
<template>
<VueFinalModal
class="confirm-modal"
content-class="confirm-modal-content"
overlay-transition="vfm-fade"
content-transition="vfm-fade"
>
<h1>{{ title }}</h1>
<slot />
<MazBtn @click="emit('confirm')">Confirm</MazBtn>
</VueFinalModal>
</template>
<style>
.confirm-modal {
display: flex;
justify-content: center;
align-items: center;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
}
.confirm-modal-content > * + * {
margin: 0.5rem 0;
}
.confirm-modal-content h1 {
font-size: 1.375rem;
}
.confirm-modal-content button {
margin: 0.25rem 0 0 auto;
padding: 0 8px;
border: 1px solid;
border-radius: 0.5rem;
}
.dark .confirm-modal-content {
background: #000;
}
</style>

31
packages/client/src/pages/index.vue

@ -0,0 +1,31 @@
<script setup lang="ts">
definePage({
name: "home",
alias: ["/", "/home"],
meta: {
cache: true,
},
});
defineOptions({
name: "home",
});
</script>
<template>
<div class="home">
<div class="chatbox-placeholder">
<ChatBox></ChatBox>
</div>
</div>
</template>
<style lang="scss" scoped>
.home {
height: 100%;
}
.chatbox-placeholder {
max-width: 1000px;
margin: 0 auto;
height: 100%;
}
</style>

34
packages/client/src/pages/test/index.vue

@ -0,0 +1,34 @@
<template>
<div @click="$router.push('/test/index2')">HOMaaE</div>
<input type="text" />
</template>
<script setup lang="ts">
definePage({
name: "test",
meta: {
cache: false,
},
});
defineOptions({
name: "test",
});
useHead({
title: "Home Page",
meta: [
{
name: "description",
content: "Welcome to our website",
},
],
});
if (import.meta.env.SSR) {
const cache = useShareCache()!;
cache.set("time", Date.now());
cache.set("homeData", { message: "Hello from Home Page!" });
console.log("Home ssrContext.cache:", cache);
} else {
console.log(useShareCache());
}
</script>

28
packages/client/src/pages/test/index2.vue

@ -0,0 +1,28 @@
<template>
<div>
<h1 @click="$router.back()">About Page</h1>
<MazBtn @click="visible = !visible"> Exec animation </MazBtn>
<MazInput></MazInput>
<MazExpandAnimation v-model="visible">
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat.
</MazExpandAnimation>
</div>
</template>
<script setup lang="ts">
definePage({
name: "about",
meta: {
cache: true
}
})
defineOptions({
name: "about",
});
const visible = ref(false);
</script>

1
packages/client/src/pages/test/readme.md

@ -0,0 +1 @@
仅供测试的界面

28
packages/client/src/router/index.ts

@ -0,0 +1,28 @@
// https://uvr.esm.is/guide/extending-routes.html#definepage
import { createRouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router';
// import NotFound from '../pages/not-found/index.vue';
import { routes } from 'vue-router/auto-routes'
import { setupLayouts } from 'virtual:generated-layouts'
// import BaseLayout from '@/layouts/base.vue';
export default function createSSRRouter() {
return createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式
routes: setupLayouts(routes),
// routes: [
// {
// name: "BaseLayout", path: '', component: BaseLayout, children: [
// { name: "home", path: '', meta: { cache: true }, component: () => import('../pages/home/index.vue') },
// { name: "about", path: 'about', meta: { cache: true }, component: () => import('../pages/about/index.vue') },
// ]
// },
// // { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') },
// // { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') },
// // 404
// { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
// ],
});
}

20
packages/client/src/store/auth.ts

@ -0,0 +1,20 @@
import { defineStore } from "pinia";
// export const useAuthStore = defineStore("auth", {
// state: () => ({
// user: null as null | { name: string },
// }),
// actions: {
// setUser(user: { name: string }) {
// this.user = user;
// }
// }
// });
export const useAuthStore = defineStore("auth", () => {
const user = ref<null | { name: string }>(null);
function setUser(u: { name: string }) {
user.value = u;
}
return { user, setUser };
});

66
packages/client/src/typed-router.d.ts

@ -0,0 +1,66 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'home': RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>,
'/_M': RouteRecordInfo<'/_M', '/_M', Record<never, never>, Record<never, never>>,
'test': RouteRecordInfo<'test', '/test', Record<never, never>, Record<never, never>>,
'about': RouteRecordInfo<'about', '/test/index2', Record<never, never>, Record<never, never>>,
}
/**
* Route file to route info map by unplugin-vue-router.
* Used by the volar plugin to automatically type useRoute()
*
* Each key is a file path relative to the project root with 2 properties:
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
* - views: names of nested views (can be passed to <RouterView name="...">)
*
* @internal
*/
export interface _RouteFileInfoMap {
'src/pages/index.vue': {
routes: 'home'
views: never
}
'src/pages/_M.vue': {
routes: '/_M'
views: never
}
'src/pages/test/index.vue': {
routes: 'test'
views: never
}
'src/pages/test/index2.vue': {
routes: 'about'
views: never
}
}
/**
* Get a union of possible route names in a certain route component file.
* Used by the volar plugin to automatically type useRoute()
*
* @internal
*/
export type _RouteNamesForFilePath<FilePath extends string> =
_RouteFileInfoMap extends Record<FilePath, infer Info>
? Info['routes']
: keyof RouteNamedMap
}

2
src/vite-env.d.ts → packages/client/src/vite-env.d.ts

@ -1,4 +1,6 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />
/// <reference types="vite-plugin-vue-layouts/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'

27
packages/client/src/vue.d.ts

@ -0,0 +1,27 @@
import 'vue-router'
// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明
export { }
declare module 'vue-router' {
interface RouteMeta {
// 是可选的
cache?: boolean
}
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$ssrContext?: Record<string, any>
}
interface ComponentInternalInstance {
_nuxtClientOnly?: boolean
}
}
declare global {
const process: {
env: {
AI_APIKEY: string
}
}
}

34
tsconfig.json → packages/client/tsconfig.json

@ -2,10 +2,13 @@
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "esnext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@ -13,14 +16,31 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "vue",
/* Linting */
"strict": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"components.d.ts",
"auto-imports.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

0
tsconfig.node.json → packages/client/tsconfig.node.json

81
packages/client/vite.config.ts

@ -0,0 +1,81 @@
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import devtoolsJson from 'vite-plugin-devtools-json';
import VueRouter from 'unplugin-vue-router/vite'
import Layouts from 'vite-plugin-vue-layouts';
import { VueRouterAutoImports } from 'unplugin-vue-router'
import { unheadVueComposablesImports } from '@unhead/vue'
import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver';
import {
MazComponentsResolver,
MazDirectivesResolver,
MazModulesResolver
} from 'maz-ui/resolvers'
import { MazIconsResolver } from '@maz-ui/icons/resolvers'
// https://vite.dev/config/
export default defineConfig({
cacheDir: '../../node_modules/.vite',
resolve: {
alias: {
'@': resolve(__dirname, 'src')
},
},
define: {
"process.env.AI_APIKEY": `"${process.env.AI_APIKEY}"`,
},
build: {
emptyOutDir: true,
},
// https://github.com/posva/unplugin-vue-router/discussions/349#discussioncomment-9043123
ssr: {
noExternal: process.env.NODE_ENV === 'development' ? ['vue-router'] : []
},
css: {
preprocessorOptions: {
"scss": {
additionalData: `@use "@/assets/styles/scss/_global.scss" as *;\n`
}
}
},
plugins: [
devtoolsJson(),
VueRouter({
root: resolve(__dirname),
dts: 'src/typed-router.d.ts',
}),
vue(),
Layouts({
defaultLayout: "base"
}),
Components({
dts: true,
dirs: ['src/components', '../../internal/x/components'],
excludeNames: [/^\_.+/],
resolvers: [
AntDesignXVueResolver(),
MazIconsResolver(),
MazComponentsResolver(),
MazDirectivesResolver(),
],
}),
AutoImport({
dts: true,
dtsMode: "overwrite",
resolvers: [MazModulesResolver()],
ignore: ["**/_*/**/*"],
imports: ['vue', 'vue-router', 'pinia', VueRouterAutoImports, unheadVueComposablesImports,
{
'ofetch': [
['$fetch']
]
}
],
dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"],
vueTemplate: true,
}),
],
})

10
packages/server/drizzle.config.ts

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'mysql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

8
packages/server/drizzle/0000_thick_may_parker.sql

@ -0,0 +1,8 @@
CREATE TABLE `users_table` (
`id` serial AUTO_INCREMENT NOT NULL,
`name` varchar(255) NOT NULL,
`age` int NOT NULL,
`email` varchar(255) NOT NULL,
CONSTRAINT `users_table_id` PRIMARY KEY(`id`),
CONSTRAINT `users_table_email_unique` UNIQUE(`email`)
);

70
packages/server/drizzle/meta/0000_snapshot.json

@ -0,0 +1,70 @@
{
"version": "5",
"dialect": "mysql",
"id": "3770df0e-b3f2-4223-8e66-2880f634d8b0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_table_id": {
"name": "users_table_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_table_email_unique": {
"name": "users_table_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

13
packages/server/drizzle/meta/_journal.json

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1760625865036,
"tag": "0000_thick_may_parker",
"breakpoints": true
}
]
}

43
packages/server/package.json

@ -0,0 +1,43 @@
{
"name": "server",
"type": "module",
"exports": {
"./*": {
"import": "./src/*.ts",
"types": "./src/*.d.ts"
}
},
"scripts": {
"db": "bunx --bun drizzle-kit "
},
"devDependencies": {
"@types/formidable": "^3.4.5",
"@types/koa": "^3.0.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa-compose": "^3.2.8",
"@types/koa-send": "^4.1.6",
"@types/path-is-absolute": "^1.0.2",
"drizzle-kit": "^0.31.5"
},
"dependencies": {
"@types/jsonwebtoken": "^9.0.10",
"assert": "^2.1.0",
"drizzle-orm": "^0.44.6",
"formidable": "^3.5.4",
"http-errors": "^2.0.0",
"jsonwebtoken": "^9.0.2",
"koa": "^3.0.1",
"koa-bodyparser": "^4.4.1",
"koa-compose": "^4.1.0",
"koa-connect": "^2.1.0",
"koa-send": "^5.0.1",
"koa-session": "^7.0.2",
"log4js": "^6.9.1",
"minimatch": "^10.0.3",
"mysql2": "^3.15.2",
"node-cron": "^4.2.1",
"path-is-absolute": "^2.0.0",
"path-to-regexp": "^8.3.0",
"unhead": "^2.0.19"
}
}

29
server/main.ts → packages/server/src/api/main.ts

@ -1,8 +1,8 @@
import { parseCookieHeader, serializeCookie } from "../src/compose/cookieUtils";
import app from "./app";
import { parseCookieHeader, serializeCookie } from "helper/cookie";
import app from "../app";
export function bootstrapServer() {
async function fetchFirstSuccess(urls) {
async function fetchFirstSuccess(urls: string[]) {
for (const url of urls) {
try {
const res = await fetch(url, {
@ -32,21 +32,22 @@ export function bootstrapServer() {
}
app.use(async (ctx, next) => {
const cookies = parseCookieHeader(ctx.request.headers.cookie as string);
// const cookies = parseCookieHeader(ctx.request.headers.cookie as string);
// 读取
const token = cookies["demo_2token"];
// const token = cookies["demo_2token"];
// 写入(HttpOnly 更安全)
if (!token) {
const setItem = serializeCookie("demo_2token", "from-mw", {
httpOnly: true,
path: "/",
sameSite: "lax",
});
ctx.set("Set-Cookie", [setItem]);
}
// // 写入(HttpOnly 更安全)
// if (!token) {
// const setItem = serializeCookie("demo_2token", "from-mw", {
// httpOnly: true,
// path: "/",
// sameSite: "lax",
// });
// ctx.set("Set-Cookie", [setItem]);
// }
if (ctx.originalUrl !== "/api/pics/random") return await next();
const { type, data } = await fetchFirstSuccess([
"https://api.miaomc.cn/image/get",
]);

19
packages/server/src/app.ts

@ -0,0 +1,19 @@
import { validateEnvironment } from "@/utils/EnvValidator"
import Koa from "koa"
import { logger } from "@/logger"
// 启动前验证环境变量
if (!validateEnvironment()) {
logger.error("环境变量验证失败,应用退出")
process.exit(1)
}
const app = new Koa({
asyncLocalStorage: true,
keys: (process.env.SESSION_SECRET || '').split(",").filter(v => !!v).map(s => s.trim())
})
export default app
export {
app
}

318
packages/server/src/base/BaseController.ts

@ -0,0 +1,318 @@
// @ts-nocheck
import { R } from "@/utils/R"
import { logger } from "@/logger.js"
import CommonError from "@/utils/error/CommonError.js"
/**
*
*
*
*/
class BaseController {
constructor() {
// 绑定所有方法的this上下文,确保在路由中使用时this指向正确
this._bindMethods()
}
/**
* this上下文
* @private
*/
_bindMethods() {
const proto = Object.getPrototypeOf(this)
const propertyNames = Object.getOwnPropertyNames(proto)
propertyNames.forEach(name => {
if (name !== 'constructor' && typeof this[name] === 'function') {
this[name] = this[name].bind(this)
}
})
}
/**
*
* @param {*} ctx - Koa上下文
* @param {*} data -
* @param {string} message -
* @param {number} statusCode - HTTP状态码
*/
success(ctx, data = null, message = null, statusCode = 200) {
return R.response(statusCode, data, message)
}
/**
*
* @param {*} ctx - Koa上下文
* @param {string} message -
* @param {*} data -
* @param {number} statusCode - HTTP状态码
*/
error(ctx, message = "操作失败", data = null, statusCode = 500) {
return R.response(statusCode, data, message)
}
/**
*
*
* @param {Function} handler -
* @returns {Function}
*/
handleRequest(handler) {
return async (ctx, next) => {
try {
await handler.call(this, ctx, next)
} catch (error) {
logger.error("Controller error:", error)
if (error instanceof CommonError) {
// 业务异常,返回具体错误信息
return this.error(ctx, error.message, null, 400)
}
// 系统异常,返回通用错误信息
return this.error(ctx, "系统内部错误", null, 500)
}
}
}
/**
*
* @param {*} ctx - Koa上下文
* @param {Object} paginationResult -
* @param {string} message -
*/
paginated(ctx, paginationResult, message = "获取数据成功") {
const { data, pagination } = paginationResult
return this.success(ctx, {
list: data,
pagination
}, message)
}
/**
*
* @param {*} ctx - Koa上下文
* @param {Object} rules -
* @throws {CommonError}
*/
validateParams(ctx, rules) {
const data = { ...ctx.request.body, ...ctx.query, ...ctx.params }
for (const [field, rule] of Object.entries(rules)) {
const value = data[field]
// 必填验证
if (rule.required && (value === undefined || value === null || value === '')) {
throw new CommonError(`${rule.label || field}不能为空`)
}
// 类型验证
if (value !== undefined && value !== null && rule.type) {
if (rule.type === 'number' && isNaN(value)) {
throw new CommonError(`${rule.label || field}必须是数字`)
}
if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new CommonError(`${rule.label || field}格式不正确`)
}
}
// 长度验证
if (value && rule.minLength && value.length < rule.minLength) {
throw new CommonError(`${rule.label || field}长度不能少于${rule.minLength}个字符`)
}
if (value && rule.maxLength && value.length > rule.maxLength) {
throw new CommonError(`${rule.label || field}长度不能超过${rule.maxLength}个字符`)
}
}
return data
}
/**
*
* @param {*} ctx - Koa上下文
* @param {Object} defaults -
* @returns {Object}
*/
getPaginationParams(ctx, defaults = {}) {
const {
page = defaults.page || 1,
limit = defaults.limit || 10,
orderBy = defaults.orderBy || 'created_at',
order = defaults.order || 'desc'
} = ctx.query
return {
page: Math.max(1, parseInt(page) || 1),
limit: Math.min(100, Math.max(1, parseInt(limit) || 10)), // 限制最大100条
orderBy,
order: order.toLowerCase() === 'asc' ? 'asc' : 'desc'
}
}
/**
*
* @param {*} ctx - Koa上下文
* @returns {Object}
*/
getSearchParams(ctx) {
const { keyword, status, category, author } = ctx.query
const params = {}
if (keyword && keyword.trim()) {
params.keyword = keyword.trim()
}
if (status) {
params.status = status
}
if (category) {
params.category = category
}
if (author) {
params.author = author
}
return params
}
/**
*
* @param {*} ctx - Koa上下文
* @param {string} fieldName -
* @returns {Object}
*/
getUploadedFile(ctx, fieldName = 'file') {
const files = ctx.request.files
if (!files || !files[fieldName]) {
return null
}
const file = Array.isArray(files[fieldName]) ? files[fieldName][0] : files[fieldName]
return {
name: file.originalFilename || file.name,
size: file.size,
type: file.mimetype || file.type,
path: file.filepath || file.path
}
}
/**
*
* @param {*} ctx - Koa上下文
* @param {string} url - URL
* @param {string} message -
*/
redirect(ctx, url, message = null) {
if (message) {
// 设置flash消息(如果有toast中间件)
if (ctx.flash) {
ctx.flash('success', message)
}
}
ctx.redirect(url)
}
/**
*
* @param {*} ctx - Koa上下文
* @param {string} template -
* @param {Object} data -
* @param {Object} options -
*/
async render(ctx, template, data = {}, options = {}) {
const defaultOptions = {
// includeSite: true,
// includeUser: true,
...options
}
return await ctx.render(template, data, defaultOptions)
}
/**
* JSON API响应助手
* @param {*} ctx - Koa上下文
* @param {*} data -
* @param {string} message -
* @param {number} statusCode - HTTP状态码
*/
json(ctx, data = null, message = null, statusCode = 200) {
ctx.status = statusCode
ctx.body = {
success: statusCode < 400,
data,
message,
timestamp: new Date().toISOString()
}
}
/**
*
* @param {*} ctx - Koa上下文
* @returns {Object|null}
*/
getCurrentUser(ctx) {
return ctx.state.user || null
}
/**
*
* @param {*} ctx - Koa上下文
* @returns {boolean}
*/
isLoggedIn(ctx) {
return !!ctx.state.user
}
/**
* ID
* @param {*} ctx - Koa上下文
* @returns {string|number|null} ID
*/
getCurrentUserId(ctx) {
const user = this.getCurrentUser(ctx)
return user ? (user.id || user._id || null) : null
}
/**
*
* @param {*} ctx - Koa上下文
* @param {string|Array} permission -
* @throws {CommonError}
*/
checkPermission(ctx, permission) {
const user = this.getCurrentUser(ctx)
if (!user) {
throw new CommonError("用户未登录")
}
// 这里可以根据实际需求实现权限检查逻辑
// 例如检查用户角色、权限列表等
// if (!user.hasPermission(permission)) {
// throw new CommonError("权限不足")
// }
}
/**
*
* @param {*} ctx - Koa上下文
* @param {Object} resource -
* @param {string} ownerField - 'author'
* @throws {CommonError}
*/
checkOwnership(ctx, resource, ownerField = 'author') {
const user = this.getCurrentUser(ctx)
if (!user) {
throw new CommonError("用户未登录")
}
const userId = this.getCurrentUserId(ctx)
if (resource[ownerField] !== userId && resource[ownerField] !== user.username) {
throw new CommonError("无权限操作此资源")
}
}
}
export default BaseController
export { BaseController }

38
packages/server/src/booststap.ts

@ -0,0 +1,38 @@
import app from "./app"
import { bootstrapServer } from "./api/main"
import LoadMiddleware from "./middleware/install"
import { Env } from "helper/env"
import os from "node:os"
import "./jobs"
bootstrapServer()
await LoadMiddleware(app)
const server = app.listen(Env.port, () => {
const address = server.address()
if (address != null && typeof address !== 'string') {
const port = address.port
// 获取本地 IP
const getLocalIP = () => {
const interfaces = os.networkInterfaces()
for (const name of Object.keys(interfaces)) {
if (!interfaces[name]) continue
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address
}
}
}
return "localhost"
}
const localIP = getLocalIP()
console.log(`────────────────────────────────────────`)
console.log(`🚀 服务器已启动`)
console.log(` 本地访问: http://localhost:${port}`)
console.log(` 局域网: http://${localIP}:${port}`)
console.log(` 启动时间: ${new Date().toLocaleString()}`)
console.log(`────────────────────────────────────────`)
}
})

4
packages/server/src/db/index.ts

@ -0,0 +1,4 @@
import { drizzle } from "drizzle-orm/mysql2";
// You can specify any property from the mysql2 connection options
const db = drizzle({ connection: { uri: process.env.DATABASE_URL } });

8
packages/server/src/db/schema.ts

@ -0,0 +1,8 @@
import { int, mysqlTable, serial, varchar } from 'drizzle-orm/mysql-core';
export const usersTable = mysqlTable('users_table', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
age: int().notNull(),
email: varchar({ length: 255 }).notNull().unique(),
});

11
packages/server/src/env.d.ts

@ -0,0 +1,11 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
SESSION_SECRET: string;
JWT_SECRET: string;
}
}
}
export { };

66
packages/server/src/jobs/index.ts

@ -0,0 +1,66 @@
import fs from 'fs';
import path from 'path';
import scheduler from './scheduler';
import { TaskOptions } from 'node-cron';
import { jobsDir } from 'helper/path';
import { logger } from '@/logger';
interface OneJob {
id: string
cronTime: string
task: Function
options: TaskOptions,
autoStart: boolean,
[key: string]: Function | string | boolean | TaskOptions | undefined
}
export function defineJob(job: OneJob) {
return job;
}
const _jobsDir = process.env.NODE_ENV === 'production' ? jobsDir : path.join(__dirname, 'jobs');
const jobModules: Record<string, OneJob> = {};
fs.readdirSync(_jobsDir).forEach(async file => {
if (!file.endsWith(process.env.NODE_ENV === 'production' ? 'Job.js' : 'Job.ts')) return;
const jobModule = await import(path.join(_jobsDir, file));
const job = jobModule.default || jobModule;
if (job && job.id && job.cronTime && typeof job.task === 'function') {
jobModules[job.id] = job;
scheduler.add(job.id, job.cronTime, job.task, job.options);
if (job.autoStart) scheduler.start(job.id);
}
});
logger.info(`[Jobs] 加载了 ${Object.keys(jobModules).length} 个任务`);
function callHook(id: string, hookName: string) {
const job = jobModules[id];
if (job && typeof job[hookName] === 'function') {
try {
job[hookName]();
} catch (e) {
logger.error(`[Job:${id}] ${hookName} 执行异常:`, e);
}
}
}
export default {
start: (id: string) => {
callHook(id, 'beforeStart');
scheduler.start(id);
},
stop: (id: string) => {
scheduler.stop(id);
callHook(id, 'afterStop');
},
updateCronTime: (id: string, cronTime: string) => scheduler.updateCronTime(id, cronTime),
list: () => scheduler.list(),
reload: (id: string) => {
const job = jobModules[id];
if (job) {
scheduler.remove(id);
scheduler.add(job.id, job.cronTime, job.task, job.options);
}
}
};

12
packages/server/src/jobs/jobs/exampleJob.ts

@ -0,0 +1,12 @@
import { jobLogger } from "@/logger"
import { defineJob } from ".."
export default defineJob({
id: "example",
cronTime: "*/10 * * * * *", // 每10秒执行一次
task: () => {
jobLogger.info("Example Job 执行了")
},
options: {},
autoStart: false,
})

63
packages/server/src/jobs/scheduler.ts

@ -0,0 +1,63 @@
import cron, { ScheduledTask, TaskFn, TaskOptions } from 'node-cron';
export interface Job { job: ScheduledTask; cronTime: string; task: Function; options: TaskOptions; status: 'running' | 'stopped' }
class Scheduler {
jobs: Map<string, Job>;
constructor() {
this.jobs = new Map();
}
add(id: string, cronTime: string, task: Function, options: TaskOptions = {}) {
if (this.jobs.has(id)) this.remove(id);
const job = cron.createTask(cronTime, task as TaskFn, { ...options, noOverlap: true });
this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' });
}
execute(id: string) {
const entry = this.jobs.get(id);
if (entry && entry.status === 'running') {
entry.job.execute();
}
}
start(id: string) {
const entry = this.jobs.get(id);
if (entry && entry.status !== 'running') {
entry.job.start();
entry.status = 'running';
}
}
stop(id: string) {
const entry = this.jobs.get(id);
if (entry && entry.status === 'running') {
entry.job.stop();
entry.status = 'stopped';
}
}
remove(id: string) {
const entry = this.jobs.get(id);
if (entry) {
entry.job.destroy();
this.jobs.delete(id);
}
}
updateCronTime(id: string, newCronTime: string) {
const entry = this.jobs.get(id);
if (entry) {
this.remove(id);
this.add(id, newCronTime, entry.task, entry.options);
}
}
list() {
return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({
id, cronTime, status
}));
}
}
export default new Scheduler();

59
packages/server/src/logger.ts

@ -0,0 +1,59 @@
import log4js from "log4js";
import { logDir } from 'helper/path';
log4js.configure({
appenders: {
all: {
type: "file",
filename: `${logDir}/all.log`,
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
error: {
type: "file",
filename: `${logDir}/error.log`,
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
jobs: {
type: "file",
filename: `${logDir}/jobs.log`,
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
console: {
type: "console",
layout: {
type: "pattern",
pattern: '\x1b[90m[%d{hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m',
},
},
},
categories: {
jobs: { appenders: ["console", "jobs"], level: "info" },
// error: { appenders: ["console", "error"], level: "error" },
default: { appenders: ["console", "all"], level: "all" },
},
});
export const logger = log4js.getLogger();
export const jobLogger = log4js.getLogger('jobs');

38
packages/server/src/middleware/Auth/index.ts

@ -0,0 +1,38 @@
import { minimatch } from "minimatch"
import CommonError from "@/utils/error/CommonError"
import { DefaultContext, Next } from "koa"
export const JWT_SECRET = process.env.JWT_SECRET
function matchList(list: any[], path: string) {
for (const item of list) {
if (typeof item === "string" && minimatch(path, item, { dot: true })) {
return { matched: true }
}
if (typeof item === "object" && minimatch(path, item.pattern, { dot: true })) {
return { matched: true }
}
}
return { matched: false }
}
export function AuthMiddleware(options: any = {
whiteList: [],
blackList: []
}) {
return (ctx: DefaultContext, next: Next) => {
if (ctx.session.user) {
ctx.state.user = ctx.session.user
}
// 黑名单优先生效
if (matchList(options.blackList, ctx.path).matched) {
throw new CommonError("禁止访问", CommonError.ERR_CODE.FORBIDDEN)
}
// 白名单处理
const white = matchList(options.whiteList, ctx.path)
if (!white.matched) {
throw new CommonError(`禁止访问:${ctx.path}`, CommonError.ERR_CODE.FORBIDDEN)
}
return next()
}
}

100
packages/server/src/middleware/Controller/index.ts

@ -0,0 +1,100 @@
import fs from "fs"
import path from "path"
import { logger } from "@/logger.js"
import compose from "koa-compose"
import { Next, ParameterizedContext } from "koa"
async function scanControllers(rootDir: string) {
const routers = []
const stack: string[] = [rootDir]
while (stack.length) {
const dir = stack.pop()
if (!dir) continue
let files
try {
files = fs.readdirSync(dir)
} catch (error: any) {
logger.error(`[控制器注册] ❌ 读取目录失败 ${dir}: ${error.message}`)
continue
}
for (const file of files) {
if (file.startsWith("_")) continue
const fullPath = path.join(dir, file)
let stat
try {
stat = fs.statSync(fullPath)
} catch (error: any) {
logger.error(`[控制器注册] ❌ 读取文件信息失败 ${fullPath}: ${error.message}`)
continue
}
if (stat.isDirectory()) {
stack.push(fullPath)
continue
}
if (!fullPath.replace(/\\/g, "/").includes("/controller/")) continue
let fileName = fullPath.replace(rootDir + path.sep, "")
try {
const controllerModule = await import(fullPath)
const controller = controllerModule.default || controllerModule
if (!controller) {
logger.warn(`[控制器注册] ${fileName} - 缺少默认导出,跳过注册`)
continue
}
let routesFactory = controller.createRoutes || controller.default?.createRoutes || controller.default || controller
if (typeof routesFactory === "function") {
routesFactory = routesFactory.bind(controller)
}
if (typeof routesFactory !== "function") {
logger.warn(`[控制器注册] ⚠️ ${fileName} - 未找到 createRoutes 方法或导出对象`)
continue
}
let routerResult
try {
routerResult = routesFactory()
} catch (error: any) {
logger.error(`[控制器注册] ❌ ${fileName} - createRoutes() 执行失败: ${error.message}`)
continue
}
const list = Array.isArray(routerResult) ? routerResult : [routerResult]
let added = 0
for (const r of list) {
if (r && typeof r.middleware === "function") {
routers.push(r)
added++
} else {
logger.warn(`[控制器注册] ⚠️ ${fileName} - createRoutes() 返回的部分路由器对象无效`)
}
}
if (added > 0) logger.debug(`[控制器注册] ✅ ${fileName} - 创建成功 (${added})`)
} catch (importError: any) {
logger.error(`[控制器注册] ❌ ${fileName} - 模块导入失败: ${importError.message}`)
logger.error(importError)
}
}
}
return routers
}
export default async function (options: { root: string, handleBeforeEachRequest: Function }) {
const { root, handleBeforeEachRequest } = options
if (!root) {
throw new Error("controller root is required")
}
const routers = await scanControllers(root)
const allRouters: any[] = []
for (let i = 0; i < routers.length; i++) {
const router = routers[i]
allRouters.push(router.middleware((options = {}) => handleBeforeEachRequest(options)))
}
return async function (ctx: ParameterizedContext, next: Next) {
return await compose(allRouters)(ctx, next)
}
}

59
packages/server/src/middleware/ResponseTime/index.ts

@ -0,0 +1,59 @@
import { logger } from "@/logger"
import type { ParameterizedContext, Next } from "koa"
// 静态资源扩展名列表
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"]
function isStaticResource(path: string): boolean {
return staticExts.some(ext => path.endsWith(ext))
}
export default async (ctx: ParameterizedContext, next: Next) => {
if (isStaticResource(ctx.path)) {
await next()
return
}
if (!ctx.path.includes("/api")) {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
if (ms > 500) {
logger.info(`${ctx.path} | ⏱️ ${ms}ms`)
}
return
}
// API日志记录
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
const Threshold = 0
if (ms > Threshold) {
logger.info("====================[ ➡️ REQ]====================")
// 用户信息(假设ctx.state.user存在)
const user = ctx.state && ctx.state.user ? ctx.state.user : null
// IP
const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress
// 请求参数
const params = {
query: ctx.query,
body: ctx.request.body,
}
// 响应状态码
const status = ctx.status
// 组装日志对象
const logObj = {
method: ctx.method,
path: ctx.path,
url: ctx.url,
user: user ? { id: user.id, username: user.username } : null,
ip,
params,
status,
ms,
}
logger.info(JSON.stringify(logObj, null, 2))
logger.info("====================[ ⬅️ END]====================\n")
}
}

186
packages/server/src/middleware/Send/index.ts

@ -0,0 +1,186 @@
// @ts-nocheck
/**
* koa-send@5.0.1 ES Module版本
*
*/
import fs from 'fs';
import { promisify } from 'util';
import logger from 'log4js';
import resolvePath from './resolve-path.js';
import createError from 'http-errors';
import assert from 'assert';
import { normalize, basename, extname, resolve, parse, sep } from 'path';
import { fileURLToPath } from 'url';
import path from "path"
// 转换为ES Module格式
const log = logger.getLogger('koa-send');
const stat = promisify(fs.stat);
const access = promisify(fs.access);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
*
* @param {string} path -
* @returns {Promise<boolean>}
*/
async function exists(path) {
try {
await access(path);
return true;
} catch (e) {
return false;
}
}
/**
*
* @param {Context} ctx - Koa上下文对象
* @param {String} path -
* @param {Object} [opts] -
* @returns {Promise} - Promise
*/
async function send(ctx, path, opts = {}) {
assert(ctx, 'koa context required');
assert(path, 'pathname required');
// 移除硬编码的public目录,要求必须通过opts.root配置
const root = opts.root;
if (!root) {
throw new Error('Static root directory must be configured via opts.root');
}
const trailingSlash = path[path.length - 1] === '/';
path = path.substr(parse(path).root.length);
const index = opts.index || 'index.html';
const maxage = opts.maxage || opts.maxAge || 0;
const immutable = opts.immutable || false;
const hidden = opts.hidden || false;
const format = opts.format !== false;
const extensions = Array.isArray(opts.extensions) ? opts.extensions : false;
const brotli = opts.brotli !== false;
const gzip = opts.gzip !== false;
const setHeaders = opts.setHeaders;
if (setHeaders && typeof setHeaders !== 'function') {
throw new TypeError('option setHeaders must be function');
}
// 解码路径
path = decode(path);
if (path === -1) return ctx.throw(400, 'failed to decode');
// 索引文件支持
if (index && trailingSlash) path += index;
path = resolvePath(root, path);
// 隐藏文件支持
if (!hidden && isHidden(root, path)) return;
let encodingExt = '';
// 尝试提供压缩文件
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) {
path = path + '.br';
ctx.set('Content-Encoding', 'br');
ctx.res.removeHeader('Content-Length');
encodingExt = '.br';
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) {
path = path + '.gz';
ctx.set('Content-Encoding', 'gzip');
ctx.res.removeHeader('Content-Length');
encodingExt = '.gz';
}
// 尝试添加文件扩展名
if (extensions && !/\./.exec(basename(path))) {
const list = [].concat(extensions);
for (let i = 0; i < list.length; i++) {
let ext = list[i];
if (typeof ext !== 'string') {
throw new TypeError('option extensions must be array of strings or false');
}
if (!/^\./.exec(ext)) ext = `.${ext}`;
if (await exists(`${path}${ext}`)) {
path = `${path}${ext}`;
break;
}
}
}
// 获取文件状态
let stats;
try {
stats = await stat(path);
// 处理目录
if (stats.isDirectory()) {
if (format && index) {
path += `/${index}`;
stats = await stat(path);
} else {
return;
}
}
} catch (err) {
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
if (notfound.includes(err.code)) {
throw createError(404, err);
}
err.status = 500;
throw err;
}
if (setHeaders) setHeaders(ctx.res, path, stats);
// 设置响应头
ctx.set('Content-Length', stats.size);
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString());
if (!ctx.response.get('Cache-Control')) {
const directives = [`max-age=${(maxage / 1000) | 0}`];
if (immutable) directives.push('immutable');
ctx.set('Cache-Control', directives.join(','));
}
if (!ctx.type) ctx.type = type(path, encodingExt);
ctx.body = fs.createReadStream(path);
return path;
}
/**
*
* @param {string} root -
* @param {string} path -
* @returns {boolean}
*/
function isHidden(root, path) {
path = path.substr(root.length).split(sep);
for (let i = 0; i < path.length; i++) {
if (path[i][0] === '.') return true;
}
return false;
}
/**
*
* @param {string} file -
* @param {string} ext -
* @returns {string} MIME类型
*/
function type(file, ext) {
return ext !== '' ? extname(basename(file, ext)) : extname(file);
}
/**
* URL路径
* @param {string} path -
* @returns {string|number}
*/
function decode(path) {
try {
return decodeURIComponent(path);
} catch (err) {
return -1;
}
}
export default send;

74
packages/server/src/middleware/Send/resolve-path.ts

@ -0,0 +1,74 @@
/*!
* resolve-path
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2015-2018 Douglas Christopher Wilson
* MIT Licensed
*/
/**
* ES Module
*
*/
import createError from 'http-errors';
import { join, normalize, resolve, sep } from 'path';
import pathIsAbsolute from 'path-is-absolute';
/**
*
* @private
*/
const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/;
/**
*
* @param {string} rootPath -
* @param {string} relativePath -
* @returns {string}
* @public
*/
function resolvePath(rootPath: string, relativePath: string) {
let path = relativePath;
let root = rootPath;
// root是可选的,类似于root.resolve
if (arguments.length === 1) {
path = rootPath;
root = process.cwd();
}
if (root == null) {
throw new TypeError('argument rootPath is required');
}
if (typeof root !== 'string') {
throw new TypeError('argument rootPath must be a string');
}
if (path == null) {
throw new TypeError('argument relativePath is required');
}
if (typeof path !== 'string') {
throw new TypeError('argument relativePath must be a string');
}
// 包含NULL字节是恶意的
if (path.indexOf('\0') !== -1) {
throw createError(400, 'Malicious Path');
}
// 路径绝不能是绝对路径
if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) {
throw createError(400, 'Malicious Path');
}
// 路径超出根目录
if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) {
throw createError(403);
}
// 拼接相对路径
return normalize(join(resolve(root), path));
}
export default resolvePath;

16
packages/server/src/middleware/Session/index.ts

@ -0,0 +1,16 @@
import { DefaultContext } from 'koa';
import session from 'koa-session';
export default (app: DefaultContext) => {
const CONFIG = {
key: 'koa:sess', // cookie key
maxAge: 86400000, // 1天
httpOnly: true,
signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys
rolling: false,
renew: false,
secure: process.env.NODE_ENV === "production" && process.env.HTTPS_ENABLE === "on",
sameSite: "strict", // https://scotthelme.co.uk/csrf-is-dead/
};
return session(CONFIG, app);
};

104
packages/server/src/middleware/install.ts

@ -0,0 +1,104 @@
import { SsrMiddleWare } from "../ssr"
import bodyParser from "koa-bodyparser"
import app from "@/app"
import ResponseTime from "./ResponseTime"
import Controller from "./Controller"
import path from "node:path"
import jwt from "jsonwebtoken"
import AuthError from "@/utils/error/AuthError"
import CommonError from "@/utils/error/CommonError"
import { DefaultContext, Next, ParameterizedContext } from "koa"
import { AuthMiddleware } from "./Auth"
import Session from "./Session"
import Send from "./Send"
import { getPathByRoot, serverModules, serverPublic } from "helper/path"
type App = typeof app
export default async (app: App) => {
app.use(ResponseTime)
// 拦截 Chrome DevTools 探测请求,直接返回 204
app.use((ctx: DefaultContext, next: Next) => {
if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") {
ctx.status = 204
ctx.body = ""
return
}
return next()
})
const publicPath = process.env.NODE_ENV === 'production' ? serverPublic : getPathByRoot("public")
app.use(async (ctx, next) => {
if (!ctx.path.startsWith("/public")) return await next()
if (ctx.method.toLowerCase() === "get") {
try {
await Send(ctx, ctx.path.replace("/public", ""), { root: publicPath, maxAge: 0, immutable: false })
} catch (err: any) {
if (err.status !== 404) throw err
}
}
})
app.use(Session(app))
// 权限设置
app.use(
AuthMiddleware({
whiteList: [
// 所有请求放行
{ pattern: "/" },
{ pattern: "/**/*" },
],
blackList: [
// 禁用api请求
// "/api",
// "/api/",
// "/api/**/*",
],
})
)
app.use(bodyParser())
app.use(
await Controller({
root: process.env.NODE_ENV === 'production' ? serverModules : path.resolve(__dirname, "../modules"),
handleBeforeEachRequest: (options: any) => {
const { auth = true } = options || {}
return async (ctx: ParameterizedContext, next: Next) => {
if (ctx.session && ctx.session.user) {
ctx.state.user = ctx.session.user
} else {
const authorizationString = ctx.headers && ctx.headers["authorization"]
if (authorizationString) {
const token = authorizationString.replace(/^Bearer\s/, "")
try {
ctx.state.user = jwt.verify(token, process.env.JWT_SECRET)
} catch (_) {
// 无效token忽略
}
}
}
if (auth === false && ctx.state.user) {
throw new CommonError("不能登录查看")
}
if (auth === "try") {
return next()
}
if (auth === true && !ctx.state.user) {
throw new AuthError("需要登录才能访问")
}
return await next()
}
},
})
)
// 处理SSR的插件,理应放在所有路由中间件的最后
await SsrMiddleWare(app)
}

51
packages/server/src/modules/Job/controller/index.ts

@ -0,0 +1,51 @@
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
import JobService from "../services"
import { R } from "@/utils/R"
import Router from "@/utils/Router"
import { ParameterizedContext } from "koa"
class JobController {
static createRoutes() {
const controller = new this()
const router = new Router({ prefix: "/api/jobs", auth: "try" })
router.get("", controller.list.bind(controller))
router.get("/", controller.list.bind(controller))
router.get("/start/:id", controller.start.bind(controller))
router.post("/stop/:id", controller.stop.bind(controller))
router.post("/update/:id", controller.updateCron.bind(controller))
return router
}
jobService: JobService
constructor() {
this.jobService = new JobService()
}
async list(ctx: ParameterizedContext) {
const data = this.jobService.listJobs()
R.response(R.SUCCESS, data)
}
async start(ctx: ParameterizedContext) {
const { id } = ctx.params
this.jobService.startJob(id)
R.response(R.SUCCESS, null, `${id} 任务已启动`)
}
async stop(ctx: ParameterizedContext) {
const { id } = ctx.params
this.jobService.stopJob(id)
R.response(R.SUCCESS, null, `${id} 任务已停止`)
}
async updateCron(ctx: ParameterizedContext) {
const { id } = ctx.params
const { cronTime } = ctx.request.body as { cronTime: string }
this.jobService.updateJobCron(id, cronTime)
R.response(R.SUCCESS, null, `${id} 任务频率已修改`)
}
}
export default JobController

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save