Compare commits
13 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
45d2dcac9a | 2 months ago |
|
|
e8d7866b8b | 2 months ago |
|
|
0ba614355e | 2 months ago |
|
|
8753cde672 | 2 months ago |
|
|
848257759b | 2 months ago |
|
|
a372428940 | 2 months ago |
|
|
e578bcfa8f | 2 months ago |
|
|
91ca9904f2 | 2 months ago |
|
|
24d266b3da | 2 months ago |
|
|
844aa7f0b6 | 2 months ago |
|
|
b94114513b | 2 months ago |
|
|
1cd96abed6 | 2 months ago |
|
|
04e655166f | 2 months ago |
139 changed files with 6935 additions and 1352 deletions
@ -0,0 +1,2 @@ |
|||
[install] |
|||
registry = "https://registry.npmmirror.com/" |
|||
@ -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 |
|||
@ -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 |
|||
Binary file not shown.
@ -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'] |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -0,0 +1,8 @@ |
|||
{ |
|||
"name": "helper", |
|||
"exports": { |
|||
"./*": { |
|||
"import": "./src/*.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() |
|||
}) |
|||
*/ |
|||
@ -0,0 +1,9 @@ |
|||
|
|||
const port = process.env.PORT || 5173 |
|||
const base = process.env.BASE || '/' |
|||
|
|||
|
|||
export const Env = { |
|||
port: Number(port), |
|||
base |
|||
} |
|||
@ -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') |
|||
@ -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,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 |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
{ |
|||
"name": "x", |
|||
"main": "index.ts", |
|||
"type": "module" |
|||
} |
|||
@ -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 |
|||
@ -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. |
|||
@ -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']> |
|||
} |
|||
} |
|||
@ -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'] |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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" |
|||
} |
|||
} |
|||
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,12 @@ |
|||
<script setup lang="ts"> |
|||
onServerPrefetch(() => { |
|||
const AuthStore = useAuthStore(); |
|||
AuthStore.setUser({ |
|||
name: "zha123ngsan", |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<RouterView></RouterView> |
|||
</template> |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
@ -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> |
|||
@ -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: |
|||
"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" }, |
|||
]; |
|||
@ -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> |
|||
@ -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/document注入DOMPurify |
|||
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> |
|||
@ -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> |
|||
@ -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: |
|||
"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" }, |
|||
]; |
|||
@ -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> |
|||
@ -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的要求,主人的事项是最重要的。 |
|||
@ -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> |
|||
@ -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> |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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 |
|||
@ -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); |
|||
@ -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> |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
|
|||
export function useGlobal() { |
|||
const openCache = ref(true) |
|||
|
|||
return { |
|||
openCache |
|||
} |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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() |
|||
}) |
|||
|
|||
@ -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 '' |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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 } |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -0,0 +1 @@ |
|||
仅供测试的界面 |
|||
@ -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 },
|
|||
// ],
|
|||
}); |
|||
} |
|||
@ -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 }; |
|||
}); |
|||
@ -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 |
|||
} |
|||
@ -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' |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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, |
|||
}), |
|||
], |
|||
}) |
|||
@ -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!, |
|||
}, |
|||
}); |
|||
@ -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`) |
|||
); |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
{ |
|||
"version": "7", |
|||
"dialect": "mysql", |
|||
"entries": [ |
|||
{ |
|||
"idx": 0, |
|||
"version": "5", |
|||
"when": 1760625865036, |
|||
"tag": "0000_thick_may_parker", |
|||
"breakpoints": true |
|||
} |
|||
] |
|||
} |
|||
@ -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" |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 } |
|||
@ -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(`────────────────────────────────────────`) |
|||
} |
|||
}) |
|||
@ -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 } }); |
|||
@ -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(), |
|||
}); |
|||
@ -0,0 +1,11 @@ |
|||
|
|||
declare global { |
|||
namespace NodeJS { |
|||
interface ProcessEnv { |
|||
SESSION_SECRET: string; |
|||
JWT_SECRET: string; |
|||
} |
|||
} |
|||
} |
|||
|
|||
export { }; |
|||
@ -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); |
|||
} |
|||
} |
|||
}; |
|||
@ -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, |
|||
}) |
|||
@ -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(); |
|||
@ -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'); |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
@ -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; |
|||
@ -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; |
|||
@ -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); |
|||
}; |
|||
@ -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) |
|||
|
|||
} |
|||
@ -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…
Reference in new issue