构建 Next BFF 级 AI 助手 - T.AI

Posted by 谌中钱 on 2026-04-08

1 项目预览

2 技术选型

本项目采用的技术点版本,除了兼容性考虑外,都采用最新版,使用 pnpm-lock.yaml 保证协同一致性。

技术点版本生产优势
基础环境Git、Node.js、NVM、PNPMgit 2.53.0.2
node.js 24.14.1
nvm 1.2.2.0
pnpm 10.33.0
🔹Git:版本控制,多人协作
🔹NVM:Node.js 多版本管理器
🔹Node.js:JS 运行环境,前端全栈基础设施
🔹PNPM:高性能的 NPM(Node.js 的包管理器)
UI设计参考 DeepSeek、字节豆包、阿里千问 的 Web 端、移动端最新🔹市场已验证,符合用户习惯
前端框架React、Next.js、create-next-appreact 19.2.4
next 16.2.3
create-next-app 16.2.3
🔹React:前端主流框架,生态强大,大厂标准
🔹Next.js:React 的全栈增强框架,解决 SEO、首屏慢、路由麻烦、接口分离等痛点
🔹create-next-app:Next.js 的脚手架,一键搭建标准化的项目结构
UITailwind CSS、shadcn/ui、lucide-react、sonnertailwindcss ^4
shadcn ^4.2.0
lucide-react ^1.8.0
sonner ^2.0.7
🔹Tailwind CSS:CSS 原子化样式工具类框架,快速写页面样式,简化响应式布局
🔹shadcn/ui:基于 Tailwind CSS 的高质量可定制组件库
🔹lucide-react:React 专用的图标库,轻量
🔹sonner:轻量提示组件,官方推荐
数据管理Zustand、SWR、fetchzustand ^5.0.12
swr ^2.4.1
🔹Zustand:前端全局状态存取,轻量
🔹SWR:后端数据调度,负责统一管理服务层接口,实现数据自动缓存、刷新、重试等,轻量
文档解析FileReader、mammoth、pdfjs-distmammoth ^1.12.0
pdfjs-dist ^5.6.205
🔹mammoth:纯前端解析 .docx 文档的库
🔹pdfjs-dist:纯前端解析 .pdf 文件的库
RAG技术@langchain/core、@langchain/textsplitters、@xenova/transformers、pg@langchain/core ^1.1.39
@langchain/textsplitters ^1.0.1
@xenova/transformers ^2.17.2
pg^8.20.0
🔹@langchain/core:RAG 流程调度核心,负责把 文档读取 → 分块 → 向量化 → 检索 → 提示词 → 大模型回答,串成一条完整流水线。
🔹@langchain/textsplitters:文档分块,切割后送入 embedding 模型
🔹@xenova/transformers:纯 JS 本地跑 AI 模型,负责向量生成、向量检索、问题分类等

当前项目使用的模型有:
🔹Xenova/all-MiniLM-L6-v2:嵌入模型,负责向量生成、检索,轻量超快
🔹Xenova/distilbert-base-uncased-mnli:分类模型,负责用户意图识别,超轻量蒸馏模型
后端框架Next API RoutesNext.js 内置的后端接口能力🔹Next API Routes:提供接口能力,信息安全隔离,支持边缘化
数据库PostgreSQL、pgvectorPostgreSQL 18
pgvector:是 PostgreSQL 的向量插件
🔹PostgreSQL:世界上最稳定、最强大的开源数据库 🐶,高一致性,适合复杂分析等
🔹pgvector:负责把 Xenova 模型生成的向量存进数据库,使用用户问题生产的向量去检索数据库中相似的文档片段
第三方工具dayjs、uuiddayjs ^1.11.20
uuid ^13.0.0
🔹dayjs:轻量级时间日期处理库
🔹uuid:唯一 ID 生成库
工程化TypeScript、ESLint、Prettier、Husky、lint-staged、Turbopacktypescript ^5
eslint ^9
prettier ^3.8.1
husky ^9.1.7
lint-staged ^16.4.0
Turbopack:Next.js 16.2.3 已全面默认的打包工具
🔹TypeScript:给 JS 加类型,防止 BUG,更安全
🔹ESLint:检查代码语法错误、不规范写法
🔹Prettier:自动格式化代码,统一风格
🔹Husky:Git 提交钩子,拦截不合格代码
🔹lint-staged:只检查本次修改的文件,速度飞快
🔹Turbopack:由 Vercel 官方开发的下一代 JavaScript 打包工具,比 webpack 快 10~100 倍,比 Vite 快 5~20 倍,兼容 webpack
自动化部署Docker、Nginx、GitHub ActionsDocker 29.1.3
Nginx 1.24.0
🔹Docker:容器化,环境统一,跨平台
🔹Nginx:高性能 Web 服务器,提供反向代理,解决跨域,SSL证书配置等功能
🔹GitHub Actions:CI/CD 自动化部署
边缘部署Vercel、Vercel Postgres

2 项目开发

2.1 项目初始化

 1# 进入项目的父目录
 2cd /d D:\dev\workspace
 3
 4# 创建并初始化项目
 5pnpm create next-app@latest nextbff-ai-assistant --ts --eslint --react-compiler --tailwind --src-dir --app --import-alias "@/*" --no-agents-md
 6
 7# .../19d71cb1a2a-1af40                    |   +1 +
 8# .../19d71cb1a2a-1af40                    | Progress: resolved 1, reused 0, downloaded 1, added 1, done
 9# Creating a new Next.js app in D:\dev\workspace\nextbff-ai-assistant.
10
11# Using pnpm.
12
13# Initializing project with template: app-tw
14
15
16# Installing dependencies:
17# - next
18# - react
19# - react-dom
20
21# Installing devDependencies:
22# - @tailwindcss/postcss
23# - @types/node
24# - @types/react
25# - @types/react-dom
26# - babel-plugin-react-compiler
27# - eslint
28# - eslint-config-next
29# - tailwindcss
30# - typescript
31
32#  WARN  Request took 11474ms: https://registry.npmjs.org/next
33# Downloading next@16.2.3: 33.99 MB/33.99 MB, done
34# Packages: +350
35# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
36# Downloading @next/swc-win32-x64-msvc@16.2.3: 43.69 MB/43.69 MB, done
37# Progress: resolved 424, reused 348, downloaded 7, added 350, done
38
39# dependencies:
40# + next 16.2.3
41# + react 19.2.4
42# + react-dom 19.2.4
43
44# devDependencies:
45# + @tailwindcss/postcss 4.2.2
46# + @types/node 20.19.39 (25.5.2 is available)
47# + @types/react 19.2.14
48# + @types/react-dom 19.2.3
49# + babel-plugin-react-compiler 1.0.0
50# + eslint 9.39.4 (10.2.0 is available)
51# + eslint-config-next 16.2.3
52# + tailwindcss 4.2.2
53# + typescript 5.9.3 (6.0.2 is available)
54
55# Done in 2m 24.7s using pnpm v10.33.0
56
57# Generating route types...
58# ✓ Types generated successfully
59
60# Initialized a git repository.
61
62# Success! Created nextbff-ai-assistant at D:\dev\workspace\nextbff-ai-assistant
63
64
65# 进入项目目录
66cd .\nextbff-ai-assistant

2.1.1 配置优化

2.1.1.1 项目依赖与脚本配置

/package.json:添加生产环境脚本、清理缓存脚本、TS 类型检查脚本、代码格式检查脚本 等

1# 清理缓存脚本依赖 rimraf
2# rimraf 是一个跨平台的 Node.js 工具,可以在 Windows、macOS 和 Linux 上正常工作,完美替代 Unix 的 rm -rf 命令
3pnpm add -D rimraf
 1{
 2  // ... existing code ...
 3  // 生产环境脚本
 4  "prod": "pnpm build && pnpm start",
 5  // 清理缓存脚本
 6  "clean": "pnpm store prune --force && rimraf pnpm-lock.yaml node_modules .next",
 7
 8  // TS 类型 检查脚本
 9  "type:check": "tsc --noEmit",
10  // 代码规范 检查脚本
11  "lint:check": "eslint",
12  // 代码格式 检查脚本
13  "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,scss}\"",
14
15  // 自动修复 ESLint 问题(包括 Prettier)
16  "lint:fix": "eslint . --fix",
17  // 代码格式化脚本
18  "format:fix": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,scss}\"",
19
20  // 三合一检查脚本
21  "check-all": "pnpm type:check && pnpm lint:check && pnpm format:check",
22  // 二合一修复脚本
23  "fix-all": "pnpm lint:fix && pnpm format:fix"
24}
2.1.1.2 Next 核心配置

/next.config.ts:添加安全配置等

 1import type { NextConfig } from "next";
 2
 3const nextConfig: NextConfig = {
 4    // 启用生产轻量化构建,支持 API Route
 5    output: "standalone",
 6
 7    // 关闭 React 严格模式(临时解决开发模式下 useEffect 执行两次)
 8    reactStrictMode: false,
 9
10    // 开启 React 官方自动优化编译器
11    // 自动帮你做 useMemo/useCallback 优化,提升页面渲染流畅度,无需手写优化 Hooks
12    reactCompiler: true,
13
14    // 开启输出压缩(Next 高版本默认已开启,显式声明更稳妥)
15    // 启用 Gzip/Brotli 压缩,减小静态资源(JS/CSS/HTML)体积,加快页面加载速度
16    compress: true,
17
18    // 禁用 x-powered-by 头(安全最佳实践)
19    // 隐藏响应头里的 X-Powered-By: Next.js,不让攻击者知道你的技术栈,提升网站安全性
20    poweredByHeader: false,
21
22    // 允许整个局域网访问开发服务,方便调试移动端,不然移动访问时事件等无法水合过去
23    allowedDevOrigins: ["192.168.1.*"],
24
25    // 配置网站安全头部
26    async headers() {
27        return [
28            {
29                source: "/:path*",
30                headers: [
31                    // 开启 DNS 预解析,加快页面加载
32                    { key: "X-DNS-Prefetch-Control", value: "on" },
33                    // 强制浏览器用 HTTPS 访问,防止网络劫持
34                    { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
35                ],
36            },
37        ];
38    },
39};
40
41export default nextConfig;
2.1.1.3 TypeScript 编译配置

/tsconfig.json:添加更严格的类型检查选项(已注释,按需配置)

 1{
 2    "compilerOptions": {
 3        "target": "ES2017", // 目标 ECMAScript 版本
 4        "lib": ["dom", "dom.iterable", "esnext"], // 包含浏览器环境类型
 5        "allowJs": true, // 允许导入 JS 文件
 6        "skipLibCheck": true, // 跳过声明文件检查(加速编译)
 7        "strict": true, // 启用严格模式
 8        "noEmit": true, // 不输出 JS 文件(Next.js 处理)
 9        "esModuleInterop": true, // 兼容 CommonJS/ESM
10        "module": "esnext", // 使用最新模块系统
11        "moduleResolution": "bundler", // 现代打包工具解析策略
12        "resolveJsonModule": true, // 允许导入 JSON
13        "isolatedModules": true, // 每个文件独立编译
14        "jsx": "react-jsx", // 使用新的 JSX 转换
15        "incremental": true, // 增量编译(加速)
16        // "noUnusedLocals": true,  // 报告未使用的局部变量
17        // "noUnusedParameters": true,  // 报告未使用的参数
18        // "exactOptionalPropertyTypes": true,  // 严格可选属性类型
19        // "noImplicitReturns": true,  // 确保所有代码路径都有返回值
20        // "noFallthroughCasesInSwitch": true,  // 禁止 switch case 穿透
21        // "forceConsistentCasingInFileNames": true,  // 强制文件名大小写一致
22        "plugins": [
23            {
24                "name": "next"
25            }
26        ],
27        "paths": {
28            "@/*": ["./src/*"]
29        }
30    },
31    "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
32    "exclude": ["node_modules"]
33}
2.1.1.4 ESLint 代码规范配置

/eslint.config.mjs:添加更多自定义规则(已注释,按需配置)

 1import { defineConfig, globalIgnores } from "eslint/config";
 2import nextVitals from "eslint-config-next/core-web-vitals";
 3import nextTs from "eslint-config-next/typescript";
 4import prettier from "eslint-plugin-prettier/recommended"; // 添加这行
 5
 6const eslintConfig = defineConfig([
 7    ...nextVitals,
 8    ...nextTs,
 9    prettier, // 集成代码格式化插件 prettier,需要添加这行,必须在最后
10    {
11        rules: {
12            "prettier/prettier": "error", // 将 Prettier 问题视为错误(可选)
13            "max-lines": [
14                "error",
15                {
16                    // 单个文件最大行数(默认 300 行,超了报错)
17                    max: 300, // 文件最大行数
18                    skipBlankLines: true, // 忽略空行
19                    skipComments: true, // 忽略注释
20                },
21            ],
22            // 允许使用 any 类型(极少数时候会用到,关闭报错)
23            "@typescript-eslint/no-explicit-any": "off",
24            // 关闭 原生JS 未使用表达式警告(比如用了 map 但不接收返回值,纯粹遍历)
25            "no-unused-expressions": "off",
26            // 关闭 TypeScript 未使用表达式警告
27            "@typescript-eslint/no-unused-expressions": "off",
28            // 全局关闭 React Hook 依赖项警告(永远不提示)
29            "react-hooks/exhaustive-deps": "off",
30            // // 禁止 console.log(生产环境)
31            // 'no-console': ['warn', { allow: ['warn', 'error'] }],
32
33            // // 强制使用箭头函数
34            // 'prefer-arrow-callback': 'error',
35
36            // // 强制 const 而非 let
37            // 'prefer-const': 'error',
38
39            // // 禁止未使用的变量
40            // '@typescript-eslint/no-unused-vars': ['error', {
41            //   argsIgnorePattern: '^_',
42            //   varsIgnorePattern: '^_'
43            // }],
44        },
45    },
46    // Override default ignores of eslint-config-next.
47    globalIgnores([
48        // Default ignores of eslint-config-next:
49        ".next/**",
50        "out/**",
51        "build/**",
52        "next-env.d.ts",
53        "node_modules/**",
54        "public/**",
55        "tmp/**",
56    ]),
57]);
58
59export default eslintConfig;
2.1.1.4.1 集成代码格式化插件 prettier

下面是代码内集成方式,保证项目所有人统一格式。

如果只是自己使用的话,可以安装 VS Code 的插件 Prettier - Code formatter:

  • 可以设置保存自动格式化,然后设置默认格式化工具为 prettier
  • 最后去插件配置里设置相关配置路径 .prettierrc.prettierignore 即可
 1pnpm install -D prettier eslint-config-prettier eslint-plugin-prettier prettier-plugin-tailwindcss prettier-plugin-import-sort import-sort-style-module
 2
 3# prettier:核心格式化工具
 4# eslint-config-prettier:会自动禁用所有与 Prettier 冲突的 ESLint 规则
 5# eslint-plugin-prettier:会将 Prettier 格式问题作为 ESLint 错误报告
 6# prettier-plugin-tailwindcss:会自动排序 Tailwind CSS 类名,非常有用!!
 7
 8
 9# 关于导入的排序,由于 相关排序插件 和 Tailwind CSS 类名排序插件不兼容,目前暂时使用手动排序:
10
11"use client";
12
13// ==================== 根布局组件 ==================== //
14
15// ========== React、Next、Utils ========== //
16// ========== Components、CSS ========== //
17// ========== Icon、Type ========== //
18// ========== Stroe、Constants ========== //
19// ========== Hooks ========== //
20// ========== Services ========== //

创建 Prettier 配置文件:/.prettierrc

 1{
 2  "semi": true, // 强制使用分号
 3  "trailingComma": "all", // 强制使用尾随逗号
 4  "singleQuote": false, // 禁用单引号
 5  "printWidth": 9999, // 自动换行长度
 6  "tabWidth": 4, // 缩进 4 个空格
 7  "useTabs": false, // 禁用制表符
 8  "bracketSpacing": true, // 对象括号前后加空格
 9  "arrowParens": "always", // 箭头函数参数使用括号
10  "endOfLine": "lf", // 换行符类型,使用 lf
11  // 其他可选值:
12  // "crlf" - Windows 风格 (\r\n)
13  // "cr"   - 旧 Mac 风格 (\r)
14  // "auto" - 根据文件内容自动检测
15  // 建议:"lf" - 跨平台兼容性最好(Git 可以处理转换)
16  "plugins": ["prettier-plugin-tailwindcss"]
17}

创建 .prettierignore 文件:/.prettierignore

# Dependencies
node_modules

# Build outputs
.next
out
build
dist

# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Environment files
.env*

# IDE
.vscode
.idea
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Generated files
next-env.d.ts
*.tsbuildinfo

# Lock files (保持原样)
pnpm-lock.yaml
package-lock.json
yarn.lock

# Public assets (通常不需要格式化)
public/*.svg
public/*.png
public/*.jpg

# 禁止格式化 Markdown 文件
*.md

# 禁止格式化 shadcn/ui 原子组件
src/components/ui

tmp
2.1.1.4.2 Git 提交格式化配置

使用 Husky + lint-staged 在提交前自动格式化:

1pnpm add -D husky lint-staged
2pnpm exec husky init

会自动创建 /.husky/pre-commit 文件,修改为:

1#!/usr/bin/env sh
2. "$(dirname -- "$0")/_/husky.sh"
3
4npx lint-staged

/package.json 中添加 lint-staged 配置:

 1{
 2  "lint-staged": {
 3    "*.{ts,tsx,js,jsx}": [
 4      "eslint --fix",
 5      "prettier --write"
 6    ],
 7    "*.{json,css,scss}": [
 8      "prettier --write"
 9    ]
10  } 
11}
2.1.1.5 pnpm 工作区配置

/pnpm-workspace.yaml: 当前项目并非 monorepo 单体仓库,不需要 workspace 配置。ignoredBuiltDependencies 用于跳过某些包的安装后构建步骤,但是项目需要这些包,应删除此文件。

1ignoredBuiltDependencies:
2  - sharp
3  - unrs-resolver
2.1.1.6 解除 pnpm 警告

项目开发过程中,如果遇到一些包安装的错误,可以运行 pnpm approve-builds 检查试试。

 1# 项目开发前,先清空下缓存,安装依赖
 2pnpm clean && pnpm install
 3# 会提示:
 4# ╭ Warning ───────────────────────────────────────────────────────────────────────────────────╮
 5# │                                                                                            │
 6# │   Ignored build scripts: sharp@0.34.5, unrs-resolver@1.11.1.                               │
 7# │   Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.   │
 8# │                                                                                            │
 9# ╰────────────────────────────────────────────────────────────────────────────────────────────╯
10# 原因:pnpm 自身的安全控制,导致 sharp、unrs-resolver 等包无法安装,但是项目需要这些包
11# 解除警告,进行安装
12pnpm approve-builds
13# 按 a键 全选,然后回车,按 y 键,安装完毕即可

2.2 UI 开发

组件开发原则:每个组件尽量控制在 300行 以内(通过ESLint可检查),职责要分离清晰,便于维护

  • 页面组件:尽量只用来 调用各子组件 和 布局。
  • 业务组件:
    • JSX 里面除了只有1行代码的的函数,都要抽出来。
    • 业务逻辑较多,或可复用的,都应该把业务抽成对应的 Hook。

2.2.1 安装依赖

 1# 安装组件库 shadcn/ui(自带图标库 lucide-react)
 2pnpm dlx shadcn@latest init --base radix --preset nova
 3# 安装轻量级全局提示容器 sonner(shadcn/ui 官方推荐)
 4pnpm install sonner
 5# 安装文档解析依赖
 6pnpm install mammoth pdfjs-dist
 7# 安装需要的工具
 8pnpm install uuid dayjs
 9
10# 按需添加组件
11pnpm dlx shadcn@latest add button-group button dialog drawer dropdown-menu input select separator textarea tooltip

2.2.2 全局样式

/src/app/globals.css

  1/* ==================== 全局样式 ==================== */
  2
  3@import "tailwindcss";
  4@import "tw-animate-css";
  5@import "shadcn/tailwind.css";
  6
  7@custom-variant dark (&:is(.dark *));
  8
  9@theme inline {
 10    --color-background: var(--background);
 11    --color-foreground: var(--foreground);
 12    --font-sans: var(--font-sans);
 13    --font-mono: var(--font-geist-mono);
 14    --font-heading: var(--font-sans);
 15    --color-sidebar-ring: var(--sidebar-ring);
 16    --color-sidebar-border: var(--sidebar-border);
 17    --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
 18    --color-sidebar-accent: var(--sidebar-accent);
 19    --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
 20    --color-sidebar-primary: var(--sidebar-primary);
 21    --color-sidebar-foreground: var(--sidebar-foreground);
 22    --color-sidebar: var(--sidebar);
 23    --color-chart-5: var(--chart-5);
 24    --color-chart-4: var(--chart-4);
 25    --color-chart-3: var(--chart-3);
 26    --color-chart-2: var(--chart-2);
 27    --color-chart-1: var(--chart-1);
 28    --color-ring: var(--ring);
 29    --color-input: var(--input);
 30    --color-border: var(--border);
 31    --color-destructive: var(--destructive);
 32    --color-accent-foreground: var(--accent-foreground);
 33    --color-accent: var(--accent);
 34    --color-muted-foreground: var(--muted-foreground);
 35    --color-muted: var(--muted);
 36    --color-secondary-foreground: var(--secondary-foreground);
 37    --color-secondary: var(--secondary);
 38    --color-primary-foreground: var(--primary-foreground);
 39    --color-primary: var(--primary);
 40    --color-popover-foreground: var(--popover-foreground);
 41    --color-popover: var(--popover);
 42    --color-card-foreground: var(--card-foreground);
 43    --color-card: var(--card);
 44    --radius-sm: calc(var(--radius) * 0.6);
 45    --radius-md: calc(var(--radius) * 0.8);
 46    --radius-lg: var(--radius);
 47    --radius-xl: calc(var(--radius) * 1.4);
 48    --radius-2xl: calc(var(--radius) * 1.8);
 49    --radius-3xl: calc(var(--radius) * 2.2);
 50    --radius-4xl: calc(var(--radius) * 2.6);
 51}
 52
 53:root {
 54    --background: oklch(1 0 0);
 55    --foreground: oklch(0.145 0 0);
 56    --card: oklch(1 0 0);
 57    --card-foreground: oklch(0.145 0 0);
 58    --popover: oklch(1 0 0);
 59    --popover-foreground: oklch(0.145 0 0);
 60    --primary: oklch(0.205 0 0);
 61    --primary-foreground: oklch(0.985 0 0);
 62    --secondary: oklch(0.97 0 0);
 63    --secondary-foreground: oklch(0.205 0 0);
 64    --muted: oklch(0.97 0 0);
 65    --muted-foreground: oklch(0.556 0 0);
 66    --accent: oklch(0.97 0 0);
 67    --accent-foreground: oklch(0.205 0 0);
 68    --destructive: oklch(0.577 0.245 27.325);
 69    --border: oklch(0.922 0 0);
 70    --input: oklch(0.922 0 0);
 71    --ring: oklch(0.708 0 0);
 72    --chart-1: oklch(0.87 0 0);
 73    --chart-2: oklch(0.556 0 0);
 74    --chart-3: oklch(0.439 0 0);
 75    --chart-4: oklch(0.371 0 0);
 76    --chart-5: oklch(0.269 0 0);
 77    --radius: 0.625rem;
 78    --sidebar: oklch(0.985 0 0);
 79    --sidebar-foreground: oklch(0.145 0 0);
 80    --sidebar-primary: oklch(0.205 0 0);
 81    --sidebar-primary-foreground: oklch(0.985 0 0);
 82    --sidebar-accent: oklch(0.97 0 0);
 83    --sidebar-accent-foreground: oklch(0.205 0 0);
 84    --sidebar-border: oklch(0.922 0 0);
 85    --sidebar-ring: oklch(0.708 0 0);
 86}
 87
 88.dark {
 89    --background: oklch(0.145 0 0);
 90    --foreground: oklch(0.985 0 0);
 91    --card: oklch(0.205 0 0);
 92    --card-foreground: oklch(0.985 0 0);
 93    --popover: oklch(0.205 0 0);
 94    --popover-foreground: oklch(0.985 0 0);
 95    --primary: oklch(0.922 0 0);
 96    --primary-foreground: oklch(0.205 0 0);
 97    --secondary: oklch(0.269 0 0);
 98    --secondary-foreground: oklch(0.985 0 0);
 99    --muted: oklch(0.269 0 0);
100    --muted-foreground: oklch(0.708 0 0);
101    --accent: oklch(0.269 0 0);
102    --accent-foreground: oklch(0.985 0 0);
103    --destructive: oklch(0.704 0.191 22.216);
104    --border: oklch(1 0 0 / 10%);
105    --input: oklch(1 0 0 / 15%);
106    --ring: oklch(0.556 0 0);
107    --chart-1: oklch(0.87 0 0);
108    --chart-2: oklch(0.556 0 0);
109    --chart-3: oklch(0.439 0 0);
110    --chart-4: oklch(0.371 0 0);
111    --chart-5: oklch(0.269 0 0);
112    --sidebar: oklch(0.205 0 0);
113    --sidebar-foreground: oklch(0.985 0 0);
114    --sidebar-primary: oklch(0.488 0.243 264.376);
115    --sidebar-primary-foreground: oklch(0.985 0 0);
116    --sidebar-accent: oklch(0.269 0 0);
117    --sidebar-accent-foreground: oklch(0.985 0 0);
118    --sidebar-border: oklch(1 0 0 / 10%);
119    --sidebar-ring: oklch(0.556 0 0);
120}
121
122@layer base {
123    * {
124        @apply border-border outline-ring/50;
125    }
126
127    body {
128        @apply bg-background text-foreground;
129    }
130
131    html {
132        @apply font-sans;
133    }
134}
135
136/* ==================== 全局美化滚动条 ==================== */
137/* 滚动条宽度 */
138::-webkit-scrollbar {
139    /* 垂直滚动条宽度 */
140    width: 4px;
141    /* 水平滚动条高度 */
142    height: 4px;
143}
144
145/* 滚动条滑块 */
146::-webkit-scrollbar-thumb {
147    background: #94a3b880;
148    border-radius: 999px;
149    /* hover 平滑过渡 */
150    transition: all 0.2s ease;
151}
152
153/* 滚动条轨道 */
154::-webkit-scrollbar-track {
155    background: #f8fafc80;
156    border-radius: 999px;
157}
158
159/* 滚动条滑块 hover */
160::-webkit-scrollbar-thumb:hover {
161    /* 主题色 */
162    background: #052658;
163}
164
165/* 兼容 Firefox */
166* {
167    scrollbar-width: thin;
168    scrollbar-color: #94a3b880 #f8fafc80;
169}
170
171/* ==================== 自定义 Toast 轻提示样式 ==================== */
172/* 全局容器 */
173.custom-toast {
174    /* 普通提示(主题深蓝 #052658) */
175    --info-or-other-bg: #f5f8fb;
176    --info-or-other-text: #052658;
177    --info-or-other-border: #d4e1f5;
178    /* 成功提示 */
179    --success-bg: #dcfce7;
180    --success-text: #166534;
181    --success-border: #bbf7d0;
182    /* 错误提示 */
183    --error-bg: #fee2e2;
184    --error-text: #fb2c36;
185    --error-border: #fecaca;
186    /* 警告提示(金黄配色) */
187    --warning-bg: #fff9e6;
188    --warning-text: #f0b100;
189    --warning-border: #fadb8e;
190    /* 通用样式 */
191    --border-radius: 8px;
192    --padding: 12px 16px;
193    --shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
194    --font-size: 14px;
195    --font-weight: 500;
196    /* PC提示位置 */
197    --width: max-content !important;
198}
199
200/* 移动端提示位置 */
201@media (max-width: 600px) {
202    .custom-toast [data-sonner-toast] {
203        width: max-content !important;
204        margin: 0 auto !important;
205        left: -32px !important;
206    }
207}
208
209/* 通用样式 */
210.custom-toast [data-sonner-toast] {
211    border-radius: var(--border-radius) !important;
212    padding: var(--padding) !important;
213    box-shadow: var(--shadow) !important;
214    font-size: var(--font-size) !important;
215    font-weight: var(--font-weight) !important;
216    border: 1px solid var(--info-border) !important;
217    /* PC提示位置 */
218    top: 10px !important;
219    left: -56px;
220}
221
222/* 提示样式 */
223.custom-toast [data-sonner-toast][data-type="info"] {
224    background: var(--info-or-other-bg) !important;
225    color: var(--info-or-other-text) !important;
226    border-color: var(--info-or-other-border) !important;
227}
228
229/* 成功样式 */
230.custom-toast [data-sonner-toast][data-type="success"] {
231    /* background: var(--success-bg) !important;
232    color: var(--success-text) !important;
233    border-color: var(--success-border) !important; */
234    background: var(--info-or-other-bg) !important;
235    color: var(--info-or-other-text) !important;
236    border-color: var(--info-or-other-border) !important;
237}
238
239/* 错误样式 */
240.custom-toast [data-sonner-toast][data-type="error"] {
241    background: var(--error-bg) !important;
242    color: var(--error-text) !important;
243    border-color: var(--error-border) !important;
244}
245
246/* 警告样式 */
247.custom-toast [data-sonner-toast][data-type="warning"] {
248    /* background: var(--warning-bg) !important;
249    color: var(--warning-text) !important;
250    border-color: var(--warning-border) !important; */
251    background: var(--info-or-other-bg) !important;
252    color: var(--info-or-other-text) !important;
253    border-color: var(--info-or-other-border) !important;
254}
255
256/* 关闭按钮隐藏(保持自动消失) */
257.custom-toast [data-close-button] {
258    display: none !important;
259}

2.2.3 布局相关

2.2.3.1 根布局组件

/src/app/layout.tsx

 1// ==================== 根布局组件 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4// ========== Components、CSS ========== //
 5import { Toaster } from "sonner";
 6import { TooltipProvider } from "@/components/ui/tooltip";
 7import "./globals.css";
 8// ========== Icon、Type ========== //
 9import type { Metadata } from "next";
10import { Geist, Geist_Mono } from "next/font/google";
11// ========== Stroe、Constants ========== //
12// ========== Hooks ========== //
13// ========== Services ========== //
14
15const geistSans = Geist({
16    variable: "--font-geist-sans",
17    subsets: ["latin"],
18    preload: false, // 关闭预加载,警告直接消失
19});
20
21const geistMono = Geist_Mono({
22    variable: "--font-geist-mono",
23    subsets: ["latin"],
24    preload: false,
25});
26
27export const metadata: Metadata = {
28    title: "T.AI - AI助手",
29    description: "Chat with T.AI – your intelligent assistant for coding, content creation, file reading, and more. Upload documents, engage in long-context conversations, and get expert help in AI, natural language processing, and beyond. | T.AI 助力编程代码开发、创意写作、文件处理等任务,支持文件上传及长文本对话,随时为您提供高效的AI支持。",
30    keywords: ["AI", "Assistant", "Next.js", "Chat"],
31    icons: {
32        icon: "/favicon.ico",
33        apple: "/assets/images/logo.png",
34    },
35    authors: [{ name: "谌中钱" }],
36    creator: "谌中钱",
37    publisher: "爬界科技",
38    formatDetection: {
39        email: false,
40        address: false,
41        telephone: false,
42    },
43    metadataBase: new URL("https://tai.templechann.com"),
44    openGraph: {
45        title: "T.AI - AI助手",
46        description: "Chat with T.AI – your intelligent assistant for coding, content creation, file reading, and more. Upload documents, engage in long-context conversations, and get expert help in AI, natural language processing, and beyond. | T.AI 助力编程代码开发、创意写作、文件处理等任务,支持文件上传及长文本对话,随时为您提供高效的AI支持。",
47        url: "https://tai.templechann.com",
48        siteName: "T.AI - AI助手",
49        locale: "zh_CN",
50        type: "website",
51        images: [{ url: "https://tai.templechann.com/logo.png" }],
52    },
53    twitter: {
54        card: "summary_large_image",
55        title: "T.AI - AI助手",
56        description: "Chat with T.AI – your intelligent assistant for coding, content creation, file reading, and more. Upload documents, engage in long-context conversations, and get expert help in AI, natural language processing, and beyond. | T.AI 助力编程代码开发、创意写作、文件处理等任务,支持文件上传及长文本对话,随时为您提供高效的AI支持。",
57        images: [{ url: "https://tai.templechann.com/logo.png" }],
58    },
59    robots: {
60        index: true,
61        follow: true,
62        googleBot: {
63            index: true,
64            follow: true,
65            "max-video-preview": -1,
66            "max-image-preview": "large",
67            "max-snippet": -1,
68        },
69    },
70};
71
72export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
73    return (
74        // data-scroll-behavior="smooth":Next.js 路由切换时会自动管理页面滚动,手动开启平滑滚动需要显示声明,不然会有警告
75        <html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} antialiased`} data-scroll-behavior="smooth">
76            {/* h-dvh,是现代动态 CSS 视口单位,专门解决移动端浏览器的坑:传统 vh 会被地址栏 / 底部导航栏挤占,导致高度计算错误 */}
77            <body className="h-dvh w-full overflow-hidden">
78                <TooltipProvider>{children}</TooltipProvider>
79                {/* 全局提示容器,轻量级 toast,shadcn/ui 官方推荐 */}
80                {/* toast.success("操作成功!"); 
81                    toast.error("操作失败!");
82                    toast.warning("请注意!");
83                    toast.info("普通提示");
84                */}
85                <Toaster
86                    className="custom-toast" // 自定义样式
87                    position="top-center" // 位置:top-center / top-right 等
88                    duration={2000} // 2秒自动消失
89                    expand={true} // 多个弹窗需要展开
90                    visibleToasts={3} // 允许同时显示5个弹窗
91                    closeButton={false} // 隐藏关闭按钮
92                    richColors={false} // 关闭默认配色,用我们的自定义色
93                    gap={10} // 多个提示间距
94                />
95            </body>
96        </html>
97    );
98}
2.2.3.2 基础布局组件

/src/components/layouts/CommonLayout.tsx

  1"use client";
  2
  3// ==================== 基础布局组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { useEffect, useLayoutEffect, useRef, useState } from "react";
  7// ========== Components、CSS ========== //
  8import Sidebar from "@/components/features/sidebar/Sidebar";
  9import CommonModal from "@/components/features/common/CommonModal";
 10import DocPreview from "@/components/features/common/DocPreview";
 11// ========== Icon、Type ========== //
 12// ========== Stroe、Constants ========== //
 13import { useCommonStore } from "@/store/useCommonStore";
 14import { useSessionStore } from "@/store/useSessionStore";
 15import { useChatStore } from "@/store/useChatStore";
 16// ========== Hooks ========== //
 17// ========== Services ========== //
 18
 19export default function BaseLayout({ children }: Readonly<{ children: React.ReactNode }>) {
 20    const [autoScroll, setAutoScroll] = useState(true);
 21    // 移动端触摸状态(解决 iOS 滑动冲突)
 22    const [isTouching, setIsTouching] = useState(false);
 23
 24    // 精确订阅
 25    const collapsed = useCommonStore((state) => state.collapsed);
 26    const currentSession = useSessionStore((state) => state.currentSession);
 27    const isSendLoading = useChatStore((state) => state.isLoading);
 28
 29    const chatContainerRef = useRef<HTMLDivElement>(null);
 30    const timerRef = useRef<NodeJS.Timeout[]>([]);
 31    const rafId = useRef<number>(null);
 32
 33    // 清理所有定时器(防止内存泄漏)
 34    const clearAllTimers = () => {
 35        timerRef.current.forEach((timer) => clearTimeout(timer));
 36        timerRef.current = [];
 37    };
 38
 39    // 组件卸载销毁定时器
 40    useEffect(() => {
 41        return () => clearAllTimers();
 42    }, []);
 43
 44    // 自动滚动到最底部
 45    const scrollToBottom = () => {
 46        if (autoScroll && !isTouching && chatContainerRef.current) {
 47            chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
 48        }
 49    };
 50
 51    // 当组件重新挂载时,重新开启自动滚动并滚动到底部
 52    useLayoutEffect(() => {
 53        if (currentSession) {
 54            rafId.current = requestAnimationFrame(() => {
 55                scrollToBottom();
 56            });
 57        }
 58        return () => {
 59            rafId.current && cancelAnimationFrame(rafId.current);
 60        };
 61    }, [currentSession, autoScroll]);
 62
 63    useLayoutEffect(() => {
 64        let timer = null;
 65        if (isSendLoading) {
 66            // 异步更新,绕过 ESLint 严格校验,功能完全不变
 67            timer = setTimeout(() => {
 68                setAutoScroll(true);
 69            }, 0);
 70            timerRef.current.push(timer);
 71        }
 72    }, [isSendLoading]);
 73
 74    // 处理滚动事件,用户手动滚动时暂停自动滚动
 75    const handleScroll = () => {
 76        if (!chatContainerRef.current || isTouching) return;
 77
 78        const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
 79        // 移动端适配:缩小阈值,更灵敏
 80        const threshold = window.innerWidth < 768 ? 160 : 100;
 81
 82        if (scrollHeight - scrollTop - clientHeight > threshold) {
 83            setAutoScroll(false);
 84        } else {
 85            setAutoScroll(true);
 86        }
 87    };
 88
 89    // 触摸事件
 90    const handleTouchStart = () => setIsTouching(true);
 91    const handleTouchEnd = () => {
 92        setIsTouching(false);
 93        handleScroll();
 94        autoScroll && scrollToBottom();
 95    };
 96
 97    return (
 98        // flex 布局必须加 w-full:因为 flex 布局默认宽度是内容宽度,会被被内容撑开
 99        <div className="flex h-full w-full md:min-w-350">
100            <Sidebar />
101            <div ref={chatContainerRef} className={`${!collapsed ? "md:pl-64" : ""} flex h-full w-full flex-1 justify-center overflow-y-auto transition-all duration-300`} onScroll={handleScroll} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd}>
102                {children}
103            </div>
104            <CommonModal />
105            <DocPreview />
106        </div>
107    );
108}

2.2.4 基础相关

2.2.4.1 通用弹窗组件

/src/components/features/common/CommonModal.tsx

  1"use client";
  2
  3// ==================== 通用弹窗组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { ReactNode } from "react";
  7// ========== Components、CSS ========== //
  8import { Button } from "@/components/ui/button";
  9import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 10// ========== Icon、Type ========== //
 11import { AlertCircle, CheckCircle, Info, LoaderCircle } from "lucide-react";
 12// ========== Stroe、Constants ========== //
 13import { useCommonStore } from "@/store/useCommonStore";
 14// ========== Hooks ========== //
 15// ========== Services ========== //
 16
 17export type ModalType = "success" | "error" | "warning" | "info"; // 弹窗类型
 18
 19export interface CommonModalProps {
 20    // 受控显隐
 21    open: boolean;
 22    onOpenChange?: ((open: boolean) => void) | null;
 23
 24    // 基础内容
 25    type?: ModalType;
 26    title?: string;
 27    // ReactNode,支持换行、标签、样式(不支持表格,可以在 children 用原生表格)
 28    description?: ReactNode;
 29    // 自定义内容
 30    children?: ReactNode;
 31
 32    // 按钮配置
 33    confirmText?: string;
 34    cancelText?: string;
 35    showCancel?: boolean;
 36    confirmLoading?: boolean;
 37
 38    // 回调
 39    onConfirm?: (() => void | Promise<void>) | null;
 40    onCancel?: (() => void) | null;
 41}
 42
 43export default function CommonModal() {
 44    const commonModal = useCommonStore((state) => state.commonModal);
 45    const setCommonModal = useCommonStore((state) => state.setCommonModal);
 46    const resetCommonModal = useCommonStore((state) => state.resetCommonModal);
 47
 48    const { open, onOpenChange, type = "info", title, description, children, confirmText = "确定", cancelText = "取消", showCancel = true, confirmLoading = false, onConfirm, onCancel } = commonModal;
 49
 50    // 根据类型自动匹配标题图标
 51    const renderIcon = (): ReactNode => {
 52        switch (type) {
 53            case "success":
 54                return <CheckCircle className="h-6 w-6 text-[#166534]" />;
 55            case "error":
 56                return <AlertCircle className="h-6 w-6 text-[#FB2C36]" />;
 57            case "warning":
 58                return <Info className="h-6 w-6 text-[#F0B100]" />;
 59            case "info":
 60                return <Info className="h-6 w-6 text-[#052658]" />;
 61            default:
 62                return <Info className="h-6 w-6 text-[#052658]" />;
 63        }
 64    };
 65
 66    // 处理确认
 67    const handleConfirm = async (): Promise<void> => {
 68        try {
 69            setCommonModal({ ...useCommonStore.getState().commonModal, confirmLoading: true });
 70            await onConfirm?.();
 71            setCommonModal({ ...useCommonStore.getState().commonModal, confirmLoading: false });
 72        } finally {
 73            // 默认自动关闭,如果需要关闭时进行其他操作,请设置 onOpenChange
 74            if (onOpenChange) {
 75                onOpenChange(false);
 76            } else {
 77                setCommonModal({ ...useCommonStore.getState().commonModal, open: false });
 78            }
 79        }
 80    };
 81    // 处理取消
 82    const handleCancel = (): void => {
 83        onCancel?.();
 84
 85        // 默认自动关闭,如果需要关闭时进行其他操作,请设置 onOpenChange
 86        if (onOpenChange) {
 87            onOpenChange(false);
 88        } else {
 89            setCommonModal({ ...commonModal, open: false });
 90            resetCommonModal();
 91        }
 92    };
 93
 94    return (
 95        <Dialog open={open} onOpenChange={onOpenChange || undefined}>
 96            <DialogContent
 97                className="md:max-w-100"
 98                // 隐藏标题右侧的关闭按钮
 99                showCloseButton={false}
100                // 禁止外部点击关闭
101                onInteractOutside={(e) => e.preventDefault()}
102                // 禁止按 ESC 关闭
103                onEscapeKeyDown={(e) => e.preventDefault()}
104                // 取消打开时的默认聚焦行为
105                onOpenAutoFocus={(e) => e.preventDefault()}
106            >
107                <DialogHeader className="flex flex-col gap-6">
108                    <DialogTitle className="flex items-center justify-start gap-3 md:max-w-80">
109                        <div className="shrink-0">{renderIcon()}</div>
110                        <div className="flex-1 truncate" title={title}>
111                            {title}
112                        </div>
113                    </DialogTitle>
114                    <DialogDescription className="pb-2 text-gray-950">{description}</DialogDescription>
115                </DialogHeader>
116
117                {/* 自定义内容区域 */}
118                {children && <div className="pb-2 text-gray-950">{children}</div>}
119                <br />
120
121                <DialogFooter className="flex items-center justify-end gap-2">
122                    {/* 取消按钮 */}
123                    {showCancel && (
124                        <Button variant="ghost" onClick={handleCancel} disabled={confirmLoading} className="cursor-pointer">
125                            {cancelText}
126                        </Button>
127                    )}
128
129                    {/* 确认按钮(根据类型变色) */}
130                    <Button variant={type === "error" ? "destructive" : "default"} disabled={confirmLoading} onClick={handleConfirm} className={`flex cursor-pointer gap-2 ${type !== "error" ? "bg-[#052658]" : ""}`}>
131                        {/* 确认按钮加载中图标 */}
132                        {confirmLoading && <LoaderCircle className="h-4! w-4! animate-spin" />}
133                        {confirmText}
134                    </Button>
135                </DialogFooter>
136            </DialogContent>
137        </Dialog>
138    );
139}
2.2.4.2 文档预览组件

/src/components/features/common/DocPreview.tsx

 1"use client";
 2
 3// ==================== 文档预览组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7import Image from "next/image";
 8import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 9// ========== Icon、Type ========== //
10// ========== Stroe、Constants ========== //
11import { useCommonStore } from "@/store/useCommonStore";
12// ========== Hooks ========== //
13// ========== Services ========== //
14
15export default function DocPreview() {
16    const previewDoc = useCommonStore((state) => state.previewDoc);
17    const isPreviewDocOpen = useCommonStore((state) => state.isPreviewDocOpen);
18    const setIsPreviewDocOpen = useCommonStore((state) => state.setIsPreviewDocOpen);
19
20    return (
21        <Dialog open={isPreviewDocOpen} onOpenChange={setIsPreviewDocOpen}>
22            <DialogContent className="flex h-[80vh] w-[90vw] max-w-[90vw]! flex-col overflow-hidden">
23                <DialogHeader>
24                    <DialogTitle className="flex items-center gap-3 text-xl">
25                        <Image src={previewDoc ? `/assets/images/fileIcons/${previewDoc?.fileType}.png` : "/assets/images/logo.png"} alt="" width={20} height={20} priority />
26                        <span className="w-[60vw] truncate text-base text-gray-900" title={previewDoc?.fileName}>
27                            {previewDoc?.fileName}
28                        </span>
29                    </DialogTitle>
30                    <DialogDescription></DialogDescription>
31                </DialogHeader>
32
33                <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-1 py-0 break-all">
34                    <pre className="rounded-md border border-gray-200 bg-white px-4 py-2 font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-800 shadow-sm">{previewDoc?.content || "文件内容为空"}</pre>
35                </div>
36            </DialogContent>
37        </Dialog>
38    );
39}

2.2.5 侧边栏相关

2.2.5.1 侧边栏组件

/src/components/features/sidebar/Sidebar.tsx

 1"use client";
 2
 3// ==================== 侧边栏组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { useRouter } from "next/navigation";
 7// ========== Components、CSS ========== //
 8import Image from "next/image";
 9import { toast } from "sonner";
10import { Button } from "@/components/ui/button";
11import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
12import SessionList from "@/components/features/sidebar/SessionList";
13// ========== Icon、Type ========== //
14import { PanelLeft, MessageCirclePlus } from "lucide-react";
15// ========== Stroe、Constants ========== //
16import { useCommonStore } from "@/store/useCommonStore";
17import { useSessionStore } from "@/store/useSessionStore";
18import { useChatStore } from "@/store/useChatStore";
19// ========== Hooks ========== //
20// ========== Services ========== //
21
22export default function Sidebar() {
23    const collapsed = useCommonStore((state) => state.collapsed);
24    const setCollapsed = useCommonStore((state) => state.setCollapsed);
25    const currentSession = useSessionStore((state) => state.currentSession);
26    const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
27    const resetChat = useChatStore((state) => state.resetChat);
28
29    const router = useRouter();
30
31    // 会话新开启
32    const handleSessionNewClick = (): void => {
33        if (!currentSession) {
34            toast.info("已在新对话中");
35            return;
36        }
37
38        // 开启新会话
39        router.push("/");
40        setCurrentSession(null);
41        resetChat();
42    };
43
44    return (
45        <>
46            <div className={`hidden md:flex ${!collapsed ? "translate-x-0" : "-translate-x-full"} fixed top-0 left-0 z-30! h-full w-64 flex-col overflow-hidden border-r border-gray-200 bg-[#F9F9F9] transition-transform duration-300 ease-in-out`}>
47                <div className="flex items-center justify-between pt-4 pr-2 pb-5 pl-4">
48                    <div className="flex items-center gap-2">
49                        <Image className="rounded-sm" src="/assets/images/logo.png" alt="T.AI" width={32} height={32} priority />
50                        <span className="text-xl font-bold tracking-widest text-[#052658] italic drop-shadow-2xl">T.AI</span>
51                    </div>
52                    <Tooltip>
53                        <TooltipTrigger asChild>
54                            <Button size="icon" variant="ghost" className="cursor-pointer text-gray-500" onClick={() => setCollapsed(!collapsed)}>
55                                <PanelLeft className="h-6! w-6!" />
56                            </Button>
57                        </TooltipTrigger>
58                        <TooltipContent>
59                            <p>收起边栏</p>
60                        </TooltipContent>
61                    </Tooltip>
62                </div>
63                <div className="mx-4 flex cursor-pointer items-center justify-center rounded-xl border border-gray-500 bg-white py-1 transition-shadow hover:shadow-md" onClick={() => handleSessionNewClick()}>
64                    <Button size="icon" variant="ghost" className="cursor-pointer">
65                        <MessageCirclePlus className="h-4! w-4!" />
66                    </Button>
67                    <span className="text-sm">开启新对话</span>
68                </div>
69                <SessionList />
70            </div>
71
72            <div className={`hidden md:flex ${!collapsed ? "pointer-events-none opacity-0" : "pointer-events-auto opacity-100"} fixed top-4 left-4 z-30! flex items-center gap-4 transition-opacity duration-1000 ease-in-out`}>
73                <Image className="rounded-sm" src="/assets/images/logo.png" alt="T.AI" width={32} height={32} priority />
74                <div className="flex items-center gap-5 rounded-xl border border-gray-500 px-3.5 py-2">
75                    <Tooltip>
76                        <TooltipTrigger asChild>
77                            <Button size="icon" variant="ghost" className="h-5! w-5! cursor-pointer" onClick={() => setCollapsed(!collapsed)}>
78                                <PanelLeft className="h-5! w-5!" />
79                            </Button>
80                        </TooltipTrigger>
81                        <TooltipContent>
82                            <p>打开边栏</p>
83                        </TooltipContent>
84                    </Tooltip>
85                    <Tooltip>
86                        <TooltipTrigger asChild>
87                            <Button size="icon" variant="ghost" className="h-5! w-5! cursor-pointer" onClick={() => handleSessionNewClick()}>
88                                <MessageCirclePlus className="h-5! w-5!" />
89                            </Button>
90                        </TooltipTrigger>
91                        <TooltipContent>
92                            <p>开启新对话</p>
93                        </TooltipContent>
94                    </Tooltip>
95                </div>
96            </div>
97        </>
98    );
99}
2.2.5.2 侧边栏组件 (移动端)

/src/components/features/sidebar/SidebarMobile.tsx

 1"use client";
 2
 3// ==================== 侧边栏组件(移动端) ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7import { toast } from "sonner";
 8import { Button } from "@/components/ui/button";
 9import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
10import SessionList from "@/components/features/sidebar/SessionList";
11// ========== Icon、Type ========== //
12import { PanelLeft } from "lucide-react";
13// ========== Stroe、Constants ========== //
14import { useCommonStore } from "@/store/useCommonStore";
15import { useSessionStore } from "@/store/useSessionStore";
16// ========== Hooks ========== //
17// ========== Services ========== //
18
19export default function SidebarMobile() {
20    const isSidebarMobileOpen = useCommonStore((state) => state.isSidebarMobileOpen);
21    const setIsSidebarMobileOpen = useCommonStore((state) => state.setIsSidebarMobileOpen);
22    const editId = useSessionStore((state) => state.editId);
23    const resetEdit = useSessionStore((state) => state.resetEdit);
24
25    // 受控抽屉隐藏操作
26    const handleSidebarMobileClose = (isOpen: boolean): void => {
27        // 抽屉隐藏后,取消重命名状态
28        if (editId && !isOpen) {
29            resetEdit();
30            toast.info("会话重命名已取消");
31        }
32        // 设置受控抽屉状态
33        setIsSidebarMobileOpen(isOpen);
34    };
35
36    return (
37        // autoFocus:禁止抽屉隐藏后,抽屉按钮获取焦点
38        <Drawer direction="left" open={isSidebarMobileOpen} onOpenChange={(isOpen) => handleSidebarMobileClose(isOpen)} autoFocus={false}>
39            <DrawerTrigger asChild>
40                <Button size="icon" variant="ghost" className="h-6 w-6 cursor-pointer">
41                    <PanelLeft className="h-6! w-6!" />
42                </Button>
43            </DrawerTrigger>
44            {/* h-dvh!,防止输入法自动关闭时,SessionList 高度没有自适应占满屏幕 */}
45            {/* 取消 ESC 关闭抽屉,兼容会话列表组件 SessionList */}
46            <DrawerContent className="h-dvh! rounded-none!" onEscapeKeyDown={(e) => e.preventDefault()}>
47                <DrawerTitle />
48                <DrawerDescription />
49                <SessionList />
50            </DrawerContent>
51        </Drawer>
52    );
53}
2.2.5.3 会话列表组件

/src/components/features/sidebar/SessionList.tsx

  1"use client";
  2
  3// ==================== 会话列表组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6// ========== Components、CSS ========== //
  7import { Button } from "@/components/ui/button";
  8import { ButtonGroup } from "@/components/ui/button-group";
  9import { Input } from "@/components/ui/input";
 10import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
 11// ========== Icon、Type ========== //
 12import { Bot, Check, MoreHorizontal, X } from "lucide-react";
 13import type { Session } from "@/lib/types/app";
 14// ========== Stroe、Constants ========== //
 15// ========== Hooks ========== //
 16import { useSessionList } from "@/components/hooks/sidebar/useSessionList";
 17// ========== Services ========== //
 18
 19export default function SessionList() {
 20    // 从业务Hook中引入相关属性和方法
 21    const { currentSession, editId, editName, sessionList, handleSessionDetailClick, handleSessionRenameClick, handleSessionRenameConfirm, handleSessionRenameCancel, handleSessionDelClick, handleAllDropdownMenusClose, handleInputRefsSet, handleInputKeyDown, handleInputTextChange, splitSessionsByTime } = useSessionList();
 22    const { sessions7Days, sessions30Days, sessionsOlder } = splitSessionsByTime();
 23
 24    // 渲染会话列表的函数
 25    const renderSessionItem = (session: Session) => (
 26        <div key={session.id} className="relative">
 27            {/* 单会话正常显示 */}
 28            <div className={` ${editId === session.id ? "pointer-events-none opacity-0" : "pointer-events-auto opacity-100"} ${session.id === currentSession?.id ? "bg-blue-100 text-[#052658]" : "hover:bg-gray-200"} flex h-11 cursor-pointer items-center justify-between rounded-lg pr-2 pl-3`} onClick={() => handleSessionDetailClick(session.id)}>
 29                <div className="flex-1 truncate text-sm" title={session.title}>
 30                    {session.title}
 31                </div>
 32                <DropdownMenu onOpenChange={handleAllDropdownMenusClose}>
 33                    <DropdownMenuTrigger asChild>
 34                        <Button
 35                            size="icon"
 36                            variant="ghost"
 37                            className="h-8! w-7! cursor-pointer rounded-xl text-[#052658] hover:bg-gray-300"
 38                            onClick={(e) => {
 39                                e.stopPropagation();
 40                            }}
 41                        >
 42                            <MoreHorizontal className="h-4! w-4!" />
 43                        </Button>
 44                    </DropdownMenuTrigger>
 45
 46                    {/* onCloseAutoFocus 取消自动聚焦弹窗关闭后的默认行为 */}
 47                    <DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
 48                        <DropdownMenuItem className="cursor-pointer" onClick={(e) => handleSessionRenameClick(e, session)}>
 49                            重命名
 50                        </DropdownMenuItem>
 51                        <DropdownMenuSeparator />
 52                        <DropdownMenuItem className="cursor-pointer text-red-600" onClick={(e) => handleSessionDelClick(e, session)}>
 53                            删除
 54                        </DropdownMenuItem>
 55                    </DropdownMenuContent>
 56                </DropdownMenu>
 57            </div>
 58
 59            {/* 会话重命名框显示 */}
 60            <div className={`${editId === session.id ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"} absolute top-0 left-0 my-0.5 rounded-lg border-2 border-[#052658] pr-2`}>
 61                <ButtonGroup className="flex items-center justify-between">
 62                    <Input className="h-9 border-0 border-[#052658]/20 pr-2 pl-4 focus:border-[#052658]/20 focus:ring-0 focus-visible:ring-0 focus-visible:outline-none" ref={(el) => handleInputRefsSet(el, session)} value={editName} onChange={handleInputTextChange} onKeyDown={(e) => handleInputKeyDown(e, session)} />
 63                    <Button size="icon" variant="ghost" className="mr-1 h-4 w-4 cursor-pointer text-[#166534]" onClick={() => handleSessionRenameConfirm(session.id)}>
 64                        <Check className="h-4! w-4!" />
 65                    </Button>
 66                    <Button size="icon" variant="ghost" className="h-4 w-4 cursor-pointer text-[#FB2C36]" onClick={handleSessionRenameCancel}>
 67                        <X className="h-4! w-4!" />
 68                    </Button>
 69                </ButtonGroup>
 70            </div>
 71        </div>
 72    );
 73
 74    return (
 75        // Tailwind CSS 规则:calc 里不能有空格
 76        <div className="flex h-full flex-col overflow-x-hidden overflow-y-auto pr-2 pb-2 pl-3 md:h-[calc(100%-64px)]">
 77            {sessionList?.length ? (
 78                <>
 79                    {/* 7天内会话 */}
 80                    {sessions7Days.length > 0 && (
 81                        <div className="mt-7 mb-2">
 82                            <h3 className="pb-2 pl-3 text-xs font-semibold text-[#052658] uppercase">7天内</h3>
 83                            {sessions7Days.map(renderSessionItem)}
 84                        </div>
 85                    )}
 86
 87                    {/* 30天内会话 */}
 88                    {sessions30Days.length > 0 && (
 89                        <div className="mt-5 mb-2">
 90                            <h3 className="pb-2 pl-3 text-xs font-semibold text-[#052658] uppercase">30天内</h3>
 91                            {sessions30Days.map(renderSessionItem)}
 92                        </div>
 93                    )}
 94
 95                    {/* 更早会话 */}
 96                    {sessionsOlder.length > 0 && (
 97                        <div className="mt-5 mb-2">
 98                            <h3 className="pb-2 pl-3 text-xs font-bold text-[#052658] uppercase">更早</h3>
 99                            {sessionsOlder.map(renderSessionItem)}
100                        </div>
101                    )}
102                </>
103            ) : (
104                <>
105                    <div className="flex h-full flex-col items-center justify-center gap-5">
106                        <Bot className="h-6! w-6! text-gray-500" />
107                        <div className="text-md font-bold text-gray-500">暂无历史会话</div>
108                    </div>
109                </>
110            )}
111        </div>
112    );
113}
2.2.5.3.1 会话列表 Hook 组件

/src/components/hooks/sidebar/useSessionList.tsx

  1"use client";
  2
  3// ==================== 会话列表 Hook 组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { useEffect, useLayoutEffect, useRef } from "react";
  7import { useRouter } from "next/navigation";
  8// ========== Components、CSS ========== //
  9import { toast } from "sonner";
 10// ========== Icon、Type ========== //
 11import type { ChangeEvent, KeyboardEvent } from "react";
 12import type { Session } from "@/lib/types/app";
 13// ========== Stroe、Constants ========== //
 14import { useCommonStore } from "@/store/useCommonStore";
 15import { useSessionStore } from "@/store/useSessionStore";
 16import { useChatStore } from "@/store/useChatStore";
 17// ========== Hooks ========== //
 18// ========== Services ========== //
 19import { useGetSessionList } from "@/components/hooks/common/useSwrApi";
 20import { sessionService } from "@/services/sessionService";
 21
 22export const useSessionList = () => {
 23    const setCommonModal = useCommonStore((state) => state.setCommonModal);
 24    const setIsSidebarMobileOpen = useCommonStore((state) => state.setIsSidebarMobileOpen);
 25    const sessionList = useSessionStore((state) => state.sessionList);
 26    const setSessionList = useSessionStore((state) => state.setSessionList);
 27    const currentSession = useSessionStore((state) => state.currentSession);
 28    const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
 29    const editId = useSessionStore((state) => state.editId);
 30    const setEditId = useSessionStore((state) => state.setEditId);
 31    const editName = useSessionStore((state) => state.editName);
 32    const setEditName = useSessionStore((state) => state.setEditName);
 33    const resetEdit = useSessionStore((state) => state.resetEdit);
 34    const resetChat = useChatStore((state) => state.resetChat);
 35    const chat = useChatStore((state) => state.chat);
 36
 37    const router = useRouter();
 38    const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
 39    const timerRef = useRef<NodeJS.Timeout[]>([]);
 40
 41    const { fetchedSessionList, refreshSessionList } = useGetSessionList();
 42
 43    // 清理所有定时器(防止内存泄漏)
 44    const clearAllTimers = () => {
 45        timerRef.current.forEach((timer) => clearTimeout(timer));
 46        timerRef.current = [];
 47    };
 48
 49    // 组件卸载销毁定时器
 50    useEffect(() => {
 51        return () => clearAllTimers();
 52    }, []);
 53
 54    // 会话列表 状态存储
 55    useEffect(() => {
 56        if (fetchedSessionList) {
 57            setSessionList(fetchedSessionList);
 58        }
 59    }, [fetchedSessionList]);
 60
 61    // 输入框获取焦点
 62    // 问题:输入框获取焦点时,会立马失去焦点
 63    // 原因:shadcn/ui 的下拉菜单关闭瞬间,会给父容器自动设置 aria-hidden="true"(无障碍机制),会强制清空父容器在的所有焦点
 64    // 解决办法:
 65    // 1 必须:<DropdownMenuContent> 设置 onCloseAutoFocus, 取消 自动聚焦弹窗关闭后 的默认行为(取消开启无障碍机制的行为)
 66    // 2 加强:延时菜单项的点击事件 handleSessionRenameClick 100ms 左右再进入编辑态,等菜单完全关闭,aria-hidden 自动移除(等开启的无障碍机制结束)
 67    useLayoutEffect(() => {
 68        if (!editId) return;
 69        const rafId = requestAnimationFrame(() => {
 70            const input = inputRefs.current.get(editId);
 71            if (input) {
 72                input.focus();
 73            }
 74        });
 75        return () => cancelAnimationFrame(rafId);
 76    }, [editId]);
 77
 78    // 会话详情获取
 79    const handleSessionDetailClick = (sessionId: string): void => {
 80        // 如果有会话正在编辑重命名,则取消重命名状态
 81        if (editId) {
 82            handleSessionRenameCancel();
 83        }
 84        if (!sessionId) {
 85            toast.error("会话ID不存在");
 86            return;
 87        }
 88        // 关闭侧边栏(移动端)
 89        setIsSidebarMobileOpen(false);
 90        if (!currentSession || (currentSession && sessionId !== currentSession.id)) {
 91            resetChat(chat.model);
 92        }
 93        // 跳转到会话页面组件
 94        router.push(`/chat/${sessionId}`);
 95    };
 96
 97    // 会话重命名点击
 98    const handleSessionRenameClick = (e: React.MouseEvent<HTMLDivElement>, session: Session): void => {
 99        e.stopPropagation();
100        if (!session.id) {
101            toast.error("会话ID不存在");
102            return;
103        }
104
105        // 延时菜单项的点击事件 handleSessionRenameClick 100ms 左右再进入编辑态,等菜单完全关闭,aria-hidden 自动移除
106        const timer = setTimeout(() => {
107            setEditId(session.id);
108            setEditName(session.title || "");
109        }, 100);
110
111        timerRef.current.push(timer);
112    };
113
114    // 会话重命名确认
115    const handleSessionRenameConfirm = async (sessionId: string): Promise<void> => {
116        if (!sessionId) {
117            toast.error("会话ID不存在");
118            return;
119        }
120        if (!editName.trim()) {
121            toast.error("会话名不能设置为空");
122            return;
123        }
124
125        try {
126            // 更新会话名称
127            await sessionService.updateSession(sessionId, { title: editName.trim().slice(0, 19) });
128            toast.success("会话重命名成功");
129            if (currentSession && sessionId === currentSession.id) {
130                const res = await sessionService.getSession(sessionId);
131                setCurrentSession(res.data);
132            }
133            resetEdit();
134            refreshSessionList();
135        } catch (error) {
136            console.error(error);
137            toast.error("会话重命名失败");
138        }
139    };
140
141    // 会话重命名取消
142    const handleSessionRenameCancel = (): void => {
143        resetEdit();
144        toast.info("会话重命名已取消");
145    };
146
147    // 会话删除点击
148    const handleSessionDelClick = (e: React.MouseEvent<HTMLDivElement>, session: Session): void => {
149        e.stopPropagation();
150        if (!session.id) {
151            toast.error("会话ID不存在");
152            return;
153        }
154        // 打开删除弹窗
155        setCommonModal({
156            open: true,
157            title: "删除对话",
158            type: "error",
159            description: (
160                <>
161                    确定删除对话 {session.title}」吗?
162                    <br />
163                    <br />
164                    此操作不可撤销,所有对话记录将被永久删除。
165                </>
166            ),
167            confirmText: "删除",
168            cancelText: "取消",
169            onConfirm: () => {
170                handleSessionDelConfirm(session.id);
171            },
172        });
173    };
174
175    // 会话删除确认
176    const handleSessionDelConfirm = async (sessionId: string): Promise<void> => {
177        if (!sessionId) {
178            toast.error("会话ID不存在");
179            return;
180        }
181        try {
182            // 会话删除
183            await sessionService.deleteSession(sessionId);
184            toast.success("会话删除成功");
185            if (currentSession && sessionId === currentSession.id) {
186                router.push(`/`);
187            }
188            setCurrentSession(null);
189            refreshSessionList();
190        } catch (error) {
191            console.error(error);
192            toast.error("会话删除失败");
193        }
194    };
195
196    // 手动安全关闭所有下拉菜单
197    // 问题:如果前一个菜单没有关闭,在点击打开下一个菜单前,旧菜单不会关闭
198    // 原因:shadcn/ui 的 DropdownMenu 组件默认独立管理自身展开 / 收起状态,多个 DropdownMenu 实例之间不会联动,因此点击新菜单的触发按钮时,旧菜单不会自动关闭。
199    // 解决办法:
200    // 触发 ESC 按键事件关闭菜单(shadcn/ui 原生支持 ESC 关闭,最稳妥),无副作用
201    const handleAllDropdownMenusClose = (): void => {
202        // 函数内部的局部临时对象,自动回收,无内存泄漏诱因
203        document.dispatchEvent(
204            new KeyboardEvent("keydown", {
205                key: "Escape",
206                // bubbles: true, // 开启事件冒泡,这里是 按键事件,不需要设置
207                cancelable: true, // 可取消事件,浏览器标准,兼容必备
208            }),
209        );
210    };
211
212    // 设置输入框 Refs
213    const handleInputRefsSet = (el: HTMLInputElement | null, session: Session): void => {
214        if (el) {
215            inputRefs.current.set(session.id, el);
216        } else {
217            inputRefs.current.delete(session.id);
218        }
219    };
220    // 输入框按键事件
221    const handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>, session: Session): void => {
222        if (e.key === "Enter") handleSessionRenameConfirm(session.id);
223        if (e.key === "Escape") handleSessionRenameCancel();
224    };
225
226    // 输入框文本限制
227    const handleInputTextChange = (e: ChangeEvent<HTMLInputElement>): void => {
228        const value = e.target.value;
229
230        if (value.length > 20) {
231            toast.warning("最多只能输入20个文字");
232            setEditName(value.slice(0, 19));
233        } else {
234            setEditName(value);
235        }
236    };
237
238    // 按创建时间分割会话列表
239    const splitSessionsByTime = () => {
240        const now = new Date();
241        const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
242        const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
243
244        const sessions7Days: Session[] = [];
245        const sessions30Days: Session[] = [];
246        const sessionsOlder: Session[] = [];
247
248        sessionList.forEach((session) => {
249            if (session.createTime) {
250                const createTime = new Date(session.createTime);
251                if (createTime >= sevenDaysAgo) {
252                    sessions7Days.push(session);
253                } else if (createTime >= thirtyDaysAgo) {
254                    sessions30Days.push(session);
255                } else {
256                    sessionsOlder.push(session);
257                }
258            } else {
259                // 没有创建时间的会话默认归为更早
260                sessionsOlder.push(session);
261            }
262        });
263
264        return { sessions7Days, sessions30Days, sessionsOlder };
265    };
266
267    return {
268        currentSession,
269        editId,
270        editName,
271        sessionList,
272        setEditName,
273        handleSessionDetailClick,
274        handleSessionRenameClick,
275        handleSessionRenameConfirm,
276        handleSessionRenameCancel,
277        handleSessionDelClick,
278        handleAllDropdownMenusClose,
279        handleInputRefsSet,
280        handleInputKeyDown,
281        handleInputTextChange,
282        splitSessionsByTime,
283    };
284};

2.2.6 会话相关

2.2.6.1 会话标题组件

/src/components/features/chat/chat-title/ChatTitle.tsx

 1"use client";
 2
 3// ==================== 会话标题组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { useRouter } from "next/navigation";
 7// ========== Components、CSS ========== //
 8import { toast } from "sonner";
 9import { Button } from "@/components/ui/button";
10import SidebarMobile from "@/components/features/sidebar/SidebarMobile";
11// ========== Icon、Type ========== //
12import { MessageCirclePlus } from "lucide-react";
13// ========== Stroe、Constants ========== //
14import { useSessionStore } from "@/store/useSessionStore";
15import { useChatStore } from "@/store/useChatStore";
16// ========== Hooks ========== //
17// ========== Services ========== //
18
19export default function ChatTitle() {
20    const currentSession = useSessionStore((state) => state.currentSession);
21    const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
22    const resetChat = useChatStore((state) => state.resetChat);
23
24    const router = useRouter();
25
26    // 会话新开启
27    const handleSessionNewClick = (): void => {
28        if (!currentSession) {
29            toast.info("已在新对话中");
30            return;
31        }
32
33        // 开启新会话
34        router.push("/");
35        setCurrentSession(null);
36        resetChat();
37    };
38
39    return (
40        <>
41            {/* PC端 */}
42            <div className={`${currentSession ? "hidden md:flex" : "hidden"} h-14 w-full items-center justify-center bg-white`}>
43                <div className="mx-40 flex-1 truncate text-center font-bold" title={currentSession?.title}>
44                    {currentSession?.title}
45                </div>
46            </div>
47            {/* 移动端 */}
48            <div className="z-30! flex h-14 w-full items-center justify-between gap-12 bg-white px-3 md:hidden">
49                <SidebarMobile />
50                <div className={`${currentSession ? "" : "hidden"} flex-1 truncate text-center font-bold`} title={currentSession?.title}>
51                    {currentSession?.title}
52                </div>
53                <Button size="icon" variant="ghost" className="h-6 w-6 cursor-pointer" onClick={() => handleSessionNewClick()}>
54                    <MessageCirclePlus className="h-6! w-6!" />
55                </Button>
56            </div>
57        </>
58    );
59}
2.2.6.2 会话内容组件

/src/components/features/chat/chat-content/ChatContent.tsx

  1"use client";
  2
  3// ==================== 会话内容组件 ====================
  4
  5// ========== React、Next、Utils ==========
  6import { useEffect, useRef, useState } from "react";
  7import { copyToClipboard } from "@/lib/utils/common-tools";
  8// ========== Components、CSS ==========
  9import Image from "next/image";
 10import { Button } from "@/components/ui/button";
 11import ChatDocShow from "@/components/features/chat/chat-box/ChatDocShow";
 12// ========== Icon、Type ==========
 13import { Copy, Check, RefreshCcw, LoaderCircle } from "lucide-react";
 14import type { Chat, ChatHistory, Message } from "@/lib/types/app";
 15// ========== Stroe、Constants ==========
 16import { useSessionStore } from "@/store/useSessionStore";
 17import { useChatStore } from "@/store/useChatStore";
 18// ========== Hooks ==========
 19// ========== Services ==========
 20
 21export default function ChatContent() {
 22    const [copiedId, setCopiedId] = useState<string | null>(null);
 23
 24    const currentSession = useSessionStore((state) => state.currentSession);
 25    const setChat = useChatStore((state) => state.setChat);
 26    const setIsAutoSend = useChatStore((state) => state.setIsAutoSend);
 27
 28    const timerRef = useRef<NodeJS.Timeout[]>([]);
 29
 30    // 清理所有定时器(防止内存泄漏)
 31    const clearAllTimers = () => {
 32        timerRef.current.forEach((timer) => clearTimeout(timer));
 33        timerRef.current = [];
 34    };
 35
 36    // 组件卸载销毁定时器
 37    useEffect(() => {
 38        return () => clearAllTimers();
 39    }, []);
 40
 41    const handleCopy = (text: string, id: string) => {
 42        copyToClipboard(text);
 43        setCopiedId(id);
 44
 45        // 2秒后重置复制状态
 46        const timer = setTimeout(() => setCopiedId(null), 2000);
 47        timerRef.current.push(timer);
 48    };
 49
 50    const handleRetry = (message: Message) => {
 51        const chatParams: Chat = {
 52            id: currentSession?.id || "",
 53            model: message.model,
 54            content: message.content,
 55            chatHistorys: currentSession?.messages?.map((msg) => ({ role: msg.role.trim(), content: msg.content.trim() }) as ChatHistory) || [],
 56            docs: message.docs,
 57            hasDocHistorys: !!currentSession?.messages?.some((item) => item.docs?.length > 0),
 58        };
 59        setChat(chatParams);
 60        setIsAutoSend(true);
 61    };
 62
 63    return (
 64        <div className="flex h-full w-full flex-col gap-6 px-4 md:px-0">
 65            {currentSession?.messages?.map((msg, index) => (
 66                <div key={index} className={`flex w-full ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
 67                    {/* AI 回答:左侧 */}
 68                    {msg.role === "assistant" && (
 69                        <div className="flex w-[92%] flex-col gap-1">
 70                            <div className="flex w-full gap-1">
 71                                <div className="shrink-0 pt-1 pr-1">
 72                                    <Image className="rounded-sm" src={msg.model ? `/assets/images/modelIcons/${msg.model}.png` : "/assets/images/logo.png"} alt="T.AI" width={30} height={30} priority />
 73                                </div>
 74                                {msg.content ? (
 75                                    <div className="max-w-full min-w-0! rounded-2xl border border-gray-200 bg-white px-4 py-3 wrap-break-word whitespace-pre-wrap">
 76                                        <div className="prose prose-sm md:prose-base max-w-none text-gray-700">{msg.content}</div>
 77                                    </div>
 78                                ) : (
 79                                    <Button size="icon" variant="ghost" className="mt-2.5 ml-1 h-6 w-6 overflow-hidden text-gray-400">
 80                                        <LoaderCircle className="h-5! w-5! animate-spin" />
 81                                    </Button>
 82                                )}
 83                            </div>
 84                            {msg.content ? (
 85                                <div className="flex items-center gap-3">
 86                                    <Button size="icon" variant="ghost" className="mt-1 ml-11 h-6 w-6 cursor-pointer rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:translate-y-0!" onClick={() => handleCopy(msg.content, `${msg.role}-${index}`)}>
 87                                        {copiedId === `${msg.role}-${index}` ? <Check className="h-4 w-4" /> : <Copy className="h-5! w-5!" />}
 88                                    </Button>
 89                                </div>
 90                            ) : (
 91                                <></>
 92                            )}
 93                        </div>
 94                    )}
 95
 96                    {/* 用户回答:右侧 */}
 97                    {msg.role === "user" && (
 98                        <div className="flex max-w-[85%] flex-col items-end gap-1">
 99                            <ChatDocShow messageDocs={msg.docs} />
100                            <div className="max-w-full min-w-0! rounded-2xl bg-[#052658] px-4 py-3 wrap-break-word whitespace-pre-wrap">
101                                <div className="prose prose-sm md:prose-base prose-invert text-white">{msg.content}</div>
102                            </div>
103                            <div className="flex items-center gap-2">
104                                <Button size="icon" variant="ghost" className="mt-1 h-6 w-6 cursor-pointer rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:translate-y-0!" onClick={() => handleRetry(msg)}>
105                                    <RefreshCcw className="h-5! w-5!" />
106                                </Button>
107                                <Button size="icon" variant="ghost" className="mt-1 mr-2 h-6 w-6 cursor-pointer rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:translate-y-0!" onClick={() => handleCopy(msg.content, `${msg.role}-${index}`)}>
108                                    {copiedId === `${msg.role}-${index}` ? <Check className="h-3 w-3" /> : <Copy className="h-5! w-5!" />}
109                                </Button>
110                            </div>
111                        </div>
112                    )}
113                </div>
114            ))}
115        </div>
116    );
117}
2.2.6.3 会话聊天框相关
2.2.6.3.1 会话聊天框组件

/src/components/features/chat/chat-box/ChatBox.tsx

 1"use client";
 2
 3// ==================== 会话聊天框相关 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7import ChatInput from "@/components/features/chat/chat-box/ChatInput";
 8import ChatDocUpload from "@/components/features/chat/chat-box/ChatDocUpload";
 9import ChatDocShow from "@/components/features/chat/chat-box/ChatDocShow";
10import ChatModelSelector from "@/components/features/chat/chat-box/ChatModelSelector";
11import ChatSendBtn from "@/components/features/chat/chat-box/ChatSendBtn";
12// ========== Icon、Type ========== //
13// ========== Stroe、Constants ========== //
14import { useChatStore } from "@/store/useChatStore";
15// ========== Hooks ========== //
16// ========== Services ========== //
17
18export default function ChatBox({ onSend, onAbort }: { onSend?: () => void; onAbort?: () => void }) {
19    const chat = useChatStore((state) => state.chat);
20
21    return (
22        <div className={`${chat.docs.length ? "h-51" : "h-34"} flex w-full flex-col gap-1 rounded-4xl border border-gray-400 bg-white p-3 shadow-sm`}>
23            <ChatDocShow />
24            <ChatInput onSend={onSend} />
25            <div className="flex items-center justify-between">
26                <ChatModelSelector />
27                <div className="flex items-center gap-3">
28                    <ChatDocUpload />
29                    <ChatSendBtn onSend={onSend} onAbort={onAbort} />
30                </div>
31            </div>
32        </div>
33    );
34}
2.2.6.3.2 输入框组件

/src/components/features/chat/chat-box/ChatInput.tsx

 1"use client";
 2
 3// ==================== 输入框组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { useRouter } from "next/navigation";
 7// ========== Components、CSS ========== //
 8import { Textarea } from "@/components/ui/textarea";
 9// ========== Icon、Type ========== //
10import type { KeyboardEvent } from "react";
11// ========== Stroe、Constants ========== //
12import { useSessionStore } from "@/store/useSessionStore";
13import { useChatStore } from "@/store/useChatStore";
14import { HOME_CHAT_PROMPT, HOME_CHATING_PROMPT } from "@/lib/constants/app";
15// ========== Hooks ========== //
16// ========== Services ========== //
17import { sessionService } from "@/services/sessionService";
18
19export default function ChatInput({ onSend }: { onSend?: () => void }) {
20    const currentSession = useSessionStore((state) => state.currentSession);
21    const chat = useChatStore((state) => state.chat);
22    const setChat = useChatStore((state) => state.setChat);
23    const isChatLoading = useChatStore((state) => state.isLoading);
24    const setIsAutoSend = useChatStore((state) => state.setIsAutoSend);
25
26    const router = useRouter();
27
28    // 输入框输入
29    const setInput = (input: string) => {
30        setChat({
31            ...chat,
32            content: input,
33        });
34    };
35
36    // 输入框回车
37    const handleInputKeyDown = async (e: KeyboardEvent<HTMLTextAreaElement>) => {
38        if (e.key === "Enter" && !e.shiftKey && chat?.content.trim()) {
39            e.preventDefault();
40            const chat = useChatStore.getState().chat;
41
42            if (!currentSession) {
43                // 首页发送
44                // 创建会话ID
45                const { id } = (
46                    await sessionService.createSession({
47                        // 标题默认截取前 20 位
48                        title: chat?.content.slice(0, 19),
49                    })
50                ).data;
51                setIsAutoSend(true);
52                // 跳转到会话页面组件
53                router.push(`/chat/${id}`);
54            } else {
55                onSend?.();
56            }
57        }
58    };
59
60    return <Textarea className="w-full flex-1 resize-none border-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0" value={chat?.content || ""} onChange={(e) => setInput(e.target.value)} placeholder={isChatLoading ? HOME_CHATING_PROMPT : HOME_CHAT_PROMPT} disabled={isChatLoading} onKeyDown={(e) => handleInputKeyDown(e)} />;
61}
2.2.6.3.3 文件上传按钮组件

/src/components/features/chat/chat-box/ChatDocUpload.tsx

 1"use client";
 2
 3// ==================== 文件上传按钮组件 ==================== //
 4// 支持 PDF/DOCX/TXT/MD 纯前端本地解析
 5// 文件限制最多3个,单文件 ≤ 100MB,自动校验大小/格式/空文件
 6// 内存安全,配合解析工具自动释放资源,无内存泄漏
 7
 8// ========== React、Next、Utils ========== //
 9import { useRef } from "react";
10// ========== Components、CSS ========== //
11import { Button } from "@/components/ui/button";
12import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
13// ========== Icon、Type ========== //
14import { Paperclip, LoaderCircle } from "lucide-react";
15// ========== Stroe、Constants ========== //
16import { useChatStore } from "@/store/useChatStore";
17import { MAX_FILE_SIZE } from "@/lib/utils/universal-file-parser";
18// ========== Hooks ========== //
19import { useDocUpload } from "@/components/hooks/chat/useDocUpload";
20// ========== Services ========== //
21
22export default function ChatDocUpload() {
23    const isSendLoading = useChatStore((state) => state.isLoading);
24    const fileInputRef = useRef<HTMLInputElement>(null);
25    const { handleFileUpload, isParsing, MAX_FILE_NUM } = useDocUpload();
26
27    return (
28        <>
29            <input className="hidden" ref={fileInputRef} multiple type="file" accept=".pdf,.docx,.txt,.md" disabled={isParsing} onChange={handleFileUpload} />
30            <Tooltip>
31                <TooltipTrigger asChild>
32                    <Button className="h-8 w-8 cursor-pointer rounded-full" variant="ghost" disabled={isParsing || isSendLoading} onClick={() => fileInputRef.current?.click()}>
33                        {isParsing ? <LoaderCircle className="h-5! w-5! animate-spin" /> : <Paperclip className="h-5! w-5!" />}
34                    </Button>
35                </TooltipTrigger>
36
37                <TooltipContent className="flex flex-col items-start">
38                    <p>上传附件(仅识别文字)</p>
39                    <p>
40                        最多 {MAX_FILE_NUM} 个,每个 {(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB,支持 .txt.md.docx.pdf 文本文件
41                    </p>
42                </TooltipContent>
43            </Tooltip>
44        </>
45    );
46}
2.2.6.3.4 文件上传按钮 Hook 组件

/src/components/hooks/chat/useDocUpload.tsx

  1"use client";
  2
  3// ==================== 文件上传按钮 Hook 组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { useState } from "react";
  7import { v4 as uuidv4 } from "uuid";
  8import { formatFileSize } from "@/lib/utils/common-tools";
  9// 解析工具核心
 10import { browserParser, type ParseResult } from "@/lib/utils/universal-file-parser";
 11// ========== Components、CSS ========== //
 12import { toast } from "sonner";
 13// ========== Icon、Type ========== //
 14import type { Document } from "@/lib/types/app";
 15// ========== Stroe、Constants ========== //
 16import { useCommonStore } from "@/store/useCommonStore";
 17import { useChatStore } from "@/store/useChatStore";
 18// ========== Hooks ========== //
 19// ========== Services ========== //
 20
 21export const useDocUpload = () => {
 22    const [isParsing, setIsParsing] = useState<boolean>(false);
 23    const setCommonModal = useCommonStore((state) => state.setCommonModal);
 24    const setChat = useChatStore((state) => state.setChat);
 25    const setIsDocUploaded = useChatStore((state) => state.setIsDocUploaded);
 26
 27    // 最大文件上传数量
 28    const MAX_FILE_NUM = 5;
 29
 30    // 文件上传处理
 31    const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
 32        const files = e.target.files;
 33        if (!files || files.length === 0) return;
 34
 35        setIsParsing(true);
 36        setIsDocUploaded(false); // 发送按钮不能点击
 37        // toast.info("文件上传中,请稍等...");
 38
 39        try {
 40            // 1. 仅校验文件数量(大小/格式/空文件 由解析工具内部统一校验)
 41            if (files.length > MAX_FILE_NUM || useChatStore.getState().chat.docs.length >= MAX_FILE_NUM) {
 42                toast.error(`文件上传失败,最多支持上传 ${MAX_FILE_NUM} 个文件`);
 43                return;
 44            }
 45
 46            const successResults: ParseResult[] = [];
 47            const errorMap: Record<string, string> = {};
 48
 49            // 2. 遍历所有文件,执行解析
 50            for (const file of files) {
 51                // 调用解析工具
 52                const result = await browserParser.parse(file);
 53
 54                // 分类收集结果/错误
 55                if (result.success) {
 56                    successResults.push(result);
 57                } else {
 58                    errorMap[file.name] = result.error!;
 59                }
 60            }
 61
 62            // 3. 更新到 会话请求状态 中
 63            setChat({
 64                ...useChatStore.getState().chat,
 65                docs: [
 66                    ...useChatStore.getState().chat.docs,
 67                    ...successResults.map((result: ParseResult) => {
 68                        const doc = {
 69                            id: uuidv4(),
 70                            fileName: result.file.name,
 71                            fileType: result.fileType,
 72                            sizeText: formatFileSize(result.file.size),
 73                            content: result.text,
 74                            uploadTime: new Date().toISOString(),
 75                        } as Document;
 76                        return doc;
 77                    }),
 78                ],
 79            });
 80
 81            // 4. 有上传错误的文件,弹窗展示告知
 82            const errorNums = Object.keys(errorMap).length;
 83            if (errorNums) {
 84                toast.warning(`文件上传结束,${files.length - errorNums} 个上传成功,${errorNums} 个上传失败,请查看原因`);
 85                // 打开上传错误文件信息弹窗
 86                setCommonModal({
 87                    open: true,
 88                    title: "部分文件上传失败信息",
 89                    type: "error",
 90                    children: (
 91                        <div className="mt-2 w-full rounded-md border border-gray-200">
 92                            {/* 关键:table-layout:fixed 固定布局,强制适配容器宽度 */}
 93                            <table className="table-layout-fixed w-full border-collapse text-left text-sm">
 94                                {/* 表头 */}
 95                                <thead className="bg-gray-50 text-gray-700">
 96                                    <tr>
 97                                        <th className="w-2/5 px-3 py-2 font-medium">文件名</th>
 98                                        <th className="w-3/5 px-3 py-2 font-medium">错误信息</th>
 99                                    </tr>
100                                </thead>
101
102                                {/* 表体 */}
103                                <tbody className="divide-y divide-gray-200">
104                                    {Object.entries(errorMap).map(([fileName, errorMsg]) => (
105                                        <tr key={fileName} className="bg-white transition-colors hover:bg-gray-50">
106                                            {/* 文件名:移除截断,自动换行,长文本拆分 */}
107                                            <td className="px-3 py-2 break-all whitespace-normal text-gray-900">{fileName}</td>
108                                            {/* 错误信息:自动换行,红色高亮 */}
109                                            <td className="px-3 py-2 break-all whitespace-normal text-red-500">{errorMsg}</td>
110                                        </tr>
111                                    ))}
112                                </tbody>
113                            </table>
114                        </div>
115                    ),
116                    confirmText: "确认",
117                    showCancel: false,
118                });
119            } else {
120                toast.success("文件上传完成");
121            }
122        } catch (err) {
123            console.error("文件处理失败:", err);
124            toast.error("文件处理失败,请重试");
125        } finally {
126            setIsParsing(false);
127            setIsDocUploaded(true);
128            e.target.value = "";
129        }
130    };
131
132    return {
133        handleFileUpload,
134        isParsing,
135        MAX_FILE_NUM,
136    };
137};
2.2.6.3.5 文件列表展示组件

/src/components/features/chat/chat-box/ChatDocShow.tsx

  1"use client";
  2
  3// ==================== 文件列表展示组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { useEffect, useRef, useState } from "react";
  7import dayjs from "dayjs";
  8// ========== Components、CSS ========== //
  9import Image from "next/image";
 10import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 11// ========== Icon、Type ========== //
 12import { X } from "lucide-react";
 13import type { Document } from "@/lib/types/app";
 14// ========== Stroe、Constants ========== //
 15import { useCommonStore } from "@/store/useCommonStore";
 16import { useChatStore } from "@/store/useChatStore";
 17// ========== Hooks ========== //
 18// ========== Services ========== //
 19
 20export default function ChatDocShow({ messageDocs }: { messageDocs?: Document[] }) {
 21    const [docs, setDocs] = useState<Document[]>([]);
 22    const setPreviewDoc = useCommonStore((state) => state.setPreviewDoc);
 23    const setIsPreviewDocOpen = useCommonStore((state) => state.setIsPreviewDocOpen);
 24    const chat = useChatStore((state) => state.chat);
 25    const setChat = useChatStore((state) => state.setChat);
 26    const docShowRef = useRef<HTMLDivElement>(null);
 27
 28    useEffect(() => {
 29        // 异步包裹 setState,彻底解决级联渲染报错
 30        Promise.resolve().then(() => {
 31            // 有 messageDocs 用它,没有就用 chat.docs,兜底空数组
 32            const finalDocs = messageDocs || chat?.docs || [];
 33            setDocs(finalDocs);
 34        });
 35    }, [messageDocs, chat]);
 36
 37    // 滚动控制颠倒
 38    useEffect(() => {
 39        const el = docShowRef.current;
 40        if (el) {
 41            const handleWheel = (e: any) => {
 42                e.preventDefault();
 43                el.scrollLeft += e.deltaY;
 44            };
 45            el.addEventListener("wheel", handleWheel);
 46            return () => el.removeEventListener("wheel", handleWheel);
 47        }
 48    }, []);
 49
 50    // 预览临时上传文档
 51    const previewTempDoc = (doc: Document) => {
 52        setPreviewDoc(doc);
 53        setIsPreviewDocOpen(true);
 54    };
 55
 56    // 删除临时上传文件
 57    const removeTempDoc = (e: any, docId: string) => {
 58        e.stopPropagation();
 59
 60        setChat({
 61            ...useChatStore.getState().chat,
 62            docs: useChatStore.getState().chat.docs.filter((doc: Document) => doc.id !== docId),
 63        });
 64    };
 65
 66    return (
 67        <div className={`${!docs.length && "hidden"} flex max-w-full gap-2 overflow-x-auto pb-1`} ref={docShowRef}>
 68            {docs?.map((doc: Document) => (
 69                <Tooltip key={doc.id}>
 70                    <TooltipTrigger asChild>
 71                        <div className="relative flex w-46 cursor-pointer items-center justify-start gap-2 rounded-sm bg-gray-100 py-1 pr-5 pl-3" title="点击预览文件" onClick={() => previewTempDoc(doc)}>
 72                            <Image src={`/assets/images/fileIcons/${doc.fileType}.png`} alt="" width={18} height={18} priority />
 73
 74                            <div className="flex min-w-0 flex-col">
 75                                <span className="truncate text-sm text-gray-900">{doc.fileName}</span>
 76
 77                                <span className="text-xs text-gray-500">
 78                                    {doc.fileType} · {doc.sizeText}
 79                                </span>
 80                            </div>
 81                            {!messageDocs ? (
 82                                <>
 83                                    <div className="absolute top-1 right-1 cursor-pointer" onClick={(e) => removeTempDoc(e, doc.id)} title="删除文件">
 84                                        <X size={12} />
 85                                    </div>
 86                                </>
 87                            ) : (
 88                                <></>
 89                            )}
 90                        </div>
 91                    </TooltipTrigger>
 92                    <TooltipContent className="flex flex-col items-start gap-1">
 93                        <p>文件ID{doc.id}</p>
 94                        <p>文件名:{doc.fileName}</p>
 95                        <p>类型:{doc.fileType}</p>
 96                        <p>大小:{doc.sizeText}</p>
 97                        {/* <p>上传时间:{dayjs(doc.uploadTime).fromNow()}</p> */}
 98                        <p>上传时间:{dayjs(doc.uploadTime).format("YYYY-MM-DD HH:mm")}</p>
 99                    </TooltipContent>
100                </Tooltip>
101            ))}
102        </div>
103    );
104}
2.2.6.3.6 模型选择组件

/src/components/features/chat/chat-box/ChatModelSelector.tsx

 1"use client";
 2
 3// ==================== 模型选择组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { useEffect } from "react";
 7// ========== Components、CSS ========== //
 8import Image from "next/image";
 9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10// ========== Icon、Type ========== //
11import type { Model } from "@/lib/types/app";
12// ========== Stroe、Constants ========== //
13import { useModelStore } from "@/store/useModelStore";
14import { useChatStore } from "@/store/useChatStore";
15// ========== Hooks ========== //
16// ========== Services ========== //
17import { useGetModelList } from "@/components/hooks/common/useSwrApi";
18
19export default function ChatModelSelector() {
20    const modelList = useModelStore((state) => state.modelList);
21    const setModelList = useModelStore((state) => state.setModelList);
22    const chat = useChatStore((state) => state.chat);
23    const setChat = useChatStore((state) => state.setChat);
24
25    const { fetchedModelList } = useGetModelList();
26
27    // 模型自动赋值
28    const setCurrentModelByChatData = () => {
29        if (fetchedModelList?.length) {
30            if (!chat.model) {
31                // 无选中模型时,默认选中第一个
32                setChat({ ...useChatStore.getState().chat, model: fetchedModelList[0].name });
33            } else {
34                const isExist = useModelStore.getState().modelList.some((model: Model) => model.name === chat.model);
35                setChat({ ...useChatStore.getState().chat, model: isExist ? chat.model : fetchedModelList[0].name });
36            }
37        }
38    };
39
40    // 模型列表 状态存储
41    useEffect(() => {
42        if (!fetchedModelList) return;
43        setModelList(fetchedModelList);
44    }, [fetchedModelList]);
45
46    useEffect(() => {
47        setCurrentModelByChatData();
48    }, [fetchedModelList, chat.model]);
49
50    // 获取当前模型配置(本项目是开启了 React Compiler,会自动 useMemo/useCallback 优化)
51    const getCurrentModelByChatData = (modelName: string): Model | undefined => {
52        return modelList?.find((model: Model) => model.name === modelName);
53    };
54
55    return (
56        <Select value={chat.model || ""} onValueChange={(name) => setChat({ ...chat, model: name })}>
57            <SelectTrigger className="flex w-35 cursor-pointer items-center px-2 py-4" autoFocus={false}>
58                <SelectValue>
59                    <Image className="rounded-sm" src={chat.model ? `/assets/images/modelIcons/${chat.model}.png` : "/assets/images/logo.png"} alt="" width={24} height={24} priority />
60                    <span className="font-medium text-gray-900">{getCurrentModelByChatData(chat.model)?.label}</span>
61                </SelectValue>
62            </SelectTrigger>
63
64            <SelectContent className="w-60 px-1" position="popper" side="top" sideOffset={0} align="start" alignOffset={0} autoFocus={false}>
65                {modelList?.map((model) => (
66                    <SelectItem className="m-1 cursor-pointer px-5 py-2" key={model.name} value={model.name || ""}>
67                        <div className="mr-4 flex w-full items-start gap-2">
68                            <Image className="mt-1 rounded-sm" src={chat.model ? `/assets/images/modelIcons/${model.name}.png` : "/assets/images/logo.png"} alt="" width={24} height={24} priority />
69                            <div className="flex flex-col">
70                                <span className="flex h-6 items-center font-medium text-gray-900">{model.label}</span>
71                                <span className="text-xs text-gray-500!">{model.description}</span>
72                            </div>
73                        </div>
74                    </SelectItem>
75                ))}
76            </SelectContent>
77        </Select>
78    );
79}
2.2.6.3.7 发送按钮组件

/src/components/features/chat/chat-box/ChatSendBtn.tsx

 1"use client";
 2
 3// ==================== 发送按钮组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { useRouter } from "next/navigation";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import { LoaderCircle, Send } from "lucide-react";
10// ========== Stroe、Constants ========== //
11import { useSessionStore } from "@/store/useSessionStore";
12import { useChatStore } from "@/store/useChatStore";
13// ========== Hooks ========== //
14// ========== Services ========== //
15import { sessionService } from "@/services/sessionService";
16
17export default function ChatSendBtn({ onSend, onAbort }: { onSend?: () => void; onAbort?: () => void }) {
18    const currentSession = useSessionStore((state) => state.currentSession);
19    const chat = useChatStore((state) => state.chat);
20    const isDocUploaded = useChatStore((state) => state.isDocUploaded);
21    const isSendLoading = useChatStore((state) => state.isLoading);
22    const setIsAutoSend = useChatStore((state) => state.setIsAutoSend);
23
24    const router = useRouter();
25
26    // 处理请求发送
27    const handleSend = async (): Promise<void> => {
28        const chat = useChatStore.getState().chat;
29
30        if (!currentSession) {
31            // 首页发送
32            // 创建会话ID
33            const { id } = (
34                await sessionService.createSession({
35                    // 标题默认截取前 20 位
36                    title: chat?.content.slice(0, 19),
37                })
38            ).data;
39            // 跳转到会话页面组件
40            setIsAutoSend(true);
41            router.push(`/chat/${id}`);
42        } else {
43            onSend?.();
44        }
45    };
46
47    return (
48        <>
49            {isSendLoading ? (
50                <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-red-500 text-white shadow-lg transition-all duration-200 hover:bg-red-600 hover:shadow-red-500/30 active:scale-95" onClick={onAbort}>
51                    <LoaderCircle className="h-4 w-4 animate-spin" />
52                </div>
53            ) : !chat.content.trim() || !isDocUploaded ? (
54                <div className="flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-gray-300 text-gray-500 shadow-lg transition-all duration-200">
55                    <Send className="h-4 w-4" />
56                </div>
57            ) : (
58                <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-[#052658] text-white shadow-lg transition-all duration-200 hover:bg-[#063272] hover:shadow-[#052658]/30 active:scale-95" onClick={handleSend}>
59                    <Send className="h-4 w-4" />
60                </div>
61            )}
62        </>
63    );
64}
2.2.6.4 前端文档解析工具封装
1pnpm install mammoth pdfjs-dist

/src/lib/utils/universalFileParser.ts

  1"use client";
  2
  3// ==================== 前端文档解析工具 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6// 依赖 mammoth pdfjs-dist,这里是动态导入,所以你需要提前安装
  7// `pnpm install mammoth pdfjs-dist`
  8// ========== Components、CSS ========== //
  9// ========== Icon、Type ========== //
 10// ========== Stroe、Constants ========== //
 11// ========== Hooks ========== //
 12// ========== Services ========== //
 13
 14export interface ParseProgress {
 15    progress: number;
 16}
 17
 18export interface ParseResult {
 19    text: string;
 20    fileType: string;
 21    file: File;
 22    success: boolean;
 23    error?: string;
 24}
 25
 26export const MAX_FILE_SIZE = 10 * 1024 * 1024;
 27export const ALLOWED_EXTENSIONS = [".txt", ".md", ".pdf", ".docx"] as const;
 28export const ALLOWED_MIME_TYPES = ["text/plain", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/pdf"];
 29export const ERROR_MESSAGES = {
 30    FILE_TOO_LARGE: `文件大小超出限制,最大支持 ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB`, // TXT、MD、DOCX、PDF
 31    INVALID_TYPE: "不支持的文件格式,请上传 .txt、.md、.docx、.pdf 文件", // TXT、MD、DOCX、PDF
 32    EMPTY_FILE: "文件内容为空,无法提取文本", // TXT、MD、DOCX、PDF
 33    NO_EXTRACTED_TEXT: "文件解析完成,但未提取到有效文本", // TXT、MD、DOCX、PDF
 34    PARSE_FAILED: "文件解析失败,请检查文件完整性", // TXT、MD
 35    CORRUPTED_FILE: "文件已损坏或格式错误", // DOCX、PDF
 36    ENCRYPTED_PDF: "PDF 文件已加密,无法解析", // PDF
 37} as const;
 38
 39class UniversalFileParser {
 40    private isPdfInitialized = false;
 41
 42    constructor() {
 43        if (typeof window !== "undefined") this.initPdfParser();
 44    }
 45
 46    /**
 47     * pdfjs-dist 解析包初始化
 48     * 提前准备:需要把 `/node_modules/pdfjs-dist/build/pdf.worker.min.mjs` 复制到 `/assets/pdf-worker/` 下
 49     */
 50    private initPdfParser = async () => {
 51        if (this.isPdfInitialized) return;
 52        try {
 53            const pdfjs = await import("pdfjs-dist");
 54            // 直接使用你项目里的本地静态文件路径
 55            pdfjs.GlobalWorkerOptions.workerSrc = "/assets/pdf-worker/pdf.worker.min.mjs";
 56            this.isPdfInitialized = true;
 57        } catch (error) {
 58            throw new Error(`pdfjs-dist 解析包初始化失败: ${(error as Error).message}`);
 59        }
 60    };
 61
 62    // 文件校验:文件大小、文件类型、文件内容非空
 63    private validateFile = (file: File): string | null => {
 64        if (file.size > MAX_FILE_SIZE) return ERROR_MESSAGES.FILE_TOO_LARGE;
 65        const fileName = file.name.toLowerCase();
 66        const isValidExt = ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
 67        const isValidMime = ALLOWED_MIME_TYPES.includes(file.type);
 68        if (!isValidExt && !isValidMime) return ERROR_MESSAGES.INVALID_TYPE;
 69        if (file.size === 0) return ERROR_MESSAGES.EMPTY_FILE;
 70        return null;
 71    };
 72
 73    // 解析 TXT / MD
 74    private parseText = async (file: File): Promise<string> => {
 75        return new Promise((resolve, reject) => {
 76            const reader = new FileReader();
 77            reader.readAsText(file, "UTF-8");
 78            reader.onerror = () => reject(ERROR_MESSAGES.PARSE_FAILED);
 79            reader.onload = (e) => {
 80                const text = e.target?.result as string;
 81                text.trim() ? resolve(text) : reject(ERROR_MESSAGES.NO_EXTRACTED_TEXT);
 82            };
 83        });
 84    };
 85
 86    // 解析 DOCX
 87    private parseDocx = async (file: File): Promise<string> => {
 88        try {
 89            const mammoth = await import("mammoth");
 90            const arrayBuffer = await file.arrayBuffer();
 91            const result = await mammoth.extractRawText({ arrayBuffer });
 92            if (result.value.trim()) {
 93                return result.value.trim();
 94            } else {
 95                throw new Error(ERROR_MESSAGES.NO_EXTRACTED_TEXT);
 96            }
 97        } catch {
 98            throw new Error(ERROR_MESSAGES.CORRUPTED_FILE);
 99        }
100    };
101
102    // 解析 PDF(纯本地、带进度、内存安全)
103    private parsePdf = async (file: File, onProgress?: (progress: ParseProgress) => void): Promise<string> => {
104        if (!this.isPdfInitialized) await this.initPdfParser();
105        const pdfjs = await import("pdfjs-dist");
106        const arrayBuffer = await file.arrayBuffer();
107        let pdfInstance = null;
108
109        try {
110            pdfInstance = await pdfjs.getDocument({ data: arrayBuffer }).promise;
111            const totalPages = pdfInstance.numPages;
112            let fullText = "";
113
114            for (let i = 1; i <= totalPages; i++) {
115                const page = await pdfInstance.getPage(i);
116                const content = await page.getTextContent();
117                const pageText = content.items.map((item: any) => item.str).join(" ");
118                fullText += pageText + "\n";
119                onProgress?.({ progress: i / totalPages });
120            }
121            if (fullText.trim()) {
122                return fullText.trim();
123            } else {
124                throw new Error(ERROR_MESSAGES.NO_EXTRACTED_TEXT);
125            }
126        } catch (err) {
127            if ((err as Error).message.includes("password")) throw new Error(ERROR_MESSAGES.ENCRYPTED_PDF);
128            throw new Error(ERROR_MESSAGES.CORRUPTED_FILE);
129        } finally {
130            if (pdfInstance) pdfInstance.destroy();
131        }
132    };
133
134    // 统一解析入口
135    parse = async (file: File, onProgress?: (progress: ParseProgress) => void): Promise<ParseResult> => {
136        try {
137            const validateError = this.validateFile(file);
138            if (validateError) return { text: "", fileType: "", file, success: false, error: validateError };
139
140            const fileName = file.name.toLowerCase();
141            let text = "";
142            let fileType = "";
143            if (fileName.endsWith(".txt") || fileName.endsWith(".md")) {
144                text = await this.parseText(file);
145                if (fileName.endsWith(".txt")) {
146                    fileType = "txt";
147                } else {
148                    fileType = "md";
149                }
150            } else if (fileName.endsWith(".docx")) {
151                text = await this.parseDocx(file);
152                fileType = "docx";
153            } else if (fileName.endsWith(".pdf")) {
154                text = await this.parsePdf(file, onProgress);
155                fileType = "pdf";
156            }
157
158            return { text, fileType, file, success: true };
159        } catch (err: any) {
160            return { text: "", fileType: "", file, success: false, error: err.message || ERROR_MESSAGES.PARSE_FAILED };
161        }
162    };
163}
164
165export const browserParser = new UniversalFileParser();

2.2.7 页面相关

2.2.7.1 首页页面组件

/src/app/page.tsx

 1"use client";
 2
 3// ==================== 首页页面组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7import Image from "next/image";
 8import CommonLayout from "@/components/layouts/CommonLayout";
 9import ChatTitle from "@/components/features/chat/chat-title/ChatTitle";
10import ChatBox from "@/components/features/chat/chat-box/ChatBox";
11// ========== Icon、Type ========== //
12// ========== Stroe、Constants ========== //
13import { useCommonStore } from "@/store/useCommonStore";
14import { HOME_CHAT_TITLE } from "@/lib/constants/app";
15// ========== Hooks ========== //
16// ========== Services ========== //
17
18export default function Home() {
19    const collapsed = useCommonStore((state) => state.collapsed);
20
21    return (
22        <CommonLayout>
23            <div className={`${collapsed ? "md:max-w-220" : "md:max-w-3xl"} flex h-full w-full flex-col md:justify-center`}>
24                <div className="fixed top-0 w-full">
25                    <ChatTitle />
26                </div>
27
28                <div className="-mt-16 flex h-full w-full items-center justify-center gap-4 md:mb-6 md:h-auto">
29                    <Image className="rounded-sm" src="/assets/images/logo.png" alt="T.AI" width={46} height={46} priority />
30                    <h3 className="text-xl font-medium text-[#052658]">{HOME_CHAT_TITLE}</h3>
31                </div>
32
33                <div className="fixed bottom-0 w-full px-3 py-5 md:relative">
34                    <ChatBox />
35                </div>
36            </div>
37        </CommonLayout>
38    );
39}
2.2.7.2 会话页面组件

/src/app/chat/page.tsx

 1"use client";
 2
 3// ==================== 会话页面组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7import CommonLayout from "@/components/layouts/CommonLayout";
 8import ChatTitle from "@/components/features/chat/chat-title/ChatTitle";
 9import ChatContent from "@/components/features/chat/chat-content/ChatContent";
10import ChatBox from "@/components/features/chat/chat-box/ChatBox";
11// ========== Icon、Type ========== //
12import type { Chat } from "@/lib/types/app";
13// ========== Stroe、Constants ========== //
14import { useCommonStore } from "@/store/useCommonStore";
15import { useChatStore } from "@/store/useChatStore";
16// ========== Hooks ========== //
17import { useChat } from "@/components/hooks/chat/useChat";
18// ========== Services ========== //
19
20export default function Chat() {
21    const collapsed = useCommonStore((state) => state.collapsed);
22    const chat = useChatStore((state) => state.chat);
23
24    const { sendChatMessage, abortChatRequest } = useChat();
25
26    return (
27        <CommonLayout>
28            <div className={`${collapsed ? "md:max-w-242" : "md:max-w-3xl"} flex h-full w-full flex-col`}>
29                <div className={`${collapsed ? "md:max-w-242" : "md:max-w-3xl"} fixed top-0 z-30! w-full md:my-auto`}>
30                    <ChatTitle />
31                    <div className="pointer-events-none h-3 w-full bg-linear-to-b from-white to-transparent" />
32                </div>
33                <div className={`${chat.docs.length ? "pb-67" : "pb-50"} z-10! w-full pt-20`}>
34                    <ChatContent />
35                </div>
36                <div className={`${collapsed ? "md:max-w-242" : "md:max-w-3xl"} fixed bottom-0 z-30! flex w-full justify-center bg-white px-3 py-5 md:my-auto md:px-0`}>
37                    <div className="pointer-events-none absolute -top-5 left-0 h-5 w-full bg-linear-to-t from-white to-transparent" />
38                    <ChatBox onSend={sendChatMessage} onAbort={abortChatRequest} />
39                </div>
40            </div>
41        </CommonLayout>
42    );
43}
2.2.7.2.1 会话页面 Hook 组件

/src/components/hooks/chat/useChat.tsx

  1"use client";
  2
  3// ==================== 会话页面 Hook 组件 ==================== //
  4
  5// ========== React、Next、Utils ========== //
  6import { useEffect, useRef, useState } from "react";
  7import { useParams, useRouter } from "next/navigation";
  8import { v4 as uuidv4 } from "uuid";
  9// ========== Components、CSS ========== //
 10// ========== Icon、Type ========== //
 11import type { Chat, ChatHistory, Message } from "@/lib/types/app";
 12// ========== Stroe、Constants ========== //
 13import { useSessionStore } from "@/store/useSessionStore";
 14import { useChatStore } from "@/store/useChatStore";
 15// ========== Hooks ========== //
 16// ========== Services ========== //
 17import { sessionService } from "@/services/sessionService";
 18import { chatService } from "@/services/chatService";
 19
 20export const useChat = () => {
 21    const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
 22    const isAutoSend = useChatStore((state) => state.isAutoSend);
 23    const setIsAutoSend = useChatStore((state) => state.setIsAutoSend);
 24    const setIsLoading = useChatStore((state) => state.setIsLoading);
 25    const setChat = useChatStore((state) => state.setChat);
 26    const resetChat = useChatStore((state) => state.resetChat);
 27
 28    // 标记会话是否初始化完成
 29    const [isSessionInitialized, setIsSessionInitialized] = useState(false);
 30    const router = useRouter();
 31    // 用于中断请求的控制器 Ref(持久化存储,避免重渲染丢失)
 32    const abortControllerRef = useRef<AbortController | null>(null);
 33    const timerRef = useRef<NodeJS.Timeout[]>([]);
 34
 35    // 从路由参数获取会话ID
 36    const params = useParams<{ sessionId: string }>();
 37    const sessionId = params.sessionId;
 38
 39    // 清理所有定时器(防止内存泄漏)
 40    const clearAllTimers = () => {
 41        timerRef.current.forEach((timer) => clearTimeout(timer));
 42        timerRef.current = [];
 43    };
 44
 45    // 初始化会话
 46    const initSession = async () => {
 47        try {
 48            // 从服务端获取会话详情
 49            const res = await sessionService.getSession(sessionId);
 50            setCurrentSession(res.data);
 51            setIsSessionInitialized(true); // 标记会话初始化完成
 52
 53            // 模型赋值
 54            if (res.data.messages?.length) {
 55                // 优先选最后一条消息的模型
 56                const modelName = res.data.messages.slice(-1)[0].model;
 57                setChat({ ...useChatStore.getState().chat, model: modelName });
 58            }
 59
 60            // 自动发送消息
 61            if (isAutoSend) {
 62                sendChatMessage();
 63            }
 64        } catch (error) {
 65            console.log("获取会话信息失败:", error);
 66            // 会话不存在时跳转到首页
 67            router.push("/");
 68            resetChat();
 69            setCurrentSession(null);
 70        }
 71    };
 72
 73    // 中止请求
 74    const abortChatRequest = () => {
 75        const controller = abortControllerRef.current;
 76        if (controller && !controller.signal.aborted) {
 77            controller.abort();
 78        }
 79    };
 80
 81    // 组件挂载时初始化会话
 82    useEffect(() => {
 83        if (!sessionId) return;
 84        initSession();
 85    }, []);
 86
 87    // 组件卸载时中止所有未完成的请求
 88    useEffect(() => {
 89        return () => {
 90            abortChatRequest();
 91            clearAllTimers();
 92        };
 93    }, []);
 94
 95    // 自动发送会话,首页跳转和重试场景使用,确保在会话初始化完成后发送,否者消息显示会重置
 96    useEffect(() => {
 97        if (isAutoSend && isSessionInitialized) {
 98            sendChatMessage();
 99        }
100    }, [isAutoSend, isSessionInitialized]);
101
102    // 发送聊天消息
103    const sendChatMessage = async () => {
104        // 清理旧请求:保证同一时间只有一个请求在执行
105        if (abortControllerRef.current) {
106            abortControllerRef.current.abort();
107            abortControllerRef.current = null;
108        }
109
110        try {
111            // 关闭自动发送开关(避免重复触发,这个开关主要是用于首页跳转的请求和重试功能)
112            setIsAutoSend(false);
113            // 创建新的中断控制器,用于本次请求
114            const abortController = new AbortController();
115            abortControllerRef.current = abortController;
116
117            // 组装请求参数:从状态库获取当前会话和聊天配置
118            let currentSession = useSessionStore.getState().currentSession;
119            const chat = useChatStore.getState().chat;
120            const chatParams: Chat = {
121                ...chat,
122                id: sessionId,
123                // 转换会话消息为接口要求的格式
124                chatHistorys:
125                    currentSession?.messages?.map(
126                        (msg) =>
127                            ({
128                                role: msg.role.trim(),
129                                content: msg.content.trim(),
130                            }) as ChatHistory,
131                    ) || [],
132                // 判断是否有文档关联的历史消息
133                hasDocHistorys: !!currentSession?.messages?.some((item) => item.docs?.length > 0),
134            };
135
136            // 用户问题 UI 显示,异步保存
137            const userMessage: Message = {
138                id: uuidv4(),
139                role: "user",
140                model: chat.model,
141                content: chat.content,
142                createTime: new Date().toISOString(),
143                docs: chat.docs,
144            };
145
146            if (currentSession) {
147                setCurrentSession({
148                    ...currentSession,
149                    messages: [...(currentSession.messages || []), userMessage],
150                });
151                // 更新消息到服务端
152                sessionService.updateSessionMessage(sessionId, { message: userMessage });
153            }
154
155            // AI回复 UI 显示,内容暂时为空
156            currentSession = useSessionStore.getState().currentSession;
157            const assistantMessage: Message = {
158                id: uuidv4(),
159                role: "assistant",
160                model: chat.model,
161                content: "", // 初始为空,后续流式填充
162                createTime: new Date().toISOString(),
163                docs: [],
164            };
165            if (currentSession) {
166                setCurrentSession({
167                    ...currentSession,
168                    messages: [...(currentSession.messages || []), assistantMessage],
169                });
170            }
171
172            // 流式返回读写器
173            let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
174
175            // 中止时的处理函数:取消流读取(等于告诉后端中断),主动保存已生成的AI消息
176            const handleAbort = async () => {
177                if (reader) {
178                    reader?.cancel();
179                }
180                // 取到AI回复的内容,加上终止信息,更新会话消息到服务器
181                currentSession = useSessionStore.getState().currentSession;
182                const allMessages = currentSession?.messages;
183                const lastMessage = allMessages!.at(-1);
184                // 添加终止信息
185                let abortMessage = "[已中止]";
186                if (lastMessage!.content) {
187                    abortMessage = "...\n\n" + abortMessage;
188                }
189
190                if (currentSession) {
191                    setCurrentSession({
192                        ...currentSession,
193                        messages: currentSession.messages?.map((item, idx, arr) =>
194                            // 只更新最后一条(AI回复)的内容
195                            idx === arr.length - 1 ? { ...item, content: item.content + abortMessage } : item,
196                        ),
197                    });
198                }
199
200                // 更新会话消息到服务端
201                lastMessage!.content += abortMessage;
202                if (lastMessage && chatParams.id) {
203                    sessionService.updateSessionMessage(chatParams.id, { message: lastMessage });
204                }
205            };
206
207            // 监听中止信号,触发流取消逻辑
208            abortController.signal.addEventListener("abort", handleAbort);
209
210            // 重置聊天输入状态,发送按钮标记加载中
211            resetChat(chat.model);
212            // 延迟500ms,确保请求以生成
213            const timer = setTimeout(() => {
214                setIsLoading(true);
215            }, 500);
216            timerRef.current.push(timer);
217
218            // 发送请求:调用聊天服务,传入中断信号
219            const res = await chatService.sendChat({
220                chatParams: chatParams,
221                signal: abortController.signal,
222            });
223
224            // 处理流式返回:逐段读取AI回复并更新UI
225            reader = res.body.getReader();
226            // 解码二进制流为字符串
227            const decoder = new TextDecoder("utf-8");
228
229            // 循环读取流式数据
230            while (true) {
231                // 若请求已中止,直接退出循环
232                if (abortController.signal.aborted) break;
233
234                try {
235                    const { done, value } = await reader!.read();
236                    // 流读取完成或请求中止,退出循环
237                    if (done || abortController.signal.aborted) break;
238
239                    // 实时更新AI回复内容(只修改最后一条AI消息)
240                    currentSession = useSessionStore.getState().currentSession;
241                    if (currentSession) {
242                        setCurrentSession({
243                            ...currentSession,
244                            messages: currentSession.messages?.map((item, idx, arr) =>
245                                // 只更新最后一条(AI回复)的内容
246                                idx === arr.length - 1 ? { ...item, content: item.content + decoder.decode(value) } : item,
247                            ),
248                        });
249                    }
250                } catch (readError) {
251                    console.error("流式读取异常:", readError);
252                    break;
253                }
254            }
255
256            // 清理中止监听事件
257            abortController.signal.removeEventListener("abort", handleAbort);
258        } catch (error) {
259            // 忽略主动中止的错误,仅打印其他异常
260            if (error instanceof DOMException && error.name === "AbortError") {
261                console.log("聊天请求已主动中止");
262            } else {
263                console.error("发送聊天消息失败:", error);
264            }
265        } finally {
266            // 无论成功/失败,都标记加载结束,清理控制器
267            setIsLoading(false);
268            abortControllerRef.current = null;
269        }
270    };
271
272    // 返回给组件的方法
273    return {
274        sendChatMessage, // 发送消息方法
275        abortChatRequest, // 中止请求方法
276        sessionId, // 当前会话ID
277    };
278};

2.2.8 通用工具

/src/lib/utils/common-tools.ts

 1// ==================== 通用工具 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4// ========== Components、CSS ========== //
 5import { toast } from "sonner";
 6// ========== Icon、Type ========== //
 7// ========== Stroe、Constants ========== //
 8// ========== Hooks ========== //
 9// ========== Services ========== //
10
11// 文件大小单位转换:B → KB → MB
12export const formatFileSize = (bytes: number): string => {
13    if (bytes < 1024) return bytes + " B";
14    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
15    return (bytes / (1024 * 1024)).toFixed(1) + " MB";
16};
17
18// 复制文本到剪贴板
19export const copyToClipboard = async (text: string) => {
20    try {
21        // 尝试使用 Clipboard API
22        if (navigator.clipboard && navigator.clipboard.writeText) {
23            await navigator.clipboard.writeText(text);
24            toast.success("复制成功");
25        } else {
26            // 降级方案:创建临时输入框并复制
27            const textArea = document.createElement("textarea");
28            textArea.value = text;
29            textArea.style.position = "fixed";
30            textArea.style.left = "-999999px";
31            textArea.style.top = "-999999px";
32            document.body.appendChild(textArea);
33            textArea.focus();
34            textArea.select();
35
36            try {
37                document.execCommand("copy");
38                toast.success("复制成功");
39            } catch (err) {
40                console.error("复制失败:", err);
41                toast.error("复制失败");
42            } finally {
43                document.body.removeChild(textArea);
44            }
45        }
46    } catch (error) {
47        console.error("复制失败:", error);
48        toast.error("复制失败");
49    }
50};

2.3 数据管理

2.3.1 安装依赖

1pnpm install zustand swr

2.3.2 客户端全局应用常量

/src/lib/constants/app.ts

 1// ==================== 客户端全局应用常量 ==================== //
 2
 3// 首页聊天框标题
 4export const HOME_CHAT_TITLE = "今天有什么可以帮到你?";
 5// 首页聊天框提示语
 6export const HOME_CHAT_PROMPT = "给 T.AI 发送消息";
 7export const HOME_CHATING_PROMPT = "稍等,T.AI 正在思考中...";
 8// 前端接口请求默认配置
 9export const FETCH_CONFIG = {
10    TIMEOUT: 600000, // 客户端请求默认超时时间
11    PREFIX: "/api", // 接口请求前缀
12    BFF_PREFIX: "/api", // BFF接口请求前缀
13};

2.3.3 客户端全局 TS 类型

/src/lib/types/app.d.ts

 1// ==================== 客户端全局TS类型 ==================== //
 2
 3// 模型类型
 4export interface Model {
 5    name?: string;
 6    label?: string;
 7    description?: string;
 8    apiKeyKey?: string;
 9    chatApiUrl?: string;
10    parserType?: string;
11    requestOptions?: string;
12    enabled?: number;
13}
14
15// 会话类型
16export interface Session {
17    id: string;
18    title: string;
19    createTime: string;
20    messages: Message[];
21}
22
23// 对话消息类型
24export interface Message {
25    id: string;
26    role: "user" | "assistant";
27    model: string;
28    content: string;
29    createTime: string;
30    docs: Document[];
31}
32
33// 文档类型
34export interface Document {
35    id: string;
36    fileName: string;
37    fileType: string;
38    sizeText: string;
39    content: string;
40    uploadTime: string;
41}
42
43// 会话请求类型
44export interface Chat {
45    id: string;
46    model: string;
47    content: string;
48    chatHistorys: ChatHistory[];
49    docs: Document[];
50    hasDocHistorys: boolean;
51}
52
53// 对话历史类型
54export interface ChatHistory {
55    role: "user" | "assistant";
56    content: string;
57}

2.3.4 状态封装

2.3.4.1 通用状态

/src/store/useCommonStore.ts

 1"use client";
 2
 3// ==================== 通用状态 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { create } from "zustand";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { ModalType, CommonModalProps } from "@/components/features/common/CommonModal";
10import type { Document } from "@/lib/types/app";
11// ========== Stroe、Constants ========== //
12// ========== Hooks ========== //
13// ========== Services ========== //
14
15interface CommonStore {
16    // 侧边栏
17    collapsed: boolean;
18    isSidebarMobileOpen: boolean;
19    setCollapsed: (collapsed: boolean) => void;
20    setIsSidebarMobileOpen: (collapsed: boolean) => void;
21    // 通用弹窗
22    commonModal: CommonModalProps;
23    setCommonModal: (commonModal: CommonModalProps) => void;
24    resetCommonModal: () => void;
25    // 文档预览
26    previewDoc: Document | null;
27    isPreviewDocOpen: boolean;
28    setPreviewDoc: (previewDoc: Document | null) => void;
29    setIsPreviewDocOpen: (isPreviewDocOpen: boolean) => void;
30}
31
32const initialState = {
33    // 侧边栏
34    collapsed: false,
35    isSidebarMobileOpen: false,
36    // 通用弹窗
37    commonModal: {
38        open: false,
39        onOpenChange: null,
40        type: "info" as ModalType,
41        title: "",
42        description: "",
43        children: null,
44        confirmText: "确定",
45        cancelText: "取消",
46        showCancel: true,
47        confirmLoading: false,
48        onConfirm: null,
49        onCancel: null,
50    },
51    // 文档预览
52    previewDoc: null,
53    isPreviewDocOpen: false,
54};
55
56export const useCommonStore = create<CommonStore>((set) => ({
57    ...initialState,
58    // 侧边栏
59    setCollapsed: (collapsed: boolean) => set({ collapsed: collapsed }),
60    setIsSidebarMobileOpen: (isSidebarMobileOpen: boolean) => set({ isSidebarMobileOpen: isSidebarMobileOpen }),
61    // 通用弹窗
62    setCommonModal: (commonModal: CommonModalProps) => set({ commonModal: commonModal }),
63    resetCommonModal: () => set({ commonModal: initialState.commonModal }),
64    // 文档预览
65    setPreviewDoc: (previewDoc: Document | null) => set({ previewDoc: previewDoc }),
66    setIsPreviewDocOpen: (isPreviewDocOpen: boolean) => set({ isPreviewDocOpen: isPreviewDocOpen }),
67}));
2.3.4.2 模型状态

/src/store/useModelStore.ts

 1"use client";
 2
 3// ==================== 模型状态 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { create } from "zustand";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { Model } from "@/lib/types/app";
10// ========== Stroe、Constants ========== //
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14interface ModelStore {
15    modelList: Model[];
16    setModelList: (modelList: Model[]) => void;
17}
18
19const initialState = {
20    modelList: [],
21};
22
23export const useModelStore = create<ModelStore>((set) => ({
24    ...initialState,
25    setModelList: (modelList: Model[]) => set({ modelList: modelList }),
26}));
2.3.4.3 会话状态

/src/store/useSessionStore.ts

 1"use client";
 2
 3// ==================== 会话状态 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { create } from "zustand";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { Session } from "@/lib/types/app";
10// ========== Stroe、Constants ========== //
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14interface SessionStore {
15    // 会话列表
16    sessionList: Session[];
17    setSessionList: (sessionList: Session[]) => void;
18    // 当前会话详情
19    currentSession: Session | null;
20    setCurrentSession: (collacurrentSessionpsed: Session | null) => void;
21    // 会话重命名
22    editId: string;
23    setEditId: (editId: string) => void;
24    editName: string;
25    setEditName: (editName: string) => void;
26    resetEdit: () => void;
27}
28
29const initialState = {
30    // 会话列表
31    sessionList: [],
32    // 当前会话详情
33    currentSession: null,
34    // 会话重命名
35    editId: "",
36    editName: "",
37};
38
39export const useSessionStore = create<SessionStore>((set) => ({
40    ...initialState,
41    // 会话列表
42    setSessionList: (sessionList: Session[]) => set({ sessionList: sessionList }),
43    // 当前会话详情
44    setCurrentSession: (currentSession: Session | null) => set({ currentSession: currentSession }),
45    // 会话重命名
46    setEditId: (editId: string) => set({ editId: editId }),
47    setEditName: (editName: string) => set({ editName: editName }),
48    resetEdit: () => set({ editId: "", editName: "" }),
49}));
2.3.4.4 会话请求状态

/src/store/useChatStore.ts

 1"use client";
 2
 3// ==================== 会话请求状态 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { create } from "zustand";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { Chat } from "@/lib/types/app";
10// ========== Stroe、Constants ========== //
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14interface ChatStore {
15    chat: Chat;
16    setChat: (chat: Chat) => void;
17    isDocUploaded: boolean;
18    setIsDocUploaded: (isDocUploaded: boolean) => void;
19    isAutoSend: boolean; // 是否自动发送,首页过来需要设置 true
20    setIsAutoSend: (isAutoSend: boolean) => void;
21    resetChat: (model?: string) => void;
22    isLoading: boolean;
23    setIsLoading: (isLoading: boolean) => void;
24    error: string;
25}
26
27const initialState = {
28    chat: {
29        id: "",
30        model: "",
31        content: "",
32        chatHistorys: [],
33        docs: [],
34        hasDocHistorys: false,
35    },
36    isDocUploaded: true,
37    isAutoSend: false,
38    isLoading: false,
39    error: "",
40};
41
42export const useChatStore = create<ChatStore>((set) => ({
43    ...initialState,
44    setChat: (chat: Chat) => set({ chat: chat }),
45    setIsDocUploaded: (isDocUploaded: boolean) => set({ isDocUploaded: isDocUploaded }),
46    setIsAutoSend: (isAutoSend: boolean) => set({ isAutoSend: isAutoSend }),
47    setIsLoading: (isLoading: boolean) => set({ isLoading: isLoading }),
48    resetChat: (model?: string) =>
49        set({
50            ...initialState,
51            chat: {
52                id: "",
53                model: model || "",
54                content: "",
55                chatHistorys: [],
56                docs: [],
57                hasDocHistorys: false,
58            },
59        }),
60}));

2.3.5 客户端请求工具封装

/src/lib/utils/request.ts

 1"use client";
 2
 3// ==================== 客户端请求工具封装(提供超时,重试等功能,兼容 后端接口 和 Next BFF 接口) ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6// ========== Components、CSS ========== //
 7// ========== Icon、Type ========== //
 8// ========== Stroe、Constants ========== //
 9import { FETCH_CONFIG } from "@/lib/constants/app";
10// ========== Hooks ========== //
11// ========== Services ========== //
12
13type FetchClientOptions = Omit<RequestInit, "body"> & {
14    body?: Record<string, any>;
15    timeout?: number;
16    isBFF?: boolean;
17    signal?: AbortSignal;
18};
19
20export async function request(
21    path: string, // 接口路径,不带域名、前缀
22    options: FetchClientOptions = {},
23): Promise<any> {
24    const {
25        timeout = FETCH_CONFIG.TIMEOUT,
26        isBFF = false, // 默认走正常后端接口
27        signal = null,
28        body,
29        headers = {},
30        ...restOptions
31    } = options;
32
33    const url = !isBFF ? `${process.env.NEXT_PUBLIC_API_BASE_URL}${FETCH_CONFIG.PREFIX}${path}` : `${FETCH_CONFIG.BFF_PREFIX}${path}`;
34
35    // 封装超时逻辑
36    const controller = new AbortController();
37    const timeoutId = setTimeout(() => controller.abort(), timeout);
38
39    try {
40        const res = await fetch(url, {
41            ...restOptions,
42            headers: {
43                "Content-Type": "application/json",
44                ...headers,
45            },
46            body: body ? JSON.stringify(body) : undefined,
47            signal: signal ? signal : controller.signal,
48        });
49
50        if (!res.ok) throw new Error(`请求失败:${res.status} ${res.statusText}`);
51
52        return res;
53    } catch (error) {
54        throw new Error((error as Error).message);
55    } finally {
56        clearTimeout(timeoutId);
57    }
58}

2.3.6 客户端服务层封装

2.3.6.1 模型列表服务层

/src/services/modelService.ts

 1"use client";
 2
 3// ==================== 模型列表服务层 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { request } from "@/lib/utils/request";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9// ========== Stroe、Constants ========== //
10// ========== Hooks ========== //
11// ========== Services ========== //
12
13export const modelService = {
14    // 获取模型列表
15    async getModel(id: string): Promise<any> {
16        return (await request(`/model?id=${id || ""}`)).json();
17    },
18    // 获取模型
19    async getModelList(): Promise<any> {
20        return (await request(`/model`)).json();
21    },
22};
2.3.6.2 会话列表服务层

/src/services/sessionService.ts

 1"use client";
 2
 3// ==================== 会话列表服务层 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { request } from "@/lib/utils/request";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { Message } from "@/lib/types/app";
10// ========== Stroe、Constants ========== //
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14export const sessionService = {
15    // 新增会话
16    async createSession(data: { title: string }): Promise<any> {
17        return (await request(`/session`, { method: "POST", body: data })).json();
18    },
19    // 删除会话
20    async deleteSession(id: string): Promise<any> {
21        return (await request(`/session?id=${id}`, { method: "DELETE" })).json();
22    },
23    // 更新会话 (重命名)
24    async updateSession(id: string, data: { title: string }): Promise<any> {
25        return (await request(`/session?id=${id}`, { method: "PATCH", body: data })).json();
26    },
27    // 更新会话(新增会话聊天数据)
28    async updateSessionMessage(id: string, data: { message: Message }): Promise<any> {
29        return (await request(`/session?id=${id}`, { method: "PATCH", body: data })).json();
30    },
31    // 获取单个会话
32    async getSession(id: string): Promise<any> {
33        return (await request(`/session?id=${id || ""}`)).json();
34    },
35    // 获取会话列表
36    async getSessionList(): Promise<any> {
37        return (await request(`/session`)).json();
38    },
39};
2.3.6.3 会话请求服务层

/src/services/chatService.ts

 1"use client";
 2
 3// ==================== 会话请求服务层 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import { request } from "@/lib/utils/request";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9import type { Chat } from "@/lib/types/app";
10// ========== Stroe、Constants ========== //
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14export const chatService = {
15    // 发送会话
16    async sendChat(data: { chatParams: Chat; signal: AbortSignal }): Promise<any> {
17        return await request(`/chat`, { method: "POST", body: data });
18    },
19};

2.3.7 SWR Hook 组件

/src/components/hooks/common/useSwrApi.ts

 1"use client";
 2
 3// ==================== SWR Hook 组件 ==================== //
 4
 5// ========== React、Next、Utils ========== //
 6import useSWR from "swr";
 7// ========== Components、CSS ========== //
 8// ========== Icon、Type ========== //
 9// ========== Stroe、Constants ========== //
10// ========== Hooks ========== //
11// ========== Services ========== //
12import { modelService } from "@/services/modelService";
13import { sessionService } from "@/services/sessionService";
14
15// 基础配置
16const SWR_CONFIG = {
17    revalidateOnReconnect: true, // 网络恢复重新请求
18    shouldRetryOnError: true, // 失败自动重试
19    errorRetryCount: 3, // 失败自动重试次数
20    errorRetryInterval: 1000, // 失败重试间隔时间(毫秒)
21    dedupingInterval: 2000, // 2秒内相同请求自动去重(避免重复调用接口)
22    revalidateOnFocus: false, // 切回页面不重新请求
23    refreshInterval: 0, // 关闭自动轮询
24};
25
26// 获取模型列表
27export function useGetModelList() {
28    const { data, error, isLoading } = useSWR(
29        "/model/getList",
30        async () => {
31            return (await modelService.getModelList()).data;
32        },
33        {
34            ...SWR_CONFIG,
35            // revalidateOnFocus: true, // 切回页面重新请求
36            fallbackData: [], // 初始兜底数据(防止首次渲染报错)
37        },
38    );
39    return {
40        fetchedModelList: data,
41        fetchedModelListLoading: isLoading,
42        fetchedModelListError: error,
43    };
44}
45
46// 获取会话列表
47export function useGetSessionList() {
48    const { data, error, isLoading, mutate } = useSWR(
49        "/session/getList",
50        async () => {
51            return (await sessionService.getSessionList()).data;
52        },
53        {
54            ...SWR_CONFIG,
55            revalidateOnFocus: true, // 切回页面重新请求
56            fallbackData: [], // 初始兜底数据(防止首次渲染报错)
57        },
58    );
59    return {
60        fetchedSessionList: data,
61        fetchedSessionListLoading: isLoading,
62        fetchedSessionListError: error,
63        refreshSessionList: mutate,
64    };
65}

2.4 BFF 开发

2.4.1 BFF 全局应用常量

/src/bff/lib/constants/app.ts

 1// ==================== BFF全局应用常量 ==================== //
 2
 3import { SupportedModel } from "@/bff/lib/db/modelConfig";
 4
 5// BFF请求请求默认配置
 6export const FETCH_CONFIG = {
 7    BFF_API_BASE_URL: process.env.NEXT_PUBLIC_BFF_API_BASE_URL || "",
 8    TIMEOUT: 600000, // BFF请求默认超时时间
 9    RETRY_TIMES: 1, // 网络波动重试1次
10    PREFIX: "/api", // 接口请求前缀
11    BFF_PREFIX: "/api", // BFF接口请求前缀
12};
13
14// 大模型密钥映射(环境变量读取,无硬编码)
15export const MODEL_API_KEY: Record<SupportedModel, string> = {
16    "deepseek-chat": process.env.DEEPSEEK_API_KEY || "",
17    "doubao-seed-2-0-pro-260215": process.env.DOUBAO_API_KEY || "",
18    "qwen3.6-plus-2026-04-02": process.env.QIANWEN_API_KEY || "",
19};

2.4.2 BFF 全局 TS 类型

/src/bff/lib/types/app.d.ts

 1// ==================== BBF全局TS类型 ==================== //
 2
 3// 模型类型
 4export interface ModelBFF {
 5    name?: string;
 6    label?: string;
 7    description?: string;
 8    apiKeyKey?: string;
 9    chatApiUrl?: string;
10    parserType?: string;
11    requestOptions?: string;
12    enabled?: number;
13}
14
15// 会话类型
16export interface SessionBFF {
17    id?: string;
18    title: string;
19    createTime?: string;
20    messages?: MessageBFF[];
21}
22
23// 对话消息类型
24export interface MessageBFF {
25    id: string;
26    role: "user" | "assistant";
27    model: string;
28    content: string;
29    createTime: string;
30    docs: DocumentBFF[];
31}
32
33// 文档类型
34export interface DocumentBFF {
35    id: string;
36    fileName: string;
37    fileType: string;
38    sizeText: string;
39    content: string;
40    uploadTime: string;
41}
42
43// 会话请求类型
44export interface ChatBFF {
45    id: string;
46    model: string;
47    content: string;
48    chatHistorys: ChatHistoryBFF[];
49    docs: DocumentBFF[];
50    hasDocHistorys: boolean;
51}
52
53// 对话历史类型
54export interface ChatHistoryBFF {
55    role: "user" | "assistant";
56    content: string;
57}
58
59// 大模型会话请求类型
60export interface ModelChatBFF {
61    model: string;
62    chatApiUrl: string;
63    apiKey: string;
64    content: string;
65}

2.4.3 数据库初始化

2.4.3.1 安装依赖
2.4.3.1.1 PostgreSQL 数据库安装
2.4.3.1.2 数据库代码连接依赖安装
1pnpm install pg
2pnpm install -D  @types/pg
2.4.3.2 模型配置文件

/src/bff/lib/db/modelConfig.ts

 1// ==================== 模型配置文件 ==================== //
 2
 3// ==================== 大模型统一配置中心 ==================== //
 4// 作用:集中管理所有大模型的API地址、密钥映射、解析器类型等配置
 5// 扩展新模型:仅需在 MODEL_CONFIG_MAP 中新增配置项即可
 6
 7// 定义支持的模型类型(强类型约束,避免拼写错误)
 8export type SupportedModel = "deepseek-chat" | "doubao-seed-2-0-pro-260215" | "qwen3.6-plus-2026-04-02";
 9
10/**
11 * 单个模型的核心配置结构
12 * @property apiKeyKey 对应 MODEL_API_KEY 常量中的key(用于获取API密钥)
13 * @property chatApiUrl 模型的聊天API地址
14 * @property parserType 该模型对应的流式解析器类型(与解析器工厂一一对应)
15 * @property requestOptions 额外请求配置(请求头、超时等)
16 */
17export interface ModelConfig {
18    label: string;
19    description: string;
20    apiKeyKey: SupportedModel;
21    chatApiUrl: string;
22    parserType: SupportedModel;
23    requestOptions?: {
24        headers?: Record<string, string>; // 请求头配置
25    };
26    enabled: number;
27}
28
29/**
30 * 所有支持模型的配置映射表
31 * 新增模型时:
32 * 1. 在 SupportedModel 类型中添加模型标识
33 * 2. 在该对象中新增对应模型的配置
34 */
35export const MODEL_CONFIG_MAP: Record<SupportedModel, ModelConfig> = {
36    // DeepSeek 模型配置(原有模型,适配现有逻辑)
37    "deepseek-chat": {
38        label: "DeepSeek",
39        description: "调用 DeepSeek-V3.2(非思考模式)模型",
40        apiKeyKey: "deepseek-chat", // 对应 MODEL_API_KEY["deepseek-chat"]
41        chatApiUrl: "https://api.deepseek.com/v1/chat/completions", // DeepSeek官方流式API地址
42        parserType: "deepseek-chat", // 使用deepseek专属解析器
43        requestOptions: {
44            headers: {}, // 固定请求头
45        },
46        enabled: 1,
47    },
48    // 豆包模型配置(新增,需替换为实际API地址)
49    "doubao-seed-2-0-pro-260215": {
50        label: "字节豆包",
51        description: "调用 Doubao-Seed-2.0-pro(非思考模式)模型",
52        apiKeyKey: "doubao-seed-2-0-pro-260215", // 需在 MODEL_API_KEY 中配置该key的密钥
53        chatApiUrl: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", // 豆包流式API示例地址
54        parserType: "doubao-seed-2-0-pro-260215", // 使用doubao专属解析器
55        requestOptions: {
56            headers: {},
57        },
58        enabled: 1,
59    },
60    // 千问模型配置(新增,阿里云千问官方地址)
61    "qwen3.6-plus-2026-04-02": {
62        label: "通义千问",
63        description: "调用 Qwen3.6-Plus(非思考模式)模型",
64        apiKeyKey: "qwen3.6-plus-2026-04-02", // 需在 MODEL_API_KEY 中配置该key的密钥
65        chatApiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", // 千问流式API地址
66        parserType: "qwen3.6-plus-2026-04-02", // 使用qwen专属解析器
67        requestOptions: {
68            headers: {},
69        },
70        enabled: 1,
71    },
72};
73
74// 获取指定模型的配置(封装成函数,统一异常处理)
75export const getModelConfig = (model: SupportedModel): ModelConfig => {
76    const config = MODEL_CONFIG_MAP[model];
77    if (!config) {
78        throw new Error(`[模型配置异常] 未找到 ${model} 的配置,请检查 modelConfig.ts`);
79    }
80    return config;
81};
2.4.3.3 数据库初始化脚本

在应用入口文件中显式调用,如根布局组件。

1pnpm install pg

/src/bff/lib/db/initDB.ts

  1// ==================== 数据库初始化脚本 ==================== //
  2
  3// ========== React、Next、Utils ========== //
  4import { Pool, PoolClient, QueryResult } from "pg";
  5// ========== Components、CSS ========== //
  6// ========== Icon、Type ========== //
  7// ========== Stroe、Constants ========== //
  8// 模型厂商配置数据
  9import { MODEL_CONFIG_MAP, SupportedModel } from "@/bff/lib/db/modelConfig";
 10// ========== Hooks ========== //
 11// ========== Services ========== //
 12
 13//  ========== 数据库连接池管理 dBPools ========== //
 14// 数据库连接池配置
 15// 连接池大小根据业务QPS调整,生产环境建议10-20;连接字符串优先从环境变量注入,避免硬编码敏感信息
 16const DB_POOL_CONFIGS = {
 17    // 默认库(postgres):仅用于创建业务库/向量库,初始化完成后关闭
 18    default: {
 19        connectionString: `${process.env.POSTGRES_URL}/postgres`,
 20        max: 5,
 21        idleTimeoutMillis: 30000,
 22    },
 23    // 业务库:存储模型配置、会话、消息等
 24    tai_chat_db: {
 25        connectionString: `${process.env.POSTGRES_URL}/tai_chat_db`,
 26        max: 10, // 最大连接数(本地5 / 生产10~20,绝对不超过20)
 27        min: 2, // 最小空闲连接(保持2个活跃,避免反复重连)
 28        idleTimeoutMillis: 30000, // 空闲30秒自动释放(关键!解决ECONNRESET)
 29        connectionTimeoutMillis: 5000, // 连接超时5秒,快速失败
 30        maxUses: 1000, // 单个连接最多使用1000次(防内存泄漏)
 31        keepAlive: true, // 开启心跳保活
 32        keepAliveInitialDelayMillis: 10000, // 10秒发一次心跳
 33        // ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
 34        ssl: false,
 35    },
 36    // 向量库:存储RAG相关向量数据
 37    tai_rag_db: {
 38        connectionString: `${process.env.POSTGRES_URL}/tai_rag_db`,
 39        max: 10,
 40        min: 2,
 41        idleTimeoutMillis: 30000,
 42        maxUses: 1000,
 43        keepAlive: true,
 44        keepAliveInitialDelayMillis: 10000,
 45        // ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
 46        ssl: false,
 47    },
 48};
 49// 连接池对象,后面会导出给业务使用
 50const dBPools = {
 51    default: new Pool(DB_POOL_CONFIGS.default), // 默认库连接池:仅用于创建业务库/向量库,初始化完成后关闭
 52    tai_chat_db: null as Pool | null, // 业务库
 53    tai_rag_db: null as Pool | null, // 向量库连接池
 54};
 55
 56//  ========== 数据库初始化配置 DB_SCHEMA_CONFIGS ========== //
 57// 向量库核心配置
 58// 向量维度需匹配 embedding 模型(all-MiniLM-L6-v2 对应 384),向量索引分片数 建议为 数据量 平方根的 1-3 倍
 59const VECTOR_CONFIG = {
 60    dimension: 384, // 向量维度
 61    ivfflatLists: 100, // 向量索引分片数
 62};
 63// 数据库初始化配置项类型
 64interface DBSchemaConfig {
 65    dbName: keyof typeof DB_POOL_CONFIGS; // 库名,关联连接池配置
 66    createTableSQL: string[]; // 该库需要执行的建表/建索引SQL列表
 67}
 68// 数据库表结构初始化SQL配置
 69// 1. 所有表名/索引名统一前缀,避免冲突
 70// 2. 索引按需创建,避免冗余
 71// 3. 外键约束带级联删除,保证数据一致性
 72const DB_SCHEMA_CONFIGS: DBSchemaConfig[] = [
 73    {
 74        dbName: "tai_chat_db",
 75        createTableSQL: [
 76            `
 77        -- 模型配置表:存储可用的大模型元信息
 78        CREATE TABLE IF NOT EXISTS chat_models (
 79          id TEXT PRIMARY KEY,                 -- 模型唯一ID,主键保证唯一性
 80          name TEXT NOT NULL,                  -- 模型内部名称(与接口对接使用)
 81          label TEXT NOT NULL,                 -- 模型展示名称
 82          description TEXT NOT NULL,           -- 模型功能描述
 83          api_key_key VARCHAR(50) NOT NULL,  -- 密钥映射键名
 84          chat_api_url TEXT NOT NULL,            -- 模型对话接口地址
 85          parser_type VARCHAR(50) NOT NULL,  -- 解析器类型
 86          request_options TEXT NOT NULL,     -- 请求头配置
 87          enabled SMALLINT DEFAULT 1,          -- 启用状态:0=禁用 1=启用
 88          CHECK (enabled IN (0, 1))            -- 约束状态值只能是0/1,避免非法值
 89        );
 90        -- 索引:按启用状态查询模型,提升列表筛选效率
 91        CREATE INDEX IF NOT EXISTS idx_chat_models_enabled 
 92        ON chat_models(enabled);
 93      `,
 94            `
 95        -- 会话表:存储用户的聊天会话信息
 96        CREATE TABLE IF NOT EXISTS chat_sessions (
 97          id TEXT PRIMARY KEY,                 -- 会话唯一ID
 98          title TEXT NOT NULL,                 -- 会话标题
 99          create_time TEXT NOT NULL             -- 创建时间(字符串格式,兼容前端时间处理)
100        );
101        -- 索引:按创建时间倒序查询会话,提升会话列表加载效率
102        CREATE INDEX IF NOT EXISTS idx_chat_sessions_create_time 
103        ON chat_sessions (create_time DESC);
104      `,
105            `
106        -- 对话消息表:存储会话下的单条聊天消息
107        CREATE TABLE IF NOT EXISTS chat_messages (
108          id TEXT PRIMARY KEY,                   -- 消息唯一ID
109          session_id TEXT NOT NULL,              -- 关联会话ID
110          role TEXT NOT NULL,                    -- 角色:user(用户)/assistant(助手)
111          model TEXT NOT NULL,                   -- 生成消息使用的模型名称
112          content TEXT NOT NULL,                 -- 消息内容
113          create_time TEXT NOT NULL,              -- 消息创建时间
114          docs TEXT NOT NULL,                    -- 检索到的文档列表(JSON字符串)
115          CHECK (role IN ('user', 'assistant')), -- 约束角色值,避免非法角色
116          -- 外键约束:关联会话表,删除会话时自动删除关联消息(级联删除)
117          FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
118        );
119        -- 复合索引:按会话ID+创建时间查询消息,提升会话消息加载效率
120        CREATE INDEX IF NOT EXISTS idx_chat_messages_session_time 
121        ON chat_messages (session_id, create_time ASC);
122      `,
123        ],
124    },
125    {
126        dbName: "tai_rag_db",
127        createTableSQL: [
128            `
129        -- 启用PostgreSQL向量扩展(需数据库用户有创建扩展权限)
130        CREATE EXTENSION IF NOT EXISTS vector;
131        
132        -- 向量表:存储RAG检索所需的文本向量数据
133        CREATE TABLE IF NOT EXISTS rag_vectors (
134          id UUID PRIMARY KEY,                  -- 向量唯一ID(UUID保证分布式环境唯一性)
135          vector vector(${VECTOR_CONFIG.dimension}) NOT NULL, -- 向量数据,维度匹配embedding模型
136          metadata JSONB NOT NULL,              -- 向量元数据(如sessionId、文档ID等,JSONB支持灵活查询)
137          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 向量创建时间,默认当前时间
138        );
139
140        -- 索引:按会话ID查询向量,提升会话维度的检索效率
141        CREATE INDEX IF NOT EXISTS idx_rag_vectors_session 
142        ON rag_vectors ((metadata->>'sessionId'));
143        
144        -- 向量索引:基于IVFFLAT算法的余弦相似度索引,提升向量检索性能
145        -- lists参数:建议为数据量平方根的1-3倍,生产环境需根据数据量调优
146        CREATE INDEX IF NOT EXISTS idx_rag_vectors_vector 
147        ON rag_vectors USING ivfflat (vector vector_cosine_ops) WITH (lists = ${VECTOR_CONFIG.ivfflatLists});
148      `,
149        ],
150    },
151];
152
153//  ========== 建表/建索引SQL执行方法 executeSQL ========== //
154async function executeSQL(
155    client: PoolClient, // client 数据库连接客户端(从连接池获取)
156    sql: string, // 待执行的SQL语句
157    desc: string, // SQL操作描述(用于日志溯源)
158    params: any[] = [], // SQL参数列表,默认空数组
159): Promise<QueryResult> {
160    try {
161        return await client.query(sql, params);
162    } catch (error) {
163        throw new Error(`建表/建索引SQL执行失败: ${(error as Error).message}`);
164    }
165}
166
167//  ========== 数据库初始化方法 initDBSchema ========== //
168async function initDBSchema(): Promise<void> {
169    let defaultClient: PoolClient | null = null;
170
171    try {
172        // 获取默认库连接(postgres)
173        defaultClient = await dBPools.default.connect();
174
175        // 串行遍历数据库配置
176        for (const schemaConfig of DB_SCHEMA_CONFIGS) {
177            const { dbName, createTableSQL } = schemaConfig;
178
179            // 检查数据库是否存在
180            const existsResult = await defaultClient.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [dbName]);
181
182            // 不存在则创建(生产环境建议由运维提前创建,此处仅用于开发/测试环境)
183            if (existsResult.rows.length === 0) {
184                await defaultClient.query(`CREATE DATABASE ${dbName}`);
185            }
186
187            // 初始化当前库的连接池并连接
188            dBPools[dbName] = new Pool(DB_POOL_CONFIGS[dbName]);
189            const currentClient = await dBPools[dbName]!.connect();
190
191            // 串行执行建表/建索引SQL
192            for (const sql of createTableSQL) {
193                await executeSQL(currentClient, sql, `[${dbName}] 建表/建索引`);
194            }
195            // 释放当前库客户端(归还到连接池)
196            currentClient.release();
197            console.log(`${dbName} 数据库表结构初始化完成 !`);
198        }
199        console.log("表结构初始化全部完成 ! \n");
200    } catch (error) {
201        throw new Error(`表结构初始化失败: ${(error as Error).message}`);
202    } finally {
203        // 释放默认库客户端 + 关闭默认池(初始化完成后不再使用)
204        if (defaultClient) {
205            defaultClient.release();
206        }
207        await dBPools.default.end();
208    }
209}
210
211//  ========== 初始化基础数据方法 initDBBaseData ========== //
212async function initDBBaseData(): Promise<void> {
213    // 校验业务库连接池是否初始化
214    if (!dBPools.tai_chat_db) {
215        throw new Error("tai_chat_db 数据库连接池未初始化,无法写入基础数据 !");
216    }
217    let chatClient: PoolClient | null = null;
218    try {
219        // 获取业务库连接
220        chatClient = await dBPools.tai_chat_db.connect();
221
222        // ===== 模型基础数据处理 =====
223        const models = Object.entries(MODEL_CONFIG_MAP).map(([model, config]) => ({
224            id: model as SupportedModel,
225            name: model as SupportedModel,
226            ...config, // 展开原有所有配置
227        }));
228        const total = models.length;
229        // 无数据则跳过
230        if (total === 0) {
231            console.log("模型基础数据为空,跳过写入 !");
232            return;
233        }
234        // 构造 PostgreSQL 的插入语句
235        // 动态生成占位符(PostgreSQL 使用 $1/$2... 而非 ?)
236        const columns = ["id", "name", "label", "description", "api_key_key", "chat_api_url", "parser_type", "request_options", "enabled"];
237        const placeholders = models.map((_, idx) => `(${columns.map((_, colIdx) => `$${idx * columns.length + colIdx + 1}`).join(", ")})`).join(", ");
238        // 扁平化参数列表,匹配占位符顺序
239        const args = models.flatMap((item) => [item.id, item.name, item.label, item.description, item.apiKeyKey, item.chatApiUrl, item.parserType, JSON.stringify(item.requestOptions), item.enabled]);
240        // 执行插入(主键冲突时忽略,避免重复写入)
241        const insertSQL = `
242            INSERT INTO chat_models (${columns.join(", ")})
243            VALUES ${placeholders}
244            ON CONFLICT (id) DO NOTHING; -- PostgreSQL 兼容的去重插入
245        `;
246        await executeSQL(chatClient, insertSQL, "[tai_chat_db] 模型表基础数据写入", args);
247        console.log(`成功写入 ${total} 条模型基础数据 !`);
248
249        // ===== 其他基础数据处理... =====
250
251        console.log("基础数据初始化全部完成 ! \n");
252    } catch (error) {
253        throw new Error(`基础数据初始化失败:${(error as Error).message}`);
254    } finally {
255        if (chatClient) {
256            chatClient.release();
257        }
258    }
259}
260
261//  ========== 进程生命周期终止管理(避免资源泄漏) ========== //
262process.on("SIGTERM", async () => {
263    console.log("[进程终止] 开始关闭数据库连接池...");
264    // 关闭所有业务库连接池
265    await Promise.all(
266        Object.values(dBPools)
267            .filter(Boolean)
268            .map((pool) => pool!.end()),
269    );
270    console.log("[进程终止] 所有连接池已关闭,退出进程");
271    // 完成了所有任务,退出
272    process.exit(0);
273});
274
275//  ========== 数据库初始化统一入口 initDatabase ========== //
276export async function initDatabase(): Promise<typeof dBPools | undefined> {
277    // 构建阶段直接跳过,不执行任何数据库操作
278    if (process.env.SKIP_DB_INIT) {
279        console.log("[构建模式] 跳过数据库初始化");
280        return;
281    }
282
283    try {
284        console.log("[数据库初始化] 执行表结构初始化 ...");
285        await initDBSchema(); // 先初始化表结构
286        console.log("[数据库初始化] 执行基础数据初始化 ...");
287        await initDBBaseData(); // 再写入基础数据
288        console.log("[数据库初始化] 初始化全部完成 !");
289        return dBPools;
290    } catch (error) {
291        console.error("[数据库初始化失败] 数据库初始化异常,终止应用", error);
292        process.exit(1); // 初始化失败终止应用,避免异常运行
293    }
294}
295
296// ========== 初始化执行 ========== //
297// 在 BFF 任意一个入口文件中显式调用 initDatabase(),如 `src\app\api\model\route.ts`
298// 因为是全局模块,只会执行一次
299initDatabase();
300
301// ========== 导出连接池 ========== //
302export default dBPools;

2.4.4 BFF 请求工具封装

/src/bff/lib/utils/request.ts

 1// ==================== BFF 请求工具封装(提供超时,重试等功能) ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4// ========== Components、CSS ========== //
 5// ========== Icon、Type ========== //
 6// ========== Stroe、Constants ========== //
 7import { FETCH_CONFIG } from "@/bff/lib/constants/app";
 8// ========== Hooks ========== //
 9// ========== Services ========== //
10
11type FetchBFFOptions = Omit<RequestInit, "body"> & {
12    body?: Record<string, any>;
13    timeout?: number;
14    retryTimes?: number;
15};
16
17export async function request(
18    url: string, // 接口路径,需要完整路径
19    options: FetchBFFOptions = {},
20): Promise<any> {
21    const { timeout = FETCH_CONFIG.TIMEOUT, retryTimes = FETCH_CONFIG.RETRY_TIMES, body, headers = {}, ...restOptions } = options;
22
23    // 封装超时逻辑
24    const controller = new AbortController();
25    const timeoutId = setTimeout(() => controller.abort(), timeout);
26
27    try {
28        const res = await fetch(url, {
29            ...restOptions,
30            headers: {
31                "Content-Type": "application/json",
32                ...headers,
33            },
34            body: body ? JSON.stringify(body) : undefined,
35            signal: controller.signal,
36        });
37
38        // 响应校验
39        if (!res.ok) {
40            const errorText = await res.text();
41            throw new Error(`[模型请求失败] ${body?.model} API返回异常:${res.status} ${errorText}`);
42        }
43
44        return res;
45    } catch (error) {
46        // 重试逻辑(仅临时网络错误重试)
47        if (retryTimes > 0 && (error as Error).name === "AbortError") {
48            return request(url, { ...options, retryTimes: retryTimes - 1 });
49        }
50        throw new Error(`BFF 第三方服务异常: ${(error as Error).message}`);
51    } finally {
52        clearTimeout(timeoutId);
53    }
54}

2.4.5 BFF 接口异常处理工具封装

/src/bff/lib/utils/errorHandler.ts

 1// ==================== BFF 接口异常处理工具封装 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4import { NextResponse } from "next/server";
 5// ========== Components、CSS ========== //
 6// ========== Icon、Type ========== //
 7// ========== Stroe、Constants ========== //
 8// ========== Hooks ========== //
 9// ========== Services ========== //
10
11export function withErrorHandler(fn: (...args: any[]) => Promise<NextResponse>): typeof fn {
12    {
13        return async (...args: any[]) => {
14            try {
15                return await fn(...args);
16            } catch (error) {
17                return NextResponse.json({ code: 500, message: (error as Error).message, error: (error as Error).message }, { status: 500 });
18            }
19        };
20    }
21}

2.4.6 API 开发

2.4.6.1 BFF 模型列表接口

/src/app/api/model/route.ts

 1// ==================== BFF 模型列表接口 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4// 初始化BFF数据库,只在这里引用一次即可
 5import "@/bff/lib/db/initDB";
 6// 预下载模型,只在这里引用一次即可
 7import "@/bff/lib/utils/rag-tool";
 8import { NextResponse } from "next/server";
 9import { withErrorHandler } from "@/bff/lib/utils/error-handler";
10// 获取数据库连接池对象
11import dBPools from "@/bff/lib/db/initDB";
12// ========== Components、CSS ========== //
13// ========== Icon、Type ========== //
14import type { PoolClient } from "pg";
15import type { Model } from "@/lib/types/app";
16// ========== Stroe、Constants ========== //
17// ========== Hooks ========== //
18// ========== Services ========== //
19
20// GET 获取模型列表,支持 id 查询
21export const GET = withErrorHandler(async (request: Request): Promise<NextResponse> => {
22    const { searchParams } = new URL(request.url);
23    const id = searchParams.get("id");
24    let data: any;
25    let result: Model[] | Model = [];
26
27    // 取业务库连接池
28    const chatPool = dBPools.tai_chat_db;
29    // 取向量库连接池
30    // const ragPool = dBPools.tai_rag_db;
31    // 校验业务库连接池是否初始化
32    if (!chatPool) {
33        throw new Error("tai_chat_db 数据库连接池未初始化 !");
34    }
35    // 声明连接变量
36    let dbClient: PoolClient | null = null;
37
38    try {
39        // 获取数据库连接
40        dbClient = await chatPool.connect();
41
42        if (id) {
43            // 检查模型是否存在
44            const { rowCount } = await dbClient.query("SELECT id FROM chat_models WHERE id = $1", [id]);
45            if (rowCount === 0) {
46                return NextResponse.json({ code: 404, message: "模型不存在" }, { status: 404 });
47            }
48
49            data = await dbClient.query(`SELECT id, name, label, description, api_key_key, chat_api_url, parser_type, request_options, enabled FROM chat_models WHERE enabled = 1 AND id = $1`, [id]);
50            result = {
51                apiKeyKey: data.rows[0]?.api_key_key || "",
52                chatApiUrl: data.rows[0]?.chat_api_url || "",
53                parserType: data.rows[0]?.parser_type || "",
54                requestOptions: JSON.parse(data.rows[0]?.request_options || JSON.stringify({})),
55            };
56        } else {
57            data = await dbClient.query(`SELECT id, name, label, description, chat_api_url, enabled FROM chat_models WHERE enabled = 1`);
58            result = data.rows
59                ? data.rows.map((model: any) => {
60                      return {
61                          name: model.name,
62                          label: model.label,
63                          description: model.description,
64                      };
65                  })
66                : [];
67        }
68        return NextResponse.json({ code: 200, data: result, message: "获取模型列表成功" });
69    } catch (error) {
70        throw new Error(`获取模型列表失败: ${(error as Error).message}`);
71    } finally {
72        // 关键:无论成功/失败,都释放连接
73        if (dbClient) {
74            dbClient.release();
75        }
76    }
77});
2.4.6.2 BFF 会话列表接口

/src/app/api/session/route.ts

  1// ==================== BFF 会话列表接口 ==================== //
  2
  3// ========== React、Next、Utils ========== //
  4import { NextResponse } from "next/server";
  5import { withErrorHandler } from "@/bff/lib/utils/error-handler";
  6import { v4 as uuidv4 } from "uuid";
  7// 获取数据库连接池对象
  8import dBPools from "@/bff/lib/db/initDB";
  9import { parseJson } from "@/bff/lib/utils/common-tools";
 10// ========== Components、CSS ========== //
 11// ========== Icon、Type ========== //
 12import type { PoolClient } from "pg";
 13// ========== Stroe、Constants ========== //
 14// ========== Hooks ========== //
 15// ========== Services ========== //
 16
 17//  POST - 新增会话
 18export const POST = withErrorHandler(async (request: Request): Promise<NextResponse> => {
 19    const title: string = (await request.json()).title;
 20    let result: string = "";
 21    let data: any;
 22
 23    // 取业务库连接池
 24    const chatPool = dBPools.tai_chat_db;
 25    // 校验业务库连接池是否初始化
 26    if (!chatPool) {
 27        throw new Error("tai_chat_db 数据库连接池未初始化 !");
 28    }
 29    // 声明连接变量
 30    let dbClient: PoolClient | null = null;
 31
 32    try {
 33        // 获取数据库连接
 34        dbClient = await chatPool.connect();
 35
 36        // 校验
 37        if (!title) {
 38            return NextResponse.json({ code: 400, message: "会话数据标题不能为空" }, { status: 400 });
 39        }
 40
 41        // 插入 chat_sessions 表
 42        data = await dbClient.query(`INSERT INTO chat_sessions (id, title, create_time) VALUES ($1, $2, $3) RETURNING id`, [uuidv4(), title, new Date().toISOString()]);
 43
 44        result = data.rows[0].id;
 45        return NextResponse.json({ code: 200, data: { id: result }, message: "新增会话成功" });
 46    } catch (error) {
 47        throw new Error(`新增会话失败: ${(error as Error).message}`);
 48    } finally {
 49        // 关键:无论成功/失败,都释放连接
 50        if (dbClient) {
 51            dbClient.release();
 52        }
 53    }
 54});
 55
 56// DELETE - 删除会话
 57export const DELETE = withErrorHandler(async (request: Request): Promise<NextResponse> => {
 58    const { searchParams } = new URL(request.url);
 59    const id = searchParams.get("id");
 60
 61    // 取业务库连接池
 62    const chatPool = dBPools.tai_chat_db;
 63    // 校验业务库连接池是否初始化
 64    if (!chatPool) {
 65        throw new Error("tai_chat_db 数据库连接池未初始化 !");
 66    }
 67    // 声明连接变量
 68    let dbClient: PoolClient | null = null;
 69
 70    try {
 71        // 获取数据库连接
 72        dbClient = await chatPool.connect();
 73
 74        // 前置校验
 75        if (!id) {
 76            return NextResponse.json({ code: 400, message: "会话ID不能为空" }, { status: 400 });
 77        }
 78        const currentSessionData = await dbClient.query("SELECT id FROM chat_sessions WHERE id = $1", [id]);
 79        if (currentSessionData.rowCount === 0) {
 80            return NextResponse.json({ code: 404, message: "会话不存在" }, { status: 404 });
 81        }
 82
 83        // 删除会话(messages表会通过外键级联删除)
 84        await dbClient.query("DELETE FROM chat_sessions WHERE id = $1", [id]);
 85
 86        return NextResponse.json({ code: 200, message: "删除会话成功" });
 87    } catch (error) {
 88        throw new Error(`删除会话失败: ${(error as Error).message}`);
 89    } finally {
 90        // 关键:无论成功/失败,都释放连接
 91        if (dbClient) {
 92            dbClient.release();
 93        }
 94    }
 95});
 96
 97// PATCH - 修改会话,包括重命名、新增聊天数据
 98export const PATCH = withErrorHandler(async (request: Request): Promise<NextResponse> => {
 99    const { searchParams } = new URL(request.url);
100    const id = searchParams.get("id");
101    const { title, message } = await request.json();
102
103    // 取业务库连接池
104    const chatPool = dBPools.tai_chat_db;
105    // 校验业务库连接池是否初始化
106    if (!chatPool) {
107        throw new Error("tai_chat_db 数据库连接池未初始化 !");
108    }
109    // 声明连接变量
110    let dbClient: PoolClient | null = null;
111
112    try {
113        // 获取数据库连接
114        dbClient = await chatPool.connect();
115
116        // 前置校验
117        if (!id) {
118            return NextResponse.json({ code: 400, message: "会话ID不能为空" }, { status: 400 });
119        }
120        const currentSessionData = await dbClient.query("SELECT id FROM chat_sessions WHERE id = $1", [id]);
121        if (currentSessionData.rowCount === 0) {
122            await dbClient.query("ROLLBACK");
123            return NextResponse.json({ code: 404, message: "会话不存在" }, { status: 404 });
124        }
125
126        await dbClient.query("BEGIN");
127
128        if (title) {
129            // 更新会话(重命名)
130            await dbClient.query(`UPDATE chat_sessions SET title = $1 WHERE id = $2`, [title, id]);
131        } else if (message) {
132            console.log("插入", message.id, id, message.role, message.model, message.content, message.createTime, message.docs?.length ? JSON.stringify(message.docs) : "");
133            // 更新会话(新增会话聊天数据)
134
135            // 批量插入 chat_messages 表
136            const placeholders = "$1, $2, $3, $4, $5, $6, $7";
137            const args = [message.id, id, message.role, message.model, message.content, message.createTime, message.docs?.length ? JSON.stringify(message.docs) : ""];
138            await dbClient.query(`INSERT INTO chat_messages (id, session_id, role, model, content, create_time, docs) VALUES (${placeholders})`, args);
139        }
140
141        await dbClient.query("COMMIT");
142
143        return NextResponse.json({ code: 200, message: "更新会话成功" });
144    } catch (error) {
145        // 出错回滚
146        if (dbClient) await dbClient.query("ROLLBACK");
147        throw new Error(`更新会话失败: ${(error as Error).message}`);
148    } finally {
149        // 关键:无论成功/失败,都释放连接
150        if (dbClient) {
151            dbClient.release();
152        }
153    }
154});
155
156//  GET - 获取单个会话详情 或 会话列表,支持 id 查询
157export const GET = withErrorHandler(async (request: Request): Promise<NextResponse> => {
158    const { searchParams } = new URL(request.url);
159    const id = searchParams.get("id");
160    let result: any[] | any = [];
161    const messages: any[] = [];
162    let data: any;
163
164    // 取业务库连接池
165    const chatPool = dBPools.tai_chat_db;
166    // 校验业务库连接池是否初始化
167    if (!chatPool) {
168        throw new Error("tai_chat_db 数据库连接池未初始化 !");
169    }
170    // 声明连接变量
171    let dbClient: PoolClient | null = null;
172
173    try {
174        // 获取数据库连接
175        dbClient = await chatPool.connect();
176
177        if (id) {
178            // 获取单个会话详情
179
180            // 前置校验
181            const { rowCount } = await dbClient.query("SELECT id FROM chat_sessions WHERE id = $1", [id]);
182            if (rowCount === 0) {
183                return NextResponse.json({ code: 404, message: "会话不存在" }, { status: 404 });
184            }
185
186            data = await dbClient.query(
187                `
188                SELECT 
189                    s.id,
190                    s.title,
191                    s.create_time,
192                    COALESCE(
193                    json_agg(
194                        json_build_object(
195                        'id', m.id,
196                        'role', m.role,
197                        'model', m.model,
198                        'content', m.content,
199                        'create_time', m.create_time,
200                        'docs', m.docs
201                        )
202                    ) FILTER (WHERE m.id IS NOT NULL),
203                    '[]'::json
204                    ) AS messages
205                FROM chat_sessions s
206                LEFT JOIN chat_messages m ON s.id = m.session_id
207                WHERE s.id = $1
208                GROUP BY s.id, s.title, s.create_time
209                `,
210                [id],
211            );
212
213            const row = data.rows[0];
214
215            result = {
216                id: row.id,
217                title: row.title,
218                createTime: row.create_time,
219                messages: row.messages || [],
220            };
221
222            result.messages = result.messages.map((msg: { docs: string }) => ({
223                ...msg,
224                // 解析 docs(兼容空值/错误格式)
225                docs: parseJson(msg.docs),
226            }));
227        } else {
228            // 获取会话列表
229            data = await dbClient.query(`SELECT id, title, create_time FROM chat_sessions ORDER BY create_time DESC`);
230            result = data.rows
231                ? data.rows.map((session: any) => {
232                      return {
233                          id: session.id,
234                          title: session.title,
235                          createTime: session.create_time,
236                          messages,
237                      };
238                  })
239                : [];
240        }
241
242        return NextResponse.json({ code: 200, data: result, message: "获取会话成功" });
243    } catch (error) {
244        throw new Error(`获取会话失败: ${(error as Error).message}`);
245    } finally {
246        // 关键:无论成功/失败,都释放连接
247        if (dbClient) {
248            dbClient.release();
249        }
250    }
251});
2.4.6.3 BFF 会话聊天接口

/src/app/api/chat/route.ts

  1// ==================== BFF 会话聊天接口 ==================== //
  2
  3// ========== React、Next、Utils ========== //
  4import { NextResponse } from "next/server";
  5import { withErrorHandler } from "@/bff/lib/utils/error-handler";
  6import nextRag from "@/bff/lib/utils/rag-tool";
  7import { v4 as uuidv4 } from "uuid";
  8// ========== Components、CSS ========== //
  9// ========== Icon、Type ========== //
 10import type { ChatBFF } from "@/bff/lib/types/app";
 11// ========== Stroe、Constants ========== //
 12import { MODEL_API_KEY } from "@/bff/lib/constants/app";
 13// ========== Hooks ========== //
 14// ========== Services ========== //
 15import { modelService } from "@/bff/services/modelService";
 16import { sessionService } from "@/bff/services/sessionService";
 17import { chatService } from "@/bff/services/chatService";
 18import { MODEL_CONFIG_MAP, SupportedModel } from "@/bff/lib/db/modelConfig";
 19import { getStreamParser } from "@/bff/lib/utils/modelStreamParser";
 20
 21// 发送会话
 22export const POST = withErrorHandler(async (request: Request): Promise<NextResponse> => {
 23    const chatParams: ChatBFF = (await request.json()).chatParams;
 24
 25    // 类型校验:确保模型在支持列表中(避免非法模型请求)
 26    const models = Object.entries(MODEL_CONFIG_MAP).map(([model]) => model);
 27    if (!models.includes(chatParams.model)) {
 28        throw new Error(`[参数异常] 不支持的模型类型:${chatParams.model}`);
 29    }
 30
 31    const model = chatParams.model as SupportedModel;
 32
 33    // 调用 RAG 获取处理后的 prompt
 34    const { prompt } = await nextRag.smartChat({
 35        sessionId: chatParams.id,
 36        userQuery: chatParams.content,
 37        docs: chatParams.docs,
 38        chatHistorys: chatParams.chatHistorys,
 39        hasDocHistorys: chatParams.hasDocHistorys,
 40    });
 41
 42    // 从统一配置中心获取模型配置
 43    const modelConfig = (await modelService.getModelConfig(model)).data;
 44    // 根据配置获取API密钥(兼容原有MODEL_API_KEY常量)
 45    const apiKey = MODEL_API_KEY[modelConfig.apiKeyKey as SupportedModel];
 46
 47    if (!apiKey) {
 48        throw new Error(`[配置异常] 未配置 ${model} 的API密钥,请检查 MODEL_API_KEY 常量`);
 49    }
 50
 51    const modelResponse = await chatService.sendChat({
 52        model,
 53        chatApiUrl: modelConfig.chatApiUrl,
 54        apiKey,
 55        content: prompt,
 56        requestOptions: modelConfig.requestOptions,
 57    });
 58    // 统一流式响应处理
 59    const stream = new ReadableStream({
 60        async start(controller) {
 61            // 防御性校验:确保响应体存在
 62            if (!modelResponse.body) {
 63                console.error(`[流异常] ${model} 返回空响应体`);
 64                controller.close();
 65                return;
 66            }
 67
 68            const reader = modelResponse.body.getReader(); // 获取流读取器
 69            const decoder = new TextDecoder("utf-8"); // 复用解码器(避免重复创建)
 70            let fullText: string = ""; // 拼接完整回复内容(用于持久化)
 71            const parser = getStreamParser(model); // 获取当前模型的解析器
 72
 73            try {
 74                // 通用流读取循环(所有模型共用此逻辑)
 75                while (true) {
 76                    const { done: readerDone, value } = await reader.read();
 77                    // 流读取完成,退出循环
 78                    if (readerDone) break;
 79
 80                    // 调用解析器处理二进制chunk,获取标准化结果
 81                    const parseResults = parser(value, decoder);
 82                    // 遍历解析结果,推送给前端
 83                    for (const result of parseResults) {
 84                        // 跳过结束标识
 85                        if (result.done) continue;
 86                        // 有内容则推送给前端
 87                        if (result.content) {
 88                            fullText += result.content;
 89                            controller.enqueue(result.content); // 仅推送纯文本给前端
 90                        }
 91                    }
 92                }
 93            } catch (error) {
 94                const errorMsg = `${model} 模型服务异常,请稍后重试`;
 95                fullText = errorMsg;
 96                controller.enqueue(errorMsg);
 97                console.error(`[流处理异常] ${model} 流读取失败:`, error);
 98            } finally {
 99                // 关闭流(必须执行,否则前端会一直等待)
100                controller.close();
101
102                // ========== 原有逻辑:持久化助手回复 ========== //
103                await sessionService.updateSessionMessage(chatParams.id, {
104                    message: {
105                        id: uuidv4(),
106                        role: "assistant",
107                        model: chatParams.model,
108                        content: fullText,
109                        createTime: new Date().toISOString(),
110                        docs: [],
111                    },
112                });
113            }
114        },
115    });
116    // 返回标准 SSE 流
117    return new NextResponse(stream, {
118        headers: {
119            "Content-Type": "text/event-stream",
120            "Cache-Control": "no-cache",
121            Connection: "keep-alive",
122            "X-Accel-Buffering": "no", // 禁用 Nginx 缓冲
123        },
124    });
125});

2.4.7 BFF 服务层封装

2.4.7.1 BFF 模型列表服务层

给 BFF 提供搜索指定模型配置信息的能力。

/src/bff/services/modelService.ts

 1// ==================== BFF 模型列表服务层 ==================== //
 2// 给 BFF 提供搜索指定模型配置信息的能力
 3
 4// ========== React、Next、Utils ========== //
 5import { request } from "@/bff/lib/utils/request";
 6// ========== Components、CSS ========== //
 7// ========== Icon、Type ========== //
 8// ========== Stroe、Constants ========== //
 9import { FETCH_CONFIG } from "@/bff/lib/constants/app";
10import { SupportedModel } from "@/bff/lib/db/modelConfig";
11// ========== Hooks ========== //
12// ========== Services ========== //
13
14export const modelService = {
15    // 新增:获取模型完整配置(推荐新逻辑使用)
16    async getModelConfig(model: SupportedModel): Promise<any> {
17        return (await request(`${FETCH_CONFIG.BFF_API_BASE_URL}${FETCH_CONFIG.BFF_PREFIX}/model?id=${model}`)).json();
18    },
19};
2.4.7.2 BFF 会话列表服务层

/src/bff/services/sessionService.ts

 1// ==================== BFF 会话列表服务层 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4import { request } from "@/bff/lib/utils/request";
 5// ========== Components、CSS ========== //
 6// ========== Icon、Type ========== //
 7import { MessageBFF } from "@/bff/lib/types/app";
 8// ========== Stroe、Constants ========== //
 9import { FETCH_CONFIG } from "@/bff/lib/constants/app";
10// ========== Hooks ========== //
11// ========== Services ========== //
12
13export const sessionService = {
14    // 更新会话(新增会话聊天数据)
15    async updateSessionMessage(id: string, data: { message: MessageBFF }): Promise<any> {
16        return (await request(`${FETCH_CONFIG.BFF_API_BASE_URL}${FETCH_CONFIG.BFF_PREFIX}/session?id=${id}`, { method: "PATCH", body: data })).json();
17    },
18};
2.4.7.3 BFF 会话请求服务层

给 BFF 提供通过前端的会查请求信息、结合搜索的模型配置信息,组装参数去访问它对应模型的会话API,获取会话结果后返回给前端。

/src/bff/services/chatService.ts

 1// ==================== BFF 会话请求服务层  ==================== //
 2// 给 BFF 提供通过前端的会查请求信息、结合搜索的模型配置信息,组装参数去访问它对应模型的会话API,获取会话结果后返回给前端
 3
 4// ========== React、Next、Utils ========== //
 5import { request } from "@/bff/lib/utils/request";
 6// ========== Components、CSS ========== //
 7// ========== Icon、Type ========== //
 8import { SupportedModel } from "@/bff/lib/db/modelConfig";
 9// ========== Stroe、Constants ========== //
10// ========== Hooks ========== //
11// ========== Services ========== //
12
13interface SendChatParams {
14    model: SupportedModel;
15    chatApiUrl: string;
16    apiKey: string;
17    content: string;
18    requestOptions?: {
19        headers?: Record<string, string>;
20    };
21}
22
23export const chatService = {
24    // 发送会话
25    async sendChat({ model, chatApiUrl, apiKey, content, requestOptions }: SendChatParams): Promise<any> {
26        // 统一请求头(基础头 + 模型专属头)
27        const headers = {
28            Authorization: `Bearer ${apiKey}`, // 统一的鉴权格式(大部分模型遵循)
29            ...requestOptions?.headers, // 模型专属头覆盖基础头
30        };
31        // 不同模型的请求参数适配(核心差异点)
32        let requestBody: Record<string, any> = {};
33        switch (model) {
34            case "deepseek-chat":
35                // DeepSeek参数格式(OpenAI兼容)
36                requestBody = {
37                    model: "deepseek-chat", // DeepSeek模型标识
38                    messages: [{ role: "user", content }], // 对话消息
39                    stream: true, // 开启流式响应
40                };
41                break;
42
43            case "doubao-seed-2-0-pro-260215":
44                // 豆包参数格式(示例,需根据实际文档调整)
45                requestBody = {
46                    model: "doubao-seed-2-0-pro-260215",
47                    messages: [
48                        {
49                            content: [
50                                {
51                                    text: content,
52                                    type: "text",
53                                },
54                            ],
55                            role: "user",
56                        },
57                    ],
58                    reasoning_effort: "medium", // 思考程度, minimal、low、medium、high 四种模式,其中 minimal 为不思考
59                    stream: true, // 开启流式响应
60                };
61                break;
62
63            case "qwen3.6-plus-2026-04-02":
64                // 千问参数格式(阿里云DashScope标准)
65                requestBody = {
66                    model: "qwen3.6-plus-2026-04-02", // 千问模型标识
67                    messages: [{ role: "user", content }], // 对话消息
68                    stream: true, // 开启流式响应
69                };
70                break;
71        }
72
73        // 发送POST请求(流式响应)
74        const response = await request(chatApiUrl, {
75            method: "POST",
76            headers,
77            body: requestBody,
78        });
79
80        return response;
81    },
82};
2.4.7.3.1 RAG 工具封装
1pnpm install @langchain/core @langchain/textsplitters @xenova/transformers

/src/bff/lib/utils/ragTool.ts

  1// ==================== RAG 工具封装 ==================== //
  2
  3// ========== React、Next、Utils ========== //
  4import { Embeddings } from "@langchain/core/embeddings";
  5import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
  6import { pipeline, env } from "@xenova/transformers";
  7import { PoolClient } from "pg";
  8import { v4 as uuidv4 } from "uuid";
  9// 获取数据库连接池对象
 10import dBPools from "@/bff/lib/db/initDB";
 11// ========== Components、CSS ========== //
 12// ========== Icon、Type ========== //
 13import type { EmbeddingsParams } from "@langchain/core/embeddings";
 14// ========== Stroe、Constants ========== //
 15// ========== Hooks ========== //
 16// ========== Services ========== //
 17
 18//  ========== 全局常量定义 ========== //
 19/** 文本分块大小 */
 20const CHUNK_SIZE = 800;
 21/** 文本分块重叠长度 */
 22const CHUNK_OVERLAP = 80;
 23/** 默认检索返回条数 */
 24const DEFAULT_TOP_K = 3;
 25/** 特征提取模型 */
 26const EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
 27/** 零样本分类模型 */
 28const CLASSIFY_MODEL = "Xenova/distilbert-base-uncased-mnli";
 29/** 检索关键词匹配库 */
 30const RETRIEVAL_KEYWORDS = ["历史", "上下文", "之前", "刚才", "会话", "文档", "记录", "总结", "回忆", "上一句"];
 31
 32//  ========== 类型接口定义 ========== //
 33
 34// 会话文本存储参数
 35export interface StoreSessionParams {
 36    sessionId: string;
 37    docs: Array<{ content?: string }>;
 38}
 39
 40// 智能检索分类参数
 41export interface ClassifyParams {
 42    userQuery: string;
 43    chatHistorys?: Array<{ role: string; content: string }>;
 44}
 45
 46// 向量检索参数
 47export interface RetrieveParams {
 48    sessionId: string;
 49    userQuery: string;
 50    topK?: number;
 51}
 52
 53// 智能聊天核心参数
 54export interface SmartChatParams {
 55    sessionId: string;
 56    userQuery: string;
 57    docs?: Array<{ content?: string }> | null;
 58    chatHistorys?: Array<{ role: string; content: string }>;
 59    hasDocHistorys?: any;
 60}
 61
 62// 聊天接口返回结果
 63export interface ChatResult {
 64    sessionId: string;
 65    userQuery: string;
 66    needRetrieval: boolean;
 67    prompt: string;
 68}
 69
 70//  ========== 本地嵌入模型实现 ========== //
 71// 继承 LangChain Embeddings 基类,适配向量数据库交互
 72// 基于 ONNX 本地模型 的文本向量生成类
 73class LocalOnnxEmbeddings extends Embeddings {
 74    private readonly model: any;
 75
 76    constructor(model: any, params?: EmbeddingsParams) {
 77        super(params ?? {});
 78        this.model = model;
 79    }
 80
 81    // 批量生成文档向量
 82    async embedDocuments(texts: string[]): Promise<number[][]> {
 83        return Promise.all(texts.map((text) => this.embedSingleText(text)));
 84    }
 85
 86    // 生成查询文本向量
 87    async embedQuery(text: string): Promise<number[]> {
 88        return this.embedSingleText(text);
 89    }
 90
 91    // 单文本向量生成核心逻辑
 92    private async embedSingleText(text: string): Promise<number[]> {
 93        const output = await this.model(text, { pooling: "mean", normalize: true });
 94        return Array.from(output.data);
 95    }
 96}
 97
 98//  ========== RAG核心工具类 ========== //
 99// 功能:文本分块、向量生成、pgvector 存储与检索、智能上下文判断
100export class NextBffRagTool {
101    private isInitialized: boolean;
102    private readonly textSplitter: RecursiveCharacterTextSplitter;
103    private embeddingModel: any;
104    private classifyModel: any;
105    private embeddingsInstance!: LocalOnnxEmbeddings;
106
107    constructor() {
108        this.isInitialized = false;
109        this.textSplitter = new RecursiveCharacterTextSplitter({
110            chunkSize: CHUNK_SIZE,
111            chunkOverlap: CHUNK_OVERLAP,
112        });
113
114        // 首次调用会下载模型,然后缓存
115        env.remoteHost = "https://hf-mirror.com";
116        env.allowRemoteModels = true;
117        env.cacheDir = "./tmp/.cache";
118    }
119
120    //  全局初始化方法(单例执行)
121    //  加载 AI 模型,避免重复初始化
122    public async initialize(): Promise<void> {
123        if (this.isInitialized) return;
124
125        try {
126            console.log("开始下载模型...");
127            this.embeddingModel = await pipeline("feature-extraction", EMBEDDING_MODEL);
128            this.classifyModel = await pipeline("zero-shot-classification", CLASSIFY_MODEL);
129            console.log("模型下载成功");
130            this.embeddingsInstance = new LocalOnnxEmbeddings(this.embeddingModel);
131            this.isInitialized = true;
132        } catch (error) {
133            console.error("模型初始化失败", error);
134            throw new Error(`模型初始化失败: ${(error as Error).message}`);
135        }
136    }
137
138    // 预加载快捷方法
139    private async preloadModels(): Promise<void> {
140        await this.initialize();
141    }
142
143    //  调用数据库连接池
144    private async getDatabaseClient(): Promise<PoolClient> {
145        // 取业务库连接池
146        const chatPool = dBPools.tai_rag_db;
147        // 校验业务库连接池是否初始化
148        if (!chatPool) {
149            throw new Error("tai_rag_db 数据库连接池未初始化 !");
150        }
151        // 声明连接变量
152        let dbClient: PoolClient | null = null;
153
154        dbClient = await chatPool.connect();
155        return dbClient;
156    }
157
158    // 存储会话文本到向量数据库
159    // 流程:文本清洗 → 分块 → 向量化 → 批量入库
160    public async storeSessionText(params: StoreSessionParams) {
161        const { sessionId, docs } = params;
162        if (!sessionId || !Array.isArray(docs) || docs.length === 0) {
163            throw new Error("sessionId 为必填项,text 必须为非空数组");
164        }
165        await this.initialize();
166
167        const fullText = docs
168            .map((item) => item?.content)
169            .filter((content) => typeof content === "string" && content.trim())
170            .join("\n\n");
171        if (!fullText) {
172            return { success: true, chunkCount: 0 };
173        }
174
175        const chunks = await this.textSplitter.splitText(fullText);
176        const vectors = await this.embeddingsInstance.embedDocuments(chunks);
177
178        const client = await this.getDatabaseClient();
179        try {
180            const values = chunks.map((chunk, index) => [uuidv4(), `[${vectors[index].join(",")}]`, JSON.stringify({ sessionId, text: chunk })]);
181            const query = `
182                INSERT INTO rag_vectors (id, vector, metadata)
183                VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}::vector, $${i * 3 + 3}::jsonb)`).join(", ")}
184            `;
185            await client.query(query, values.flat());
186            return { success: true, chunkCount: chunks.length };
187        } finally {
188            // 关键:无论成功/失败,都释放连接
189            if (client) {
190                client.release();
191            }
192        }
193    }
194
195    // 智能判断是否需要检索上下文
196    // 规则:关键词匹配 + AI 零样本分类
197    public async classifyNeedRetrieval(params: ClassifyParams): Promise<boolean> {
198        const { userQuery, chatHistorys = [] } = params;
199        if (!userQuery) return false;
200
201        if (RETRIEVAL_KEYWORDS.some((key) => userQuery.includes(key))) {
202            return true;
203        }
204
205        await this.initialize();
206        const context = `用户问题:${userQuery} | 历史对话:${JSON.stringify(chatHistorys)}`;
207        const result = await this.classifyModel(context, ["需要检索上下文", "不需要检索"]);
208        return result.labels[0] === "需要检索上下文";
209    }
210
211    // 向量相似度检索
212    // 根据用户问题匹配当前会话最相关的上下文
213    public async retrieveSessionContext(params: RetrieveParams): Promise<string[]> {
214        const { sessionId, userQuery, topK = DEFAULT_TOP_K } = params;
215
216        await this.initialize();
217        const queryVector = await this.embeddingsInstance.embedQuery(userQuery);
218
219        const client = await this.getDatabaseClient();
220        try {
221            const res = await client.query(
222                `
223                    SELECT metadata->>'text' AS text
224                    FROM rag_vectors
225                    WHERE metadata->>'sessionId' = $1
226                    ORDER BY vector <-> $2::vector
227                    LIMIT $3
228                `,
229                [sessionId, `[${queryVector.join(",")}]`, topK * 2],
230            );
231
232            return (
233                res.rows
234                    .map((row) => row.text)
235                    .filter(Boolean)
236                    .slice(0, topK) || []
237            );
238        } finally {
239            // 关键:无论成功/失败,都释放连接
240            if (client) {
241                client.release();
242            }
243        }
244    }
245
246    // 检查当前会话是否存在向量数据
247    // public async hasSessionVector(sessionId: string): Promise<boolean> {
248    //     await this.initialize();
249    //     const client = await this.getDatabaseClient();
250
251    //     try {
252    //         const res = await client.query("SELECT 1 FROM rag_vectors WHERE metadata->>'sessionId' = $1 LIMIT 1", [sessionId]);
253    //         return res.rows.length > 0;
254    //     } catch (error) {
255    //         console.error("会话向量查询失败", error);
256    //         return false;
257    //     } finally {
258    //         // 关键:无论成功/失败,都释放连接
259    //         if (client) {
260    //             client.release();
261    //         }
262    //     }
263    // }
264
265    // 清空指定会话的所有向量数据
266    public async clearSession(sessionId: string) {
267        const client = await this.getDatabaseClient();
268
269        try {
270            await client.query("DELETE FROM rag_vectors WHERE metadata->>'sessionId' = $1", [sessionId]);
271            return { success: true, msg: "会话数据已清空" };
272        } finally {
273            // 关键:无论成功/失败,都释放连接
274            if (client) {
275                client.release();
276            }
277        }
278    }
279
280    // 核心对外接口:智能聊天
281    // 整合存储、判断、检索全流程
282    public async smartChat(params: SmartChatParams): Promise<ChatResult> {
283        const { sessionId, userQuery, docs, chatHistorys = [], hasDocHistorys } = params;
284        await this.initialize();
285
286        // const hasVector = await this.hasSessionVector(sessionId);
287        let needRetrieval: boolean;
288
289        if (docs?.length) {
290            needRetrieval = true;
291            await this.storeSessionText({ sessionId, docs });
292        } else if (!chatHistorys.length || !hasDocHistorys) {
293            needRetrieval = false;
294        } else {
295            needRetrieval = await this.classifyNeedRetrieval(params);
296        }
297        const context = needRetrieval ? await this.retrieveSessionContext({ sessionId, userQuery }) : [];
298
299        // 拼接大模型输入内容
300        const promptDocs = context?.length ? `【参考文档】\n${context.map((item, i) => `文档片段${i + 1}\n${item}`).join("\n\n")}\n\n` : "";
301        const promptHistory = `【历史对话】\n${chatHistorys.map((h) => `【${h.role}${h.content}`).join("\n")}\n\n`;
302        const promptQuestion = `【用户问题】\n${userQuery}\n\n`;
303        const ask = `请根据${promptDocs ? "参考文档和" : ""}历史对话,精准回答用户问题,不要编造。`;
304        const prompt = `${promptDocs}${promptHistory}${promptQuestion}${ask}`;
305        return { sessionId, needRetrieval, userQuery, prompt };
306    }
307}
308
309//  ========== 单例导出 ========== //
310// 全局单例实例
311// 避免重复加载模型,提升性能
312const nextRag = new NextBffRagTool();
313
314nextRag.initialize();
315
316export default nextRag;
2.4.7.3.2 大模型流式响应解析器工厂

/src/bff/lib/utils/modelStreamParser.ts

  1// ==================== 流式解析器工厂 ==================== //
  2
  3// ==================== 大模型流式响应解析器工厂 ==================== //
  4// 作用:集中管理不同模型的流式响应解析逻辑,统一输出格式
  5// 扩展新模型:仅需实现对应解析器函数,并加入 parserMap 映射
  6
  7import { SupportedModel } from "@/bff/lib/db/modelConfig";
  8
  9/**
 10 * 解析器统一返回格式
 11 * @property content 解析出的纯文本片段
 12 * @property done 是否解析完成([DONE]标识)
 13 */
 14export interface StreamParserResult {
 15    content: string; // 前端需要的纯文本内容
 16    done: boolean; // 是否结束解析
 17}
 18
 19/**
 20 * 解析器函数接口(所有模型解析器必须遵循该接口)
 21 * @param chunk 流式返回的二进制数据块
 22 * @param decoder 文本解码器(复用避免重复创建)
 23 * @returns 解析结果数组(单chunk可能包含多个消息行)
 24 */
 25export type StreamParser = (chunk: Uint8Array, decoder: TextDecoder) => StreamParserResult[];
 26
 27// ==================== 各模型解析器实现(隔离不同模型的解析逻辑) ==================== //
 28
 29/**
 30 * DeepSeek 流式解析器(适配原有逻辑)
 31 * DeepSeek响应格式:data: {"choices":[{"delta":{"content":"xxx"}}]}
 32 */
 33const deepseekParser: StreamParser = (chunk, decoder) => {
 34    const results: StreamParserResult[] = [];
 35    // 解码二进制数据为字符串(stream: true 保留未完成的字符)
 36    const chunkStr = decoder.decode(chunk, { stream: true });
 37    // 按行分割(流式响应每行是一个消息)
 38    const lines = chunkStr.split("\n").filter((line) => line.trim() !== "");
 39
 40    // console.log("lines", lines);
 41
 42    for (const line of lines) {
 43        // 过滤非data开头的行(避免空行/注释行)
 44        if (!line.startsWith("data: ")) continue;
 45        // 移除前缀,获取纯JSON字符串
 46        const data = line.replace("data: ", "");
 47
 48        // 处理结束标识
 49        if (data === "[DONE]") {
 50            results.push({ content: "", done: true });
 51            continue;
 52        }
 53
 54        // 解析JSON并提取内容(异常容错)
 55        try {
 56            const json = JSON.parse(data);
 57            // 解析关键
 58            const content = json.choices?.[0]?.delta?.content || "";
 59            results.push({ content, done: false });
 60        } catch (e) {
 61            console.error(`[DeepSeek解析异常] ${(e as Error).message},原始数据:${data}`);
 62            results.push({ content: "", done: false }); // 解析失败返回空内容,不中断流
 63        }
 64    }
 65    return results;
 66};
 67
 68/**
 69 * 豆包流式解析器(适配豆包实际响应格式)
 70 * 豆包响应格式示例:data: {"delta":{"content":"xxx"}}
 71 */
 72const doubaoParser: StreamParser = (chunk, decoder) => {
 73    const results: StreamParserResult[] = [];
 74    const chunkStr = decoder.decode(chunk, { stream: true });
 75    const lines = chunkStr.split("\n").filter((line) => line.trim() !== "");
 76
 77    // console.log("lines", lines);
 78
 79    for (const line of lines) {
 80        if (!line.startsWith("data: ")) continue;
 81        const data = line.replace("data: ", "");
 82
 83        if (data === "[DONE]") {
 84            results.push({ content: "", done: true });
 85            continue;
 86        }
 87
 88        try {
 89            const json = JSON.parse(data);
 90            // 解析关键
 91            const content = json.choices?.[0]?.delta?.content || "";
 92            results.push({ content, done: false });
 93        } catch (e) {
 94            console.error(`[豆包解析异常] ${(e as Error).message},原始数据:${data}`);
 95            results.push({ content: "", done: false });
 96        }
 97    }
 98    return results;
 99};
100
101/**
102 * 千问流式解析器(适配千问实际响应格式)
103 * 千问响应格式示例:data: {"output":{"text":"xxx"}}
104 */
105const qwenParser: StreamParser = (chunk, decoder) => {
106    const results: StreamParserResult[] = [];
107    const chunkStr = decoder.decode(chunk, { stream: true });
108    const lines = chunkStr.split("\n").filter((line) => line.trim() !== "");
109
110    // console.log("lines", lines);
111
112    for (const line of lines) {
113        if (!line.startsWith("data: ")) continue;
114        const data = line.replace("data: ", "");
115
116        if (data === "[DONE]") {
117            results.push({ content: "", done: true });
118            continue;
119        }
120
121        try {
122            const json = JSON.parse(data);
123            // 解析关键
124            const content = json.choices?.[0]?.delta?.content || "";
125            results.push({ content, done: false });
126        } catch (e) {
127            console.error(`[千问解析异常] ${(e as Error).message},原始数据:${data}`);
128            results.push({ content: "", done: false });
129        }
130    }
131    return results;
132};
133
134/**
135 * 解析器工厂函数(根据模型类型获取对应解析器)
136 * @param model 模型类型
137 * @returns 该模型对应的解析器函数
138 * @throws 未实现解析器时抛出错误
139 */
140export const getStreamParser = (model: SupportedModel): StreamParser => {
141    // 解析器映射表(新增模型时,只需在这里添加映射)
142    const parserMap: Record<SupportedModel, StreamParser> = {
143        "deepseek-chat": deepseekParser,
144        "doubao-seed-2-0-pro-260215": doubaoParser,
145        "qwen3.6-plus-2026-04-02": qwenParser,
146    };
147
148    const parser = parserMap[model];
149    if (!parser) {
150        throw new Error(`[解析器异常] 未实现 ${model} 的流式解析器,请检查 modelStreamParser.ts`);
151    }
152    return parser;
153};

2.4.8 BFF 通用工具

/src/bff/lib/utils/common-tools.ts

 1// ==================== 通用工具 ==================== //
 2
 3// ========== React、Next、Utils ========== //
 4// ========== Components、CSS ========== //
 5// ========== Icon、Type ========== //
 6// ========== Stroe、Constants ========== //
 7// ========== Hooks ========== //
 8// ========== Services ========== //
 9
10// 工具函数:万能解析(永不报错)
11export const parseJson = (str: string) => {
12    try {
13        if (!str) return [];
14        const res = JSON.parse(str);
15        return Array.isArray(res) ? res : [];
16    } catch {
17        return [];
18    }
19};

2.5 自动化部署

2.5.1 CI/CD 脚本

/.github/workflows/tai_deploy.yml

 1name: Deploy to Server
 2
 3on:
 4    push:
 5        branches:
 6            - main
 7
 8# 强制使用 Node.js 24,消除所有警告
 9env:
10    FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
11
12# 只有3步:拉代码 → 配置SSH → 连服务器部署,完事!
13jobs:
14    deploy:
15        runs-on: ubuntu-latest
16        steps:
17            - name: Checkout code
18              uses: actions/checkout@v6
19
20            - name: Install SSH key
21              uses: webfactory/ssh-agent@v0.9.1
22              with:
23                  ssh-private-key: ${{ secrets.TAI_SSH_PRIVATE_KEY }}
24
25            - name: Add known hosts
26              run: ssh-keyscan ${{ secrets.TAI_SERVER_IP }} > ~/.ssh/known_hosts
27
28            - name: Deploy to server
29              run: |
30                  ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=60 -o ServerAliveCountMax=10 ${{ secrets.TAI_USER }}@${{ secrets.TAI_SERVER_IP }} << 'EOF'
31                  export POSTGRES_DB="${{ secrets.POSTGRES_DB }}"
32                  export POSTGRES_USER="${{ secrets.POSTGRES_USER }}"
33                  export POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}"
34                  export POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}
35                  export POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}
36                  export DEEPSEEK_API_KEY="${{ secrets.DEEPSEEK_API_KEY }}"
37                  export DOUBAO_API_KEY="${{ secrets.DOUBAO_API_KEY }}"
38                  export QIANWEN_API_KEY="${{ secrets.QIANWEN_API_KEY }}"
39                  export POSTGRES_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}"
40                  bash /usr/local/src/tai/deploy.sh
41                  EOF

2.5.2 部署脚本

/deploy.sh

  1#!/bin/bash
  2##############################################################################
  3# 项目一键部署脚本 (OpenCloudOS 9 / Next.js 服务端构建版)
  4# 功能:代码拉取 → 图片压缩 → Node环境安装 → 项目构建 → Docker部署
  5# 特性:严格错误捕获、失败立即退出、全流程可视化、适配GitHub Actions
  6##############################################################################
  7
  8# ====================== 脚本核心配置:严格错误模式(必开) ======================
  9set -euo pipefail
 10
 11# 定义固定常量
 12readonly PROJECT_DIR="/usr/local/src/tai"
 13readonly GIT_REPO_URL="https://github.com/templechan/tai.git"  # 仓库地址
 14readonly GIT_BRANCH="main"                    # 部署分支
 15readonly NODE_VERSION="v20.18.0"
 16readonly NODE_BIN_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz"
 17
 18# ==============================================
 19# 【步骤 1/8】基础环境清理:删除旧项目目录
 20# ==============================================
 21echo -e "\033[1;34m[1/8] 初始化项目目录(保留缓存)...\033[0m"
 22mkdir -p "${PROJECT_DIR}"
 23cd "${PROJECT_DIR}"
 24echo -e "\033[1;32m✅ 项目目录准备完成\033[0m"
 25
 26# ==============================================
 27# 【步骤 2/8】安装Git(如未安装)+ 配置用户信息
 28# ==============================================
 29echo -e "\033[1;34m[2/8] 检查并安装Git工具...\033[0m"
 30if ! command -v git &> /dev/null; then
 31    echo "未检测到Git,开始安装..."
 32    dnf install -y git
 33    git config --global user.email "templechan@126.com"
 34    git config --global user.name "templechan"
 35    echo -e "\033[1;32m✅ Git安装&配置完成\033[0m"
 36else
 37    echo -e "\033[1;32m✅ Git已存在,跳过安装\033[0m"
 38fi
 39
 40# ==============================================
 41# 【步骤 3/8】克隆项目代码(核心步骤:失败直接 退出)
 42# ==============================================
 43# 配置GitHub国内加速镜像(解决拉取慢/失败)
 44echo -e "\033[1;34m[3/7] 配置镜像并拉取项目代码...\033[0m"
 45git config --global url."https://gh.sevencdn.com/".insteadOf https://
 46# git config --global url."https://ghproxy.net/".insteadOf https://
 47# 如果失效,则删除旧的,设置的新的,记得先测试下是否有效
 48# git config --global --unset url."https://gh.sevencdn.com/".insteadOf https://
 49
 50# 首次部署=克隆,后续=增量更新
 51if [ ! -d ".git" ]; then
 52    # 核心:克隆代码,失败直接退出脚本,Actions标记部署失败
 53    echo -e "\033[1;34m[3/8] 正在拉取GitHub代码(main分支)...\033[0m"
 54    if ! git clone -b "${GIT_BRANCH}" "${GIT_REPO_URL}" "${PROJECT_DIR}"; then
 55        echo -e "\033[1;31m❌ 代码拉取失败!部署终止\033[0m"
 56        exit 1
 57    fi
 58else
 59    git stash push -m "auto stash"
 60    git pull origin main
 61fi
 62echo -e "\033[1;32m✅ 代码拉取成功\033[0m"
 63
 64# 进入项目目录(克隆成功才会执行)
 65cd "${PROJECT_DIR}"
 66
 67# ==============================================
 68# 【步骤 4/8】安装图片压缩依赖(ImageMagick)
 69# ==============================================
 70echo -e "\033[1;34m[4/8] 检查并安装图片压缩工具...\033[0m"
 71if ! command -v mogrify &> /dev/null; then
 72    echo "安装ImageMagick+依赖包..."
 73    dnf install -y ImageMagick bc parallel
 74    sed -i '/<policy domain="coder" rights=".*" pattern="PNG,JPG,JPEG,WEBP"/d;/<policymap>/a \  <policy domain="coder" rights="read|write" pattern="PNG,JPG,JPEG,WEBP" />;s/<policy domain="resource" name="memory" value="[^"]*"/<policy domain="resource" name="memory" value="256MiB"/;s/<policy domain="resource" name="disk" value="[^"]*"/<policy domain="resource" name="disk" value="1GiB"/;s/<policy domain="resource" name="width" value="[^"]*"/<policy domain="resource" name="width" value="8KP"/;s/<policy domain="resource" name="height" value="[^"]*"/<policy domain="resource" name="height" value="8KP"/;s/<policy domain="resource" name="thread" value="[^"]*"/<policy domain="resource" name="thread" value="2"/;s/<policy domain="resource" name="throttle" value="[^"]*"/<policy domain="resource" name="throttle" value="1"/;s/<policy domain="resource" name="map" value="[^"]*"/<policy domain="resource" name="map" value="256MiB"/' /etc/ImageMagick-7/policy.xml
 75    echo -e "\033[1;32m✅ 图片压缩工具安装完成\033[0m"
 76else
 77    echo -e "\033[1;32m✅ 图片压缩工具已存在,跳过安装\033[0m"
 78fi
 79
 80# ==============================================
 81# 【步骤 5/8】自动批量压缩项目图片
 82# ==============================================
 83# 进度交互版
 84# start=$SECONDS; find ./public/assets/images/ \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.webp" \) -type f -print0 | parallel -0 -j 2 --bar 'f="{}";old_size=$(stat -c %s "$f");if [ $old_size -gt 102400 ]; then q=$(echo "scale=0;80-40*l($old_size/102400)/l(10)" | bc -l | awk "{print int(\$1+0.5)}");q=$((q<15?15:q>60?60:q));ext="${f##*.}";case "$ext" in png) mogrify -strip -quality $q -define png:compression-level=9 -colors 128 "$f" 2>/dev/null ;; jpg|jpeg) mogrify -strip -quality $q -sampling-factor 4:2:0 -density 72x72 "$f" 2>/dev/null ;; webp) mogrify -strip -quality $((q-5)) -define webp:method=6 "$f" 2>/dev/null ;; esac;new_size=$(stat -c %s "$f");save=$((old_size-new_size));echo "$save" >> /tmp/img_save.txt;fi'; touch /tmp/img_save.txt; total_save=$(awk '{sum+=$1}END{print sum+0}' /tmp/img_save.txt 2>/dev/null); count=$(wc -l < /tmp/img_save.txt 2>/dev/null); rm -f /tmp/img_save.txt; cost=$((SECONDS - start)); min=$((cost / 60)); sec=$((cost % 60)); echo -e "\n\033[1;32m=== 压缩完成 ===\033[0m"; echo "✅ 压缩数量:${count:-0} 张"; echo "✅ 节省空间:$((total_save/1024)) KB ($((total_save/1024/1024)) MB)"; echo -e "✅ 耗时:${min}分${sec}秒"; echo -e "\033[1;32m================\033[0m"
 85echo -e "\033[1;34m[5/8] 开始自动压缩项目图片资源...\033[0m"
 86start=$SECONDS
 87touch /tmp/img_save.txt
 88find ./public/assets/images/ \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.webp" \) -type f -print0 | parallel -0 -j 2 'f="{}";old_size=$(stat -c %s "$f");if [ $old_size -gt 102400 ]; then q=$(echo "scale=0;80-40*l($old_size/102400)/l(10)" | bc -l | awk "{print int(\$1+0.5)}");q=$((q<15?15:q>60?60:q));ext="${f##*.}";case "$ext" in png) mogrify -strip -quality $q -define png:compression-level=9 -colors 128 "$f" 2>/dev/null ;; jpg|jpeg) mogrify -strip -quality $q -sampling-factor 4:2:0 -density 72x72 "$f" 2>/dev/null ;; webp) mogrify -strip -quality $((q-5)) -define webp:method=6 "$f" 2>/dev/null ;; esac;new_size=$(stat -c %s "$f");save=$((old_size-new_size));echo "$save" >> /tmp/img_save.txt;fi' 2>/dev/null
 89
 90total_save=$(awk '{sum+=$1}END{print sum+0}' /tmp/img_save.txt 2>/dev/null)
 91count=$(wc -l < /tmp/img_save.txt 2>/dev/null)
 92rm -f /tmp/img_save.txt
 93cost=$((SECONDS-start))
 94min=$((cost/60))
 95sec=$((cost%60))
 96
 97echo -e "\033[1;32m=== 图片压缩完成 ===\033[0m"
 98echo -e "压缩数量:${count:-0} 张"
 99echo -e "节省空间:$((total_save/1024)) KB ($((total_save/1024/1024)) MB)"
100echo -e "耗时:${min}${sec}秒"
101echo -e "\033[1;32m====================\033[0m"
102
103# ==============================================
104# 【步骤 6/8】安装Node.js + pnpm
105# ==============================================
106echo -e "\033[1;34m[6/8] 检查并安装Node.js运行环境...\033[0m"
107if ! command -v node &> /dev/null; then
108    echo "安装官方Node.js ${NODE_VERSION}..."
109    curl -fsSL "${NODE_BIN_URL}" -o node.tar.xz
110    tar -xf node.tar.xz --strip-components=1 -C /usr/local
111    rm -f node.tar.xz
112    echo -e "\033[1;32m✅ Node.js安装完成\033[0m"
113else
114    echo -e "\033[1;32m✅ Node.js已存在,跳过安装\033[0m"
115fi
116
117echo "安装pnpm包管理器..."
118npm install -g pnpm --force
119echo -e "\033[1;32m✅ pnpm安装完成\033[0m"
120
121# ==============================================
122# 【步骤 7/8】项目构建
123# ==============================================
124echo -e "\033[1;34m[7/8] 开始构建Next.js项目...\033[0m"
125export SHARP_DOWNLOAD_BINARY=true
126export SKIP_DB_INIT=true
127
128echo "安装项目依赖..."
129pnpm approve-builds --all
130pnpm install --frozen-lockfile
131
132echo "开始生产构建(静默模式,解除阻塞)..."
133# 关闭Next.js终端动画 + 静默输出,彻底解决SSH卡住
134export NEXT_TELEMETRY_DISABLED=1
135export NEXT_DISABLE_TERMINAL_OUTPUT=1
136pnpm build >/dev/null 2>&1 || true
137sync && echo -e "\n"
138
139echo -e "\033[1;32m✅ 项目构建完成\033[0m"
140
141# ==============================================
142# 【步骤 8/8】Docker环境配置 + 启动
143# ==============================================
144echo -e "\033[1;34m[8/8] 检查并配置Docker容器环境...\033[0m"
145if ! command -v docker &> /dev/null; then
146    echo "卸载旧版Docker组件..."
147    dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine
148
149    echo "启用系统软件仓库..."
150    sed -i 's/enabled=0/enabled=1/g' /etc/yum.repos.d/OpenCloudOS.repo
151    dnf clean all && dnf makecache
152
153    echo "配置阿里云Docker镜像源..."
154    dnf install -y dnf-plugins-core
155    dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
156
157    echo "安装Docker引擎..."
158    dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
159
160    echo "启动Docker并设置开机自启..."
161    systemctl start docker
162    systemctl enable docker
163    echo -e "\033[1;32m✅ Docker安装完成\033[0m"
164else
165    echo -e "\033[1;32m✅ Docker已存在,跳过安装\033[0m"
166fi
167
168echo "配置Docker镜像代理..."
169tee /etc/docker/daemon.json <<EOF
170{
171"registry-mirrors": [
172    "https://docker.1ms.run",
173    "https://dockerproxy.net",
174    "https://proxy.vvvv.ee",
175    "https://dockerproxy.link"
176]
177}
178EOF
179systemctl daemon-reload
180echo -e "\033[1;32m✅ Docker镜像配置完成\033[0m"
181
182echo -e "\033[1;34m正在启动项目服务...\033[0m"
183docker compose up -d --build
184
185echo -e ""
186echo -e "============================================================"
187echo -e "\033[1;32m🎉 项目部署全部完成!服务已后台运行\033[0m"
188echo -e "============================================================"
189
190# 强制返回0退出码,告诉GitHub Actions:部署成功
191exit 0

2.5.3 Docker 文件

/Dockerfile

 1# 运行阶段
 2FROM node:20-slim
 3WORKDIR /app
 4
 5COPY ./.next/standalone ./
 6COPY ./.next/static ./.next/static
 7COPY ./public ./public
 8
 9# 安装缺失的onnx运行库
10RUN apt-get update && apt-get install -y --no-install-recommends wget && \
11    wget --no-check-certificate https://ghproxy.net/https://github.com/microsoft/onnxruntime/releases/download/v1.14.0/onnxruntime-linux-x64-1.14.0.tgz && \
12    tar -zxvf onnxruntime-linux-x64-1.14.0.tgz && \
13    cp onnxruntime-linux-x64-1.14.0/lib/libonnxruntime.so.1.14.0 /usr/local/lib/ && \
14    ldconfig && \
15    rm -rf onnxruntime-linux-x64-1.14.0* && \
16    apt-get purge -y wget && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*
17
18ENV NODE_ENV=production
19ENV PORT=91
20# EXPOSE 3000
21
22CMD ["node", "server.js"]

/docker-compose.yml

 1services:
 2    tai:
 3        # 等价于 docker build -t tai . (自动构建当前目录的Dockerfile)
 4        build: .
 5        image: tai:latest
 6        container_name: tai
 7        # 开机/崩溃自动重启
 8        restart: always
 9        environment:
10            # 直接读取系统环境变量(从 GitHub Secrets 传递过来)
11            # 数据库配置
12            - POSTGRES_URL=${POSTGRES_URL}
13            # API密钥
14            - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
15            - DOUBAO_API_KEY=${DOUBAO_API_KEY}
16            - QIANWEN_API_KEY=${QIANWEN_API_KEY}
17        volumes:
18            - ./tmp/.cache:/app/tmp/.cache
19        # 等待数据库启动后再启动应用
20        depends_on:
21            - db
22        # 加入Docker内部网络,互通
23        networks:
24            - app-network
25        # 端口映射 主机91 → 容器91
26        ports:
27            - "91:91"
28    # ======================
29    # PostgreSQL + pgvector 数据库(自带插件)
30    # ======================
31    db:
32        # 官方镜像,内置 pgvector 插件,开箱即用
33        image: ankane/pgvector:latest
34        container_name: ankane-pgvector
35        restart: always
36        # 加载环境变量
37        environment:
38            # 直接读取系统环境变量
39            - POSTGRES_DB=${POSTGRES_DB}
40            - POSTGRES_USER=${POSTGRES_USER}
41            - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
42        volumes:
43            - postgres_data:/var/lib/postgresql/data
44        # 可选:暴露端口给本地数据库工具连接(调试用)
45        ports:
46            - "5432:5432"
47        networks:
48            - app-network
49# Docker 内部网络(应用和数据库互通)
50networks:
51    app-network:
52        driver: bridge
53# 数据库持久化卷
54volumes:
55    postgres_data: