目录

1,java与React的对应关系

2,新建项目

3. 修改启动端口为3000

4. 先在页面上现实一行字试试

5.小试牛刀,建几个按钮,并点击弹窗

6. 后端对接大模型ollama,RAG,迭代对话

找到接口入口,添加处理对话方法

处理对话chat-stream-handler.ts

db目录添加表结构文件db/schema.ts

初始化数据,和推送push脚本db/seed.ts

queries/connections.ts连接数据库客户端

RAG知识库处理 rag.ts

ollama.ts对接ollama暴露的接口,目前只用到了POST /api/chat:多轮对话(对应 ollama run交互模式)接口,后期可接入高级embedding接口,作为微调

drizzle.config.ts配置文件可以在不启动后端的情况下操作数据库,主要是drizzle-kit客户端操作数据库 可以通过npm run db:push在启动服务前初始化数据库

package.json和package-lock.json

tsconfig.json 负责引入tsconfig.node.json tsconfig.server.json  tsconfig.app.json

最后是vite.config.ts

7. 执行数据库脚本,启动服务,测试接口,日志

8.打包 运行,下载源码地址 等命令

9.总体项目说明:

备注:React学习总结


1,java与React的对应关系

Java / Spring

React / Vite

Application.java

main.tsx

Controller

Component

Service

Hook

DTO

Props / State

application.yml

vite.config.ts

Maven

npm

2,新建项目

终端执行以下命令:

npx create-vite@latest rag-tf-tf-legal-ai-app --template react-ts

完成后就会在当前执行命令的终端目录生成项目文件了,目录如下:

创建完成后执行,编译、启动命令。

cd rag-tf-tf-legal-ai-app
npm install #安装依赖
npm run dev

.vscode/lanuch.json 登录debug文件内容: 注意如果你用的是chrome,那type就是chrome。edge是msedge。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "msedge",
      "request": "launch",
      "name": "调试 React (Vite)",
      "url": "http://localhost:5174",
      "webRoot": "${workspaceFolder}"
    }
  ]
}

此时就能debug启动了。

3. 修改启动端口为3000

修改vite.config.ts文件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
   server: {
    port: 3000, // 👈 改成你想要的端口
    strictPort: true, // 可选:端口被占用直接报错(而不是换端口)
    host: true, // 可选:允许局域网访问
 open:false,// 启动时不会自动打开浏览器 这个也很有用,不会让你写着代码就自动跳到浏览器了。因为自动保存 自动生效了。
  },
})

记得把上面debug文件中的端口也改成3000.

记得手动ctrl+s报错,否则不生效。被这个坑惨。可以file->auto save 设置为自动保存。

vite的启动方式不用我们重写build,页面上就能自动生效。但是如果修改了vite.config.ts的配置内容,还是要重写构建并重启的。

4. 先在页面上现实一行字试试

首先首页index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>LegalAI Assistant</title>
  </head>
  <body>
    <!-- 首页,root是整个页面的div表示。script是修改root这个div的代码,我们后面所有对页面的操作将都由script来操作 -->
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

然后是main.tsx文件:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  // strictMode就是校验代码的,不影响页面展示
   <StrictMode>
    {/* BrowserRouter是必要的,如果没有这个包裹着App,那么app中的代码就不生效了,页面上也就不会有对应
    样式 */}
    <BrowserRouter>
    
        <App />

    </BrowserRouter>
  </StrictMode>,
)

App.tsx文件


import { Route, Routes } from 'react-router'
import './App.css'
import Home from './pages/home'

export default function App() {
  return (<Routes>
    <Route path='/' element={<Home />}>
    </Route>
  </Routes>
  );
}

home.tsx文件

export default function Home(){
    return <div>
        <h2 className="text-lg font-semibold mb-2">
            法律AI助手
        </h2>
    </div>
}

最后页面上就有了:

注:注意看上面代码中有三种注释的表现形式,分别对应三种语言,css,js,jsx

注释形式

属于

用在

<!-- -->

HTML / JSX 模板

写在 JSX 里

{/* */}

JSX 表达式

写在 JSX 里

///* */

JavaScript / TypeScript

写在 JS / TS 逻辑里

还有些css样式的设置,有点看不懂哈。不研究了,都是常用样式的简写。

类名

含义

实际效果

text-lg

Text Size Large

字体变大(约 18px~20px,取决于主题)

font-semibold

Font Weight Semibold

字体粗细为“半粗体”(比 normal 粗,比 bold 细)

mb-2

Margin Bottom 2

底部外边距为 0.5rem(8px)

5.小试牛刀,建几个按钮,并点击弹窗

下面的代码中涉及到函数的声明、div中嵌套数据,集合的表示,用{}表示变量,变量{}中嵌套变量{},按钮的点击事件

只需修改homt.tsx文件即可:

export default function Home() {
    // 虽然const表示常量,在react中“函数即值”,也是不可修改的。所以这里就是
    // 把函数赋值给常量sendMessage。通常表示事件处理函数
    // 此外 ()-> {}的函数声明方式就是= function(){}的lambda写法。
    const sendMessage = (m, params) =>{
        alert(m + params)
    }
      const sendMessage2 = function(m, params){
        alert(m + params+"12")
    }
    return <div>
        {/* 标题 */}
        <h2 className="text-lg font-semibold mb-2">
            法律AI助手
        </h2>
        {/* 描述文字 */}
        <p className="text-sm text-muted-foreground text-center max-w-md mb-6">
            基于本地大模型的智能法律顾问。支持检索本地法律知识库,
            为你提供专业、准确的法律咨询服务。
        </p>
        {/* 示例问题按钮 */}
        <div>
            {["劳动合同纠纷如何处理?",
                    "民间借贷的诉讼时效是多久?",
                    "交通事故责任如何划分?",
                    "离婚财产分割的法律依据?",].map((m) => (
                        <button onClick={() => sendMessage2(m, "sd")}>
                            {m}
                        </button>
                    ))}
        </div>

    </div>
}

生效后,页面点击按钮如下:

6. Hono后端开发

  •  什么是Hono?

一种轻量后端框架,也不管了,就把他看js就行了。

  • 创建后端入口文件:
  • 加入如下内容:
// 后端入口
// 什么是Hono,一种轻量后端框架,也不管了,就把他看js就行了。
import { Hono } from "hono"
// 这里带@是因为表示我们跑在哪个平台上,反正还不是很理解 先放着。
import { HttpBindings } from "@hono/node-server"
import { bodyLimit } from "hono/body-limit"
import { env } from "./lib/env";
const app = new Hono<{ Bindings: HttpBindings }>

// 给请求加一个限制,防止传的东西太大
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }))
// 流式对话端点(不走trpc,直接用Hono)
app.post("/api/chat/stream", async (c) => {
    try {
        const body = await c.req.json();
        return new Response(env.ollamaUrl, {
            headers: {
                "Content-Type": "text/event-stream",
                "Cache-Control": "no-cache",
                Connection: "keep-alive",
            },
        });
    } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        console.log("[chat/stream] error", message);
        return c.json({ error: message }, 500);
    }
});
// 因为app需要被别的文件使用,所以需要export   使用default修饰的组件被import时,不用加{}
export default app;

if (env.isProduction) {
    const { serve } = await import("@hono/node-server");
    const { serveStaticFiles } = await import("./lib/vite");
    serveStaticFiles(app);

    const port = parseInt("3002");
    serve({ fetch: app.fetch, port }, () => {
        console.log(`Server running on http://localhost:${port}/`);
    });
}
  • 这里我们要测试的是/api/chat/stream Post接口,目前肯定还调不通。所以我们梳理下调用流程。

开发环境是由vite控制的,所以我们重点看vite.config.ts文件:

import { defineConfig } from 'vite'
import devServer from "@hono/vite-dev-server"
import react from '@vitejs/plugin-react'

// 什么是vite?是一个前端构建工具+开发服务器。
// 在我们这个项目中 vite需要启动开发服务器,需要ts,tsx为js.还能通过devServer集成后端Hono
// vite只是在开发环境才有的,方便我们开发,生产是没有的
// 这里配置devServer 才能连接到后端文件入口,才都能访问成功。

// vite怎么引入环境变量?不用我们手动指定,都是约定的.env文件
// 引入环境变量方式 Node 端用 process.env 前端用 import.meta.env
// https://vite.dev/config/
export default defineConfig({
  plugins: [
    devServer({entry:"api/boot.ts", 
     exclude: [/^\/(?!api\/).*$/] 
    }),
    react()],
   server: {
    port: 3000, // 👈 改成你想要的端口
    strictPort: true, // 可选:端口被占用直接报错(而不是换端口)
    host: true, // 可选:允许局域网访问
    open:false,// 启动时不会自动打开浏览器
  },
})
  • 上面解释了什么是vite?怎么调用全局环境变量?
  • 调用流程 vite->devServer->api/boot.ts后端入口文件 ->引入组件HttpBindings -》post调用/api/chat/stream接口-》返回环境变量中的env.ollamaUrl配置

再补充下后端读取全局变量的代码:


// 后端读取全局变量的方式:process.env 
function required(name: string): string {
  const value = process.env[name];
  if (!value && process.env.NODE_ENV === "production") {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value ?? "";
}

export const env = {
  appId: required("APP_ID"),
  appSecret: required("APP_SECRET"),
  isProduction: process.env.NODE_ENV === "production",
  databaseUrl: required("DATABASE_URL"),
  ollamaUrl: process.env.OLLAMA_URL || process.env.ollama_url || "http://localhost:11434",
};

然后npm build dev  npm run dev 启动,在postman上调用接口,就能正常调用了。http://localhost:3000/api/chat/stream

6. 后端对接大模型ollama,RAG,迭代对话

这里代码比较多,先给目录结构

  • 找到接口入口,添加处理对话方法

// 后端入口
// 什么是Hono,一种轻量后端框架,也不管了,就把他看js就行了。
import { Hono } from "hono"
// 这里带@是因为表示我们跑在哪个平台上,反正还不是很理解 先放着。
import { HttpBindings } from "@hono/node-server"
import { bodyLimit } from "hono/body-limit"
import { env } from "./lib/env";
import { handleChatStream } from "./lib/chat-stream-handler";
const app = new Hono<{ Bindings: HttpBindings }>

// 给请求加一个限制,防止传的东西太大
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }))
// 流式对话端点(不走trpc,直接用Hono)
app.post("/api/chat/stream", async (c) => {
    try {
        console.log("sddssd")
        const body = await c.req.json();
        const stream = await handleChatStream(body);
        // return new Response(env.ollamaUrl, {
        return new Response(stream, {
            headers: {
                "Content-Type": "text/event-stream",
                "Cache-Control": "no-cache",
                Connection: "keep-alive",
            },
        });
    } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        console.log("[chat/stream] error", message);
        return c.json({ error: message }, 500);
    }
});
// 因为app需要被别的文件使用,所以需要export   使用default修饰的组件被import时,不用加{}
export default app;

// 生产使用
if (env.isProduction) {
    const { serve } = await import("@hono/node-server");
    const { serveStaticFiles } = await import("./lib/vite");
    serveStaticFiles(app);

    const port = parseInt("3002");
    serve({ fetch: app.fetch, port }, () => {
        console.log(`Server running on http://localhost:${port}/`);
    });
}
  • 处理对话chat-stream-handler.ts

 * 完整数据流转:
 * 1,接受post请求
 * 2,保存用户消息到数据库
 * 3,RAG检索本地知识库
 * 4,组装系统提示词
 * 5,获取历史消息
 * 6,发送给ollama
 * 7,读取sse流式处理,逐个token推送给前端
 * 8,流结束时保存助手回复到数据库
/**
 * Hono 流式对话处理器
 * 完整数据流转:
 * 1,接受post请求
 * 2,保存用户消息到数据库
 * 3,RAG检索本地知识库
 * 4,组装系统提示词
 * 5,获取历史消息
 * 6,发送给ollama
 * 7,读取sse流式处理,逐个token推送给前端
 * 8,流结束时保存助手回复到数据库
 */
import { getDb } from "../queries/connection";
import { conversations, messages } from "@db/schema";
import { eq, desc, asc } from "drizzle-orm";
import { searchRelevantChunks } from "./rag";
import { getLegalSystemPrompt, type OllamaMessage } from "./ollama";
import { env } from "./env";

const OLLAMA_BASE_URL = env.ollamaUrl || "http://localhost:11434";

// interface是声明一个结构的描述  不是类,不能new 跟java挺像的,java的interface也是不能new的。只是作为一种类型,用来引用的。
// 更形象的解释:只要一个对象满足:有 content(string)有 model(string)它就是 StreamBody
interface StreamBody {
  // ?表示可有可没有,没有问号就必须要有了
  conversationId?: number;
  content: string;
  model: string;
  useRAG?: boolean;
  temperature?: number;
  topP?: number;
  topK?: number;
  numCtx?: number;
  repeatPenalty?: number;
  category?: string;
}

/**
 * 处理流式对话请求
 * 
 * 日志输出说明(查看方式:终端窗口中会打印):
 * - [RAG] 开头:知识库检索相关日志
 * - [Ollama] 开头:模型调用相关日志
 * - [Chat] 开头:整体流程日志
 */
export async function handleChatStream(body: StreamBody) {
  const db = getDb();
  const startTime = Date.now();
  console.log("\n========================================");
  console.log(`[Chat] 收到新请求: "${body.content.slice(0, 60)}..."`);
  console.log(`[Chat] 模型: ${body.model} | RAG: ${body.useRAG !== false}`);
  console.log("========================================\n");

  // 保存对话模型
  let conversationId = body.conversationId;
  if (!conversationId) {
    const result = await db.insert(conversations).values({
      title: body.content.slice(0, 30) + "...",
      model: body.model,
    });
    conversationId = Number(result[0].insertId);
    console.log(`[Chat] 创建新对话: ID=${conversationId}`);
  } else {
    console.log(`[Chat] 继续对话: ID=${conversationId}`);
  }
  // ========== 步骤 2:保存用户消息 ==========
  await db.insert(messages).values({
    conversationId,
    role: "user",
    content: body.content,
  });
  console.log(`[Chat] 用户消息已保存(${body.content.length} 字)`);

  // ========== 步骤 3:RAG 检索知识库 ==========
  //   let sources:是在声明一个变量的类型。 这也是ts编程的特点,先定义类型,跟js不一样, js的话 一上来就let sources=值。
  let sources: Array<{ title: string; content: string; similarity: number }> = []
  let contextChunks: string[] = [];
  if (body.useRAG != false) {
    console.log("[RAG]开始检索知识库。。。");
    const ragStart = Date.now();
    const results = await searchRelevantChunks(body.content, {
      topK: 5,
      minScore: 0.03,
      category: body.category,
    });
    //计算检索时间
    const ragDuration = Date.now() - ragStart;
    if (results.length > 0) {
      console.log(`[RAG] 检索完成!找到 ${results.length} 条相关内容(耗时 ${ragDuration}ms)`);

      sources = results.map((r) => ({
        title: `${r.docTitle}(${r.docCategory})`,
        content: r.content,
        similarity: Math.round(r.similarity * 1000) / 1000,
      }));

      contextChunks = results.map((r) => `[来源:${r.docTitle}]\n${r.content}`);

      // 打印检索到的内容摘要
      results.forEach((r, i) => {
        console.log(`[RAG]  结果 ${i + 1}: [相似度 ${(r.similarity * 100).toFixed(1)}%] ${r.docTitle}`);
        console.log(`[RAG]         内容: ${r.content.slice(0, 100)}...`);
      });
    } else {
      console.log(`[RAG] 警告!未找到相关内容(耗时 ${ragDuration}ms),将使用模型自身知识回答`);
    }
  } else {
    console.log(`[RAG] 知识库检索已关闭,跳过`);
  }

  // ========== 步骤 4:组装系统提示词 ==========
  const systemPrompt = getLegalSystemPrompt(
    contextChunks.length > 0 ? contextChunks : undefined
  );
  console.log(`\n[Chat] 系统提示词长度: ${systemPrompt.length} 字`);
  if (contextChunks.length > 0) {
    console.log(`[Chat] 提示词中包含 ${contextChunks.length} 条参考资料`);
  }

  // ========== 步骤 5:获取历史消息  ==========
  const history = await db
    .select()
    .from(messages)
    .where(eq(messages.conversationId, conversationId))
    .orderBy(asc(messages.createdAt))
    .limit(20);
  console.log(`[Chat] 历史消息: ${history.length} 条`);

  // ========== 步骤 6:构建 Ollama 消息列表 ==========
  const ollamaMessages: OllamaMessage[] = [];
  ollamaMessages.push({ role: "system", content: systemPrompt });

  // 把相关的历史问答都加到提示词中
  for (const msg of history) {
    if (msg.role === "user" || msg.role === "assistant") {
      ollamaMessages.push({ role: msg.role, content: msg.content });
    }
  }

  // ========== 步骤 7:调用 Ollama ==========
  //解释下这里的变量为啥要用${}, 因为这里是字符串模板,在字符串中插入变量,所以要用$,这点跟python和shell脚本很像。
  console.log(`\n[Ollama] 发送请求到 ${OLLAMA_BASE_URL}`);
  console.log(`[Ollama] 模型: ${body.model}`);
  console.log(`[Ollama] 参数: temperature=${body.temperature ?? 0.7}, top_p=${body.topP ?? 0.9}, num_ctx=${body.numCtx ?? 4096}`);
  console.log(`[Ollama] 提示词:`, JSON.stringify(ollamaMessages, null, 2))
  const ollamaStart = Date.now();

  const ollamaRes = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: body.model,
      messages: ollamaMessages,
      stream: true,
      options: {
        temperature: body.temperature ?? 0.7,
        top_p: body.topP ?? 0.9,
        top_k: body.topK ?? 40,
        num_ctx: body.numCtx ?? 4096,
        repeat_penalty: body.repeatPenalty ?? 1.1,
      },
    }),
  });

  if (!ollamaRes.ok || !ollamaRes.body) {
    console.error(`[Ollama] 请求失败: HTTP ${ollamaRes.status}`);
    throw new Error(`Ollama 请求失败: ${ollamaRes.status}`);
  }

  const ollamaConnectTime = Date.now() - ollamaStart;
  console.log(`[Ollama] 连接成功(耗时 ${ollamaConnectTime}ms),开始接收流...\n`);


  // ========== 步骤 8:组装 SSE 响应 ==========
  /**
   * “Server-Sent Events(SSE)流式响应” 
   * 👉 一边从 Ollama 拿 token
   * 👉 一边往前端的浏览器推
   * 👉 结束后一次性写入数据库
   *  
   * */
  //创建编码器
  const encoder = new TextEncoder();
  let fullContent = "";
  let tokenCount = 0;

  /**
   * ReadableStream是关键
   * 基本结构:
   * new ReadableStream({
  start(controller) {
    // 这里写“怎么产生数据”
  }
});
   *  */

  return new ReadableStream({
    //这里start(controller)很奇怪 没有地方传入controller?
    // 因为这是在node,hono内部调用的。  是ReadableStream是关键的基本结构
    // controller是一个回调函数参数
    async start(controller) {
      try {
        const reader = ollamaRes.body!.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value, { stream: true });
          const lines = chunk.split("\n").filter((l) => l.trim());

          for (const line of lines) {
            try {
              const json = JSON.parse(line) as {
                message?: { content?: string };
                done?: boolean;
                eval_count?: number;
                prompt_eval_count?: number;
              };

              if (json.message?.content) {
                fullContent += json.message.content;
                tokenCount++;
                controller.enqueue(
                  encoder.encode(
                    `data: ${JSON.stringify({
                      type: "token",
                      content: json.message.content,
                    })}\n\n`
                  )
                );
              }

              if (json.done) {
                const totalDuration = Date.now() - startTime;
                console.log("\n========================================");
                console.log(`[Chat] 生成完成!总耗时 ${totalDuration}ms`);
                console.log(`[Chat] 生成 token 数: ~${tokenCount}`);
                console.log(`[Chat] 回复长度: ${fullContent.length} 字`);
                console.log(`[Chat] 使用了 ${sources.length} 条参考资料`);
                console.log("========================================\n");

                // 保存助手回复到数据库
                await db.insert(messages).values({
                  conversationId,
                  role: "assistant",
                  content: fullContent || "(无回复)",
                  sources: sources.length > 0 ? sources : undefined,
                  metadata: {
                    temperature: body.temperature ?? 0.7,
                    topP: body.topP ?? 0.9,
                    topK: body.topK ?? 40,
                    numCtx: body.numCtx ?? 4096,
                    duration: totalDuration,
                  },
                });

                // 更新对话时间
                await db
                  .update(conversations)
                  .set({ updatedAt: new Date() })
                  .where(eq(conversations.id, conversationId));

                controller.enqueue(
                  encoder.encode(
                    `data: ${JSON.stringify({
                      type: "done",
                      conversationId,
                      sources,
                    })}\n\n`
                  )
                );
                controller.close();
                return;
              }
            } catch {
              // 忽略解析失败的行
            }
          }
        }

        // 流正常结束但没有 done 标记(保险处理)
        if (fullContent) {
          await db.insert(messages).values({
            conversationId,
            role: "assistant",
            content: fullContent,
            sources: sources.length > 0 ? sources : undefined,
          });
        }

        controller.enqueue(
          encoder.encode(
            `data: ${JSON.stringify({ type: "done", conversationId, sources })}\n\n`
          )
        );
        controller.close();
      } catch (err) {
        console.error(`[Chat] 流处理错误:`, err);
        controller.error(err);
      }
    },
  });
}

  • db目录添加表结构文件db/schema.ts

import {
  mysqlTable,
  mysqlEnum,
  serial,
  varchar,
  text,
  timestamp,
  json,
  int,
  bigint,
} from "drizzle-orm/mysql-core";

// 对话记录
export const conversations = mysqlTable("conversations", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull().default("新对话"),
  model: varchar("model", { length: 100 }).notNull().default("qwen2.5:7b"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});

// 消息记录
export const messages = mysqlTable("messages", {
  id: serial("id").primaryKey(),
  conversationId: bigint("conversation_id", { mode: "number", unsigned: true }).notNull(),
  role: mysqlEnum("role", ["user", "assistant", "system"]).notNull(),
  content: text("content").notNull(),
  sources: json("sources").$type<Array<{ title: string; content: string; similarity: number }>>(),
  metadata: json("metadata").$type<{
    temperature?: number;
    topP?: number;
    topK?: number;
    numCtx?: number;
    duration?: number;
  }>(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

// 知识库文档
export const knowledgeDocs = mysqlTable("knowledge_docs", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  content: text("content").notNull(),
  category: varchar("category", { length: 100 }).notNull().default("未分类"),
  source: varchar("source", { length: 255 }),
  docType: mysqlEnum("doc_type", ["law", "regulation", "case", "interpretation", "custom"]).notNull().default("custom"),
  wordCount: int("word_count").notNull().default(0),
  chunkCount: int("chunk_count").notNull().default(0),
  isBuiltIn: int("is_built_in").notNull().default(0), // 1=内置, 0=用户上传
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

// 文档分块(用于 RAG 检索)
export const documentChunks = mysqlTable("document_chunks", {
  id: serial("id").primaryKey(),
  docId: bigint("doc_id", { mode: "number", unsigned: true }).notNull(),
  content: text("content").notNull(),
  chunkIndex: int("chunk_index").notNull(),
  // 使用 TF-IDF 向量的 JSON 表示
  tfidfVector: json("tfidf_vector").$type<Record<string, number>>(),
  // 关键词(用于快速过滤)
  keywords: text("keywords"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

// 系统参数配置
export const settings = mysqlTable("settings", {
  id: serial("id").primaryKey(),
  key: varchar("key", { length: 100 }).notNull().unique(),
  value: text("value").notNull(),
  group: varchar("group", { length: 50 }).notNull().default("general"),
  description: varchar("description", { length: 500 }),
  updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});

// Ollama 模型配置
export const modelConfigs = mysqlTable("model_configs", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 100 }).notNull(),
  displayName: varchar("display_name", { length: 255 }).notNull(),
  description: varchar("description", { length: 500 }),
  parameters: json("parameters").$type<{
    temperature: number;
    topP: number;
    topK: number;
    numCtx: number;
    repeatPenalty: number;
    numPredict: number;
  }>(),
  isDefault: int("is_default").notNull().default(0),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});
  • 初始化数据,和推送push脚本db/seed.ts

/**
 * 法律知识库种子数据
 * 预置基础法律条文,启动后即可使用
 */

import { getDb } from "../api/queries/connection";
import { knowledgeDocs, documentChunks } from "./schema";
import { eq } from "drizzle-orm";

// 民法典总则编(精简版)
const CIVIL_CODE_GENERAL = `中华人民共和国民法典
第一编 总则

第一章 基本规定

第一条 为了保护民事主体的合法权益,调整民事关系,维护社会和经济秩序,适应中国特色社会主义发展要求,弘扬社会主义核心价值观,根据宪法,制定本法。

第二条 民法调整平等主体的自然人、法人和非法人组织之间的人身关系和财产关系。

第三条 民事主体的人身权利、财产权利以及其他合法权益受法律保护,任何组织或者个人不得侵犯。

第四条 民事主体在民事活动中的法律地位一律平等。

第五条 民事主体从事民事活动,应当遵循自愿原则,按照自己的意思设立、变更、终止民事法律关系。

第六条 民事主体从事民事活动,应当遵循公平原则,合理确定各方的权利和义务。

第七条 民事主体从事民事活动,应当遵循诚信原则,秉持诚实,恪守承诺。

第八条 民事主体从事民事活动,不得违反法律,不得违背公序良俗。

第九条 民事主体从事民事活动,应当有利于节约资源、保护生态环境。

第十条 处理民事纠纷,应当依照法律;法律没有规定的,可以适用习惯,但是不得违背公序良俗。

第二章 自然人

第一节 民事权利能力和民事行为能力

第十三条 自然人从出生时起到死亡时止,具有民事权利能力,依法享有民事权利,承担民事义务。

第十四条 自然人的民事权利能力一律平等。

第十七条 十八周岁以上的自然人为成年人。不满十八周岁的自然人为未成年人。

第十八条 成年人为完全民事行为能力人,可以独立实施民事法律行为。十六周岁以上的未成年人,以自己的劳动收入为主要生活来源的,视为完全民事行为能力人。

第十九条 八周岁以上的未成年人为限制民事行为能力人,实施民事法律行为由其法定代理人代理或者经其法定代理人同意、追认;但是,可以独立实施纯获利益的民事法律行为或者与其年龄、智力相适应的民事法律行为。

第二十条 不满八周岁的未成年人为无民事行为能力人,由其法定代理人代理实施民事法律行为。

第二节 监护

第二十六条 父母对未成年子女负有抚养、教育和保护的义务。成年子女对父母负有赡养、扶助和保护的义务。

第二十七条 父母是未成年子女的监护人。未成年人的父母已经死亡或者没有监护能力的,由下列有监护能力的人按顺序担任监护人:(一)祖父母、外祖父母;(二)兄、姐;(三)其他愿意担任监护人的个人或者组织,但是须经未成年人住所地的居民委员会、村民委员会或者民政部门同意。

第三节 宣告失踪和宣告死亡

第四十条 自然人下落不明满二年的,利害关系人可以向人民法院申请宣告该自然人为失踪人。

第四十六条 自然人有下列情形之一的,利害关系人可以向人民法院申请宣告该自然人死亡:(一)下落不明满四年;(二)因意外事件,下落不明满二年。因意外事件下落不明,经有关机关证明该自然人不可能生存的,申请宣告死亡不受二年时间的限制。

第三章 法人

第五十七条 法人是具有民事权利能力和民事行为能力,依法独立享有民事权利和承担民事义务的组织。

第五十八条 法人应当依法成立。法人应当有自己的名称、组织机构、住所、财产或者经费。法人成立的具体条件和程序,依照法律、行政法规的规定。

第四章 非法人组织

第一百零二条 非法人组织是不具有法人资格,但是能够依法以自己的名义从事民事活动的组织。非法人组织包括个人独资企业、合伙企业、不具有法人资格的专业服务机构等。

第五章 民事权利

第一百零九条 自然人的人身自由、人格尊严受法律保护。

第一百一十条 自然人享有生命权、身体权、健康权、姓名权、肖像权、名誉权、荣誉权、隐私权、婚姻自主权等权利。法人、非法人组织享有名称权、名誉权和荣誉权。

第一百一十八条 民事主体依法享有债权。债权是因合同、侵权行为、无因管理、不当得利以及法律的其他规定,权利人请求特定义务人为或者不为一定行为的权利。

第六章 民事法律行为

第一百四十三条 具备下列条件的民事法律行为有效:(一)行为人具有相应的民事行为能力;(二)意思表示真实;(三)不违反法律、行政法规的强制性规定,不违背公序良俗。

第一百四十四条 无民事行为能力人实施的民事法律行为无效。

第一百四十五条 限制民事行为能力人实施的纯获利益的民事法律行为或者与其年龄、智力、精神健康状况相适应的民事法律行为有效;实施的其他民事法律行为经法定代理人同意或者追认后有效。

第七章 代理

第一百六十一条 民事主体可以通过代理人实施民事法律行为。依照法律规定、当事人约定或者民事法律行为的性质,应当由本人亲自实施的民事法律行为,不得代理。

第一百六十二条 代理人在代理权限内,以被代理人名义实施的民事法律行为,对被代理人发生效力。

第八章 民事责任

第一百七十六条 民事主体依照法律规定或者按照当事人约定,履行民事义务,承担民事责任。

第一百七十七条 二人以上依法承担按份责任,能够确定责任大小的,各自承担相应的责任;难以确定责任大小的,平均承担责任。

第一百七十八条 二人以上依法承担连带责任的,权利人有权请求部分或者全部连带责任人承担责任。

第九章 诉讼时效

第一百八十八条 向人民法院请求保护民事权利的诉讼时效期间为三年。法律另有规定的,依照其规定。诉讼时效期间自权利人知道或者应当知道权利受到损害以及义务人之日起计算。法律另有规定的,依照其规定。但是,自权利受到损害之日起超过二十年的,人民法院不予保护,有特殊情况的,人民法院可以根据权利人的申请决定延长。

第一百九十六条 下列请求权不适用诉讼时效的规定:(一)请求停止侵害、排除妨碍、消除危险;(二)不动产物权和登记的动产物权的权利人请求返还财产;(三)请求支付抚养费、赡养费或者扶养费;(四)依法不适用诉讼时效的其他请求权。`;

// 婚姻家庭编
const MARRIAGE_FAMILY = `中华人民共和国民法典
第五编 婚姻家庭

第一千零四十条 本编调整因婚姻家庭产生的民事关系。

第一千零四十一条 婚姻家庭受国家保护。实行婚姻自由、一夫一妻、男女平等的婚姻制度。保护妇女、未成年人、老年人、残疾人的合法权益。

第一千零四十二条 禁止包办、买卖婚姻和其他干涉婚姻自由的行为。禁止借婚姻索取财物。禁止重婚。禁止有配偶者与他人同居。禁止家庭暴力。禁止家庭成员间的虐待和遗弃。

第一千零四十六条 结婚应当男女双方完全自愿,禁止任何一方对另一方加以强迫,禁止任何组织或者个人加以干涉。

第一千零四十七条 结婚年龄,男不得早于二十二周岁,女不得早于二十周岁。

第一千零五十一条 有下列情形之一的,婚姻无效:(一)重婚;(二)有禁止结婚的亲属关系;(三)未到法定婚龄。

第一千零五十四条 无效的或者被撤销的婚姻自始没有法律约束力,当事人不具有夫妻的权利和义务。

第一千零六十二条 夫妻在婚姻关系存续期间所得的下列财产,为夫妻的共同财产,归夫妻共同所有:(一)工资、奖金、劳务报酬;(二)生产、经营、投资的收益;(三)知识产权的收益;(四)继承或者受赠的财产,但是本法第一千零六十三条第三项规定的除外;(五)其他应当归共同所有的财产。夫妻对共同财产,有平等的处理权。

第一千零六十三条 下列财产为夫妻一方的个人财产:(一)一方的婚前财产;(二)一方因受到人身损害获得的赔偿或者补偿;(三)遗嘱或者赠与合同中确定只归一方的财产;(四)一方专用的生活用品;(五)其他应当归一方的财产。

第一千零六十四条 夫妻双方共同签名或者夫妻一方事后追认等共同意思表示所负的债务,以及夫妻一方在婚姻关系存续期间以个人名义为家庭日常生活需要所负的债务,属于夫妻共同债务。夫妻一方在婚姻关系存续期间以个人名义超出家庭日常生活需要所负的债务,不属于夫妻共同债务;但是,债权人能够证明该债务用于夫妻共同生活、共同生产经营或者基于夫妻双方共同意思表示的除外。

第一千零七十六条 夫妻双方自愿离婚的,应当签订书面离婚协议,并亲自到婚姻登记机关申请离婚登记。离婚协议应当载明双方自愿离婚的意思表示和对子女抚养、财产以及债务处理等事项协商一致的意见。

第一千零七十七条 自婚姻登记机关收到离婚登记申请之日起三十日内,任何一方不愿意离婚的,可以向婚姻登记机关撤回离婚登记申请。前款规定期限届满后三十日内,双方应当亲自到婚姻登记机关申请发给离婚证;未申请的,视为撤回离婚登记申请。

第一千零七十九条 夫妻一方要求离婚的,可以由有关组织进行调解或者直接向人民法院提起离婚诉讼。人民法院审理离婚案件,应当进行调解;如果感情确已破裂,调解无效的,应当准予离婚。有下列情形之一,调解无效的,应当准予离婚:(一)重婚或者与他人同居;(二)实施家庭暴力或者虐待、遗弃家庭成员;(三)有赌博、吸毒等恶习屡教不改;(四)因感情不和分居满二年;(五)其他导致夫妻感情破裂的情形。

第一千零八十四条 父母与子女间的关系,不因父母离婚而消除。离婚后,子女无论由父或者母直接抚养,仍是父母双方的子女。离婚后,不满两周岁的子女,以由母亲直接抚养为原则。已满两周岁的子女,父母双方对抚养问题协议不成的,由人民法院根据双方的具体情况,按照最有利于未成年子女的原则判决。子女已满八周岁的,应当尊重其真实意愿。

第一千零八十七条 离婚时,夫妻的共同财产由双方协议处理;协议不成的,由人民法院根据财产的具体情况,按照照顾子女、女方和无过错方权益的原则判决。

第一千零九十一条 有下列情形之一,导致离婚的,无过错方有权请求损害赔偿:(一)重婚;(二)与他人同居;(三)实施家庭暴力;(四)虐待、遗弃家庭成员;(五)有其他重大过错。`;

// 劳动法
const LABOR_LAW = `中华人民共和国劳动法

第十三条 妇女享有与男子平等的就业权利。在录用职工时,除国家规定的不适合妇女的工种或者岗位外,不得以性别为由拒绝录用妇女或者提高对妇女的录用标准。

第十六条 劳动合同是劳动者与用人单位确立劳动关系、明确双方权利和义务的协议。建立劳动关系应当订立劳动合同。

第二十一条 劳动合同可以约定试用期。试用期最长不得超过六个月。

第二十五条 劳动者有下列情形之一的,用人单位可以解除劳动合同:(一)在试用期间被证明不符合录用条件的;(二)严重违反劳动纪律或者用人单位规章制度的;(三)严重失职,营私舞弊,对用人单位利益造成重大损害的;(四)被依法追究刑事责任的。

第二十六条 有下列情形之一的,用人单位可以解除劳动合同,但是应当提前三十日以书面形式通知劳动者本人:(一)劳动者患病或者非因工负伤,医疗期满后,不能从事原工作也不能从事由用人单位另行安排的工作的;(二)劳动者不能胜任工作,经过培训或者调整工作岗位,仍不能胜任工作的;(三)劳动合同订立时所依据的客观情况发生重大变化,致使原劳动合同无法履行,经当事人协商不能就变更劳动合同达成协议的。

第二十九条 劳动者有下列情形之一的,用人单位不得依据本法第二十六条、第二十七条的规定解除劳动合同:(一)患职业病或者因工负伤并被确认丧失或者部分丧失劳动能力的;(二)患病或者负伤,在规定的医疗期内的;(三)女职工在孕期、产期、哺乳期内的;(四)法律、行政法规规定的其他情形。

第三十一条 劳动者解除劳动合同,应当提前三十日以书面形式通知用人单位。

第三十六条 国家实行劳动者每日工作时间不超过八小时、平均每周工作时间不超过四十四小时的工时制度。

第三十八条 用人单位应当保证劳动者每周至少休息一日。

第四十一条 用人单位由于生产经营需要,经与工会和劳动者协商后可以延长工作时间,一般每日不得超过一小时;因特殊原因需要延长工作时间的,在保障劳动者身体健康的条件下延长工作时间每日不得超过三小时,但是每月不得超过三十六小时。

第四十四条 有下列情形之一的,用人单位应当按照下列标准支付高于劳动者正常工作时间工资的工资报酬:(一)安排劳动者延长工作时间的,支付不低于工资的百分之一百五十的工资报酬;(二)休息日安排劳动者工作又不能安排补休的,支付不低于工资的百分之二百的工资报酬;(三)法定休假日安排劳动者工作的,支付不低于工资的百分之三百的工资报酬。

第四十五条 国家实行带薪年休假制度。劳动者连续工作一年以上的,享受带薪年休假。具体办法由国务院规定。

第四十八条 国家实行最低工资保障制度。最低工资的具体标准由省、自治区、直辖市人民政府规定,报国务院备案。用人单位支付劳动者的工资不得低于当地最低工资标准。

第五十条 工资应当以货币形式按月支付给劳动者本人。不得克扣或者无故拖欠劳动者的工资。

第七十七条 用人单位与劳动者发生劳动争议,当事人可以依法申请调解、仲裁、提起诉讼,也可以协商解决。调解原则适用于仲裁和诉讼程序。

第七十九条 劳动争议发生后,当事人可以向本单位劳动争议调解委员会申请调解;调解不成,当事人一方要求仲裁的,可以向劳动争议仲裁委员会申请仲裁。当事人一方也可以直接向劳动争议仲裁委员会申请仲裁。对仲裁裁决不服的,可以向人民法院提起诉讼。

第八十二条 提出仲裁要求的一方应当自劳动争议发生之日起六十日内向劳动争议仲裁委员会提出书面申请。仲裁裁决一般应在收到仲裁申请的六十日内作出。对仲裁裁决无异议的,当事人必须履行。`;

// 刑法相关
const CRIMINAL_LAW = `中华人民共和国刑法

第十七条 已满十六周岁的人犯罪,应当负刑事责任。已满十四周岁不满十六周岁的人,犯故意杀人、故意伤害致人重伤或者死亡、强奸、抢劫、贩卖毒品、放火、爆炸、投放危险物质罪的,应当负刑事责任。已满十二周岁不满十四周岁的人,犯故意杀人、故意伤害罪,致人死亡或者以特别残忍手段致人重伤造成严重残疾,情节恶劣,经最高人民检察院核准追诉的,应当负刑事责任。

第二十条 为了使国家、公共利益、本人或者他人的人身、财产和其他权利免受正在进行的不法侵害,而采取的制止不法侵害的行为,对不法侵害人造成损害的,属于正当防卫,不负刑事责任。正当防卫明显超过必要限度造成重大损害的,应当负刑事责任,但是应当减轻或者免除处罚。

第二百三十四条 故意伤害他人身体的,处三年以下有期徒刑、拘役或者管制。犯前款罪,致人重伤的,处三年以上十年以下有期徒刑;致人死亡或者以特别残忍手段致人重伤造成严重残疾的,处十年以上有期徒刑、无期徒刑或者死刑。本法另有规定的,依照规定。

第二百六十三条 以暴力、胁迫或者其他方法抢劫公私财物的,处三年以上十年以下有期徒刑,并处罚金;有下列情形之一的,处十年以上有期徒刑、无期徒刑或者死刑,并处罚金或者没收财产:(一)入户抢劫的;(二)在公共交通工具上抢劫的;(三)抢劫银行或者其他金融机构的;(四)多次抢劫或者抢劫数额巨大的;(五)抢劫致人重伤、死亡的;(六)冒充军警人员抢劫的;(七)持枪抢劫的;(八)抢劫军用物资或者抢险、救灾、救济物资的。

第二百六十四条 盗窃公私财物,数额较大的,或者多次盗窃、入户盗窃、携带凶器盗窃、扒窃的,处三年以下有期徒刑、拘役或者管制,并处或者单处罚金;数额巨大或者有其他严重情节的,处三年以上十年以下有期徒刑,并处罚金;数额特别巨大或者有其他特别严重情节的,处十年以上有期徒刑或者无期徒刑,并处罚金或者没收财产。

第二百六十六条 诈骗公私财物,数额较大的,处三年以下有期徒刑、拘役或者管制,并处或者单处罚金;数额巨大或者有其他严重情节的,处三年以上十年以下有期徒刑,并处罚金;数额特别巨大或者有其他特别严重情节的,处十年以上有期徒刑或者无期徒刑,并处罚金或者没收财产。本法另有规定的,依照规定。

第二百七十条 将代为保管的他人财物非法占为己有,数额较大,拒不退还的,处二年以下有期徒刑、拘役或者罚金;数额巨大或者有其他严重情节的,处二年以上五年以下有期徒刑,并处罚金。

第二百七十五条 故意毁坏公私财物,数额较大或者有其他严重情节的,处三年以下有期徒刑、拘役或者罚金;数额巨大或者有其他特别严重情节的,处三年以上七年以下有期徒刑。

第二百九十三条 有下列寻衅滋事行为之一,破坏社会秩序的,处五年以下有期徒刑、拘役或者管制:(一)随意殴打他人,情节恶劣的;(二)追逐、拦截、辱骂、恐吓他人,情节恶劣的;(三)强拿硬要或者任意损毁、占用公私财物,情节严重的;(四)在公共场所起哄闹事,造成公共场所秩序严重混乱的。

第三百一十二条 明知是犯罪所得及其产生的收益而予以窝藏、转移、收购、代为销售或者以其他方法掩饰、隐瞒的,处三年以下有期徒刑、拘役或者管制,并处或者单处罚金;情节严重的,处三年以上七年以下有期徒刑,并处罚金。`;

// 侵权责任编
const TORT_LIABILITY = `中华人民共和国民法典
第七编 侵权责任

第一千一百六十四条 本编调整因侵害民事权益产生的民事关系。

第一千一百六十五条 行为人因过错侵害他人民事权益造成损害的,应当承担侵权责任。依照法律规定推定行为人有过错,其不能证明自己没有过错的,应当承担侵权责任。

第一千一百六十六条 行为人造成他人民事权益损害,不论行为人有无过错,法律规定应当承担侵权责任的,依照其规定。

第一千一百七十九条 侵害他人造成人身损害的,应当赔偿医疗费、护理费、交通费、营养费、住院伙食补助费等为治疗和康复支出的合理费用,以及因误工减少的收入。造成残疾的,还应当赔偿辅助器具费和残疾赔偿金;造成死亡的,还应当赔偿丧葬费和死亡赔偿金。

第一千一百八十二条 侵害他人人身权益造成财产损失的,按照被侵权人因此受到的损失或者侵权人因此获得的利益赔偿;被侵权人因此受到的损失以及侵权人因此获得的利益难以确定,被侵权人和侵权人就连赔偿数额协商不一致,向人民法院提起诉讼的,由人民法院根据实际情况确定赔偿数额。

第一千一百八十三条 侵害自然人人身权益造成严重精神损害的,被侵权人有权请求精神损害赔偿。因故意或者重大过失侵害自然人具有人身意义的特定物造成严重精神损害的,被侵权人有权请求精神损害赔偿。

第一千一百八十四条 侵害他人财产的,财产损失按照损失发生时的市场价格或者其他合理方式计算。

第一千一百八十八条 无民事行为能力人、限制民事行为能力人造成他人损害的,由监护人承担侵权责任。监护人尽到监护职责的,可以减轻其侵权责任。

第一千一百九十一条 用人单位的工作人员因执行工作任务造成他人损害的,由用人单位承担侵权责任。用人单位承担侵权责任后,可以向有故意或者重大过失的工作人员追偿。

第一千一百九十二条 个人之间形成劳务关系,提供劳务一方因劳务造成他人损害的,由接受劳务一方承担侵权责任。接受劳务一方承担侵权责任后,可以向有故意或者重大过失的提供劳务一方追偿。提供劳务一方因劳务受到损害的,根据双方各自的过错承担相应的责任。

第一千二百零八条 机动车发生交通事故造成损害的,依照道路交通安全法律和本法的有关规定承担赔偿责任。

第一千二百一十三条 机动车发生交通事故造成损害,属于该机动车一方责任的,先由承保机动车强制保险的保险人在强制保险责任限额范围内予以赔偿;不足部分,由承保机动车商业保险的保险人按照保险合同的约定予以赔偿;仍然不足或者没有投保机动车商业保险的,由侵权人赔偿。

第一千二百五十四条 禁止从建筑物中抛掷物品。从建筑物中抛掷物品或者从建筑物上坠落的物品造成他人损害的,由侵权人依法承担侵权责任;经调查难以确定具体侵权人的,除能够证明自己不是侵权人的外,由可能加害的建筑物使用人给予补偿。可能加害的建筑物使用人补偿后,有权向侵权人追偿。`;

async function seed() {
  console.log("开始导入法律知识库种子数据...");

  const db = getDb();

  const docs = [
    {
      title: "中华人民共和国民法典 - 总则编",
      content: CIVIL_CODE_GENERAL,
      category: "民法",
      source: "国家法律法规数据库",
      docType: "law" as const,
    },
    {
      title: "中华人民共和国民法典 - 婚姻家庭编",
      content: MARRIAGE_FAMILY,
      category: "民法",
      source: "国家法律法规数据库",
      docType: "law" as const,
    },
    {
      title: "中华人民共和国劳动法",
      content: LABOR_LAW,
      category: "劳动法",
      source: "国家法律法规数据库",
      docType: "law" as const,
    },
    {
      title: "中华人民共和国刑法(精选)",
      content: CRIMINAL_LAW,
      category: "刑法",
      source: "国家法律法规数据库",
      docType: "law" as const,
    },
    {
      title: "中华人民共和国民法典 - 侵权责任编",
      content: TORT_LIABILITY,
      category: "民法",
      source: "国家法律法规数据库",
      docType: "law" as const,
    },
  ];

  for (const doc of docs) {
    const existing = await db
      .select()
      .from(knowledgeDocs)
      .where(eq(knowledgeDocs.title, doc.title))
      .limit(1);

    if (existing.length > 0) {
      console.log(`  跳过已存在: ${doc.title}`);
      continue;
    }

    const result = await db.insert(knowledgeDocs).values({
      title: doc.title,
      content: doc.content,
      category: doc.category,
      source: doc.source,
      docType: doc.docType,
      wordCount: doc.content.length,
      isBuiltIn: 1,
    });

    const docId = Number(result[0].insertId);

    // 按条文分块
    const articles = doc.content.split(/(?=第[一二三四五六七八九十百零]+条)/);
    let chunkIndex = 0;

    for (const article of articles) {
      const trimmed = article.trim();
      if (trimmed.length < 10) continue;

      if (trimmed.length > 600) {
        for (let i = 0; i < trimmed.length; i += 500) {
          const chunk = trimmed.slice(i, i + 500);
          await db.insert(documentChunks).values({
            docId,
            content: chunk,
            chunkIndex: chunkIndex++,
          });
        }
      } else {
        await db.insert(documentChunks).values({
          docId,
          content: trimmed,
          chunkIndex: chunkIndex++,
        });
      }
    }

    console.log(`  已导入: ${doc.title} (${chunkIndex} 分块)`);
  }

  console.log("法律知识库种子数据导入完成!");
}

seed().catch(console.error);
  • queries/connections.ts连接数据库客户端

/**
 * 什么是drizzle?
 * Drizzle 是一个专为 TypeScript 设计的数据库操作工具(ORM)
 * 能定义表的schema,并建表,初始化数据,做增删改查。可以通过链式写法.select().from.where的方式操作数据库
 * 类似java QueryDSL
 */
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "../lib/env";

// 把 @db/schema这个模块里所有导出的东西,一次性整体导入,并命名为 schema
// 注意这里@db,要在tsconfig.json和vite.config.ts都配置使用上。也可以使用相对路径:../../表示。
import * as schema from "@db/schema";

// ...schema 表示 把 schema对象里的所有属性,“原样复制一份”到新对象里**
const fullSchema = { ...schema };
// 声明一个便利instance,它的类型=drizzle(fullSchema)的返回值类型,只声明类型,不创建对象
// drizzle<...> 表示 创建数据库客户端
let instance: ReturnType<typeof drizzle<typeof fullSchema>>;

// 创建单利数据库连接
export function getDb(){
    if(!instance){
        instance=drizzle(env.databaseUrl, {
            // PlanetScale 是 MySQL + Vitess  跟普通mysql的区别是不支持外键,不能做某些alter,其实都能用,差不多
            mode:"planetscale",
            schema:fullSchema,
        })
    }
    return instance;
}
  • RAG知识库处理 rag.ts

通过TF-IDF,输入词向量与数据库每条数据的词向量的余弦相似度,私有库内容.正式RAG的精华所在
 

import { getDb } from "../queries/connection";
import { documentChunks, knowledgeDocs } from "@db/schema";
import { eq, sql } from "drizzle-orm";
export interface SearchResult {
    chunkId: number;
    docId: number;
    content: string;
    similarity: number;
    docTitle: string;
    docCategory: string;
}
// 中文分词:按标点、空格分句,然后按字/词提取
function tokenize(text: string): string[] {
    const cleaned = text.replace(/[\s\n\r\t]+/g, " ")
        .replace(/[,。、;:?!""''()《》【】]/g, " ")
        .trim();
    const tokens: string[] = [];
    //   把上面那些符合都变成空格后,下面就直接用空格分割了
    const words = cleaned.split(/\s+/);

    //   这里是滑动窗口形式的分词分割,不用过的研究
    for (const word of words) {
        if (word.length <= 1) continue;
        tokens.push(word);
        for (let i = 0; i < word.length - 1; i++) {
            tokens.push(word.slice(i, i + 2));
            if (i < word.length - 2) {
                tokens.push(word.slice(i, i + 3));
            }
        }
    }
    return tokens;

}
/**
 * 计算“词频(Term Frequency)” 每个 token 在文本中出现的相对频率
 * 统计每个词出现了多少次 → 再除以总词数 → 得到归一化的 TF
 * @param tokens 
 * @returns  Map<string, number> 注意声明方法返回类型的方式
 */
function computeTF(tokens: string[]): Map<string, number> {
    const tf = new Map<string, number>();
    for (const token of tokens) {
        tf.set(token, (tf.get(token) || 0) + 1);
    }
    const total = tokens.length;
    for (const [key, val] of tf) {
        tf.set(key, val / total);
    }
    return tf;
}

/**
 *  计算“逆文档频率(IDF)”
 * 衡量 一个词在整个知识库中有多“稀有”
 * 这里计算的是词出现的文档个数,不是词的总出现次数
 * @returns 
 */
async function computeGlobalIDF(): Promise<Map<string, number>> {
    const db = getDb();
    const chunks = await db
        .select({ content: documentChunks.content })
        .from(documentChunks);

    const docFreq = new Map<string, number>();
    const totalDocs = chunks.length;

    if (totalDocs === 0) return new Map();

    for (const chunk of chunks) {
        const tokens = new Set(tokenize(chunk.content));
        for (const token of tokens) {
            docFreq.set(token, (docFreq.get(token) || 0) + 1);
        }
    }
    const idf = new Map<string, number>();
    for (const [term, df] of docFreq) {
        idf.set(term, Math.log(totalDocs / (df + 1)) + 1);
    }

    return idf;
}

/**
 *  把“词频(TF)”和“稀缺度(IDF)”乘起来  得到 综合重要性
 * @param tf 这个词在当前文档有多常见
 * @param idf 这个词在整个库有多稀有
 * @returns 综合重要性
 */
function computeTFIDF(
    tf: Map<string, number>,
    idf: Map<string, number>
): Map<string, number> {
    const tfidf = new Map<string, number>();
    for (const [term, tfVal] of tf) {
        const idfVal = idf.get(term) || 0;
        tfidf.set(term, tfVal * idfVal);
    }
    return tfidf;
}

/**
 * 计算两个“稀疏向量”之间的余弦相似度
 * 
 * @param vecA 向量A
 * @param vecB 向量B
 * @returns cos(A*B)=(A * B)/sqrt(A*A) *sqrt(B*B)
 */
function cosineSimilarity(
    vecA: Map<string, number>,
    vecB: Map<string, number>
): number {
    let dotProduct = 0;
    let normA = 0;
    let normB = 0;

    for (const [term, valA] of vecA) {
        //计算A的模,也就是所有词频率的累加值
        normA += valA * valA;
        //取B中对应的值  稀疏向量关键技巧
        const valB = vecB.get(term) || 0;

        dotProduct += valA * valB;
    }

    for (const valB of vecB.values()) {
        normB += valB * valB;
    }

    if (normA === 0 || normB === 0) return 0;
    return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}


let globalIDF: Map<string, number> | null = null;
let lastChunkCount = 0;

/**
 *  只在 文档数量变化时​ 才重新计算, 否则直接返回缓存
 * @returns 
 */
async function getIDF(): Promise<Map<string, number>> {
    const db = getDb();
    const countResult = await db
        .select({ count: sql<number>`count(*)` })
        .from(documentChunks);
    const count = countResult[0]?.count || 0;

    if (!globalIDF || count !== lastChunkCount) {
        globalIDF = await computeGlobalIDF();
        lastChunkCount = count;
    }

    return globalIDF;
}

export async function searchRelevantChunks(
    query: string,
    // 这里声明对象类型,字段间用;分割,也可以用,号,都一样,推荐用;
    options: {
        topK?: number;
        minScore?: number;
        category?: string;
    } = {}
): Promise<SearchResult[]> {
    const { topK = 5, minScore = 0.05, category } = options;
    const db = getDb();
    let chunks: Array<{
        id: number; docId: number; content: string; tfidfVector: Record<string, number> | null;
        chunkIndex: number; keywords: string | null; createdAt: Date
    }>;
    // 下面这个if总结就是 ,如果参数中带有查询文档库中的指定数据,那就返回指定数据,否则就查询表所有数据
    if (category) {
        const docs = await db
            .select({ id: knowledgeDocs.id })
            .from(knowledgeDocs)
            .where(eq(knowledgeDocs.category, category));
        //   便利查库结果,读取其中的id字段,并存为数组
        const docIds = docs.map((d: { id: number }) => d.id);
        if (docIds.length === 0) return [];
        chunks = await db
            .select()
            .from(documentChunks)
            .where(sql`${documentChunks.docId} IN (${docIds.join(",")})`);
    } else {
        chunks = await db.select().from(documentChunks);
    }

    if (chunks.length === 0) return [];


    // 解释下面所有代码的作用,主要看这一行:cosineSimilarity(queryTFIDF, chunkTFIDF);
    // 就是计算用户输入的查询(tf-idf:词频–逆文档频率,每个词在文档中的重要性)与知识库中每个句子的相似度。
    // 只取相似度较高的。
    const queryTokens = tokenize(query);
    const queryTF = computeTF(queryTokens);
    const idf = await getIDF();
    const queryTFIDF = computeTFIDF(queryTF, idf);
    const results: SearchResult[] = [];
    for (const chunk of chunks) {
        let chunkTFIDF: Map<string, number>;
        // tfidVector是稀疏TF-IDF向量 通常长这样
        /**
         * {
            "宪法": 0.42,
            "人工": 0.31,
            "智能": 0.27,
            "责任": 0.18
            }
         */
        if (chunk.tfidfVector) {
            chunkTFIDF = new Map(Object.entries(chunk.tfidfVector as Record<string, number>));
        } else {
            const tokens = tokenize(chunk.content);
            const tf = computeTF(tokens);
            chunkTFIDF = computeTFIDF(tf, idf);
        }
        const similarity = cosineSimilarity(queryTFIDF, chunkTFIDF);

        if (similarity >= minScore) {
            const doc = await db
                .select()
                .from(knowledgeDocs)
                .where(eq(knowledgeDocs.id, chunk.docId))
                .limit(1);

            results.push({
                chunkId: chunk.id,
                docId: chunk.docId,
                content: chunk.content,
                similarity,
                docTitle: doc[0]?.title || "未知文档",
                docCategory: doc[0]?.category || "未分类",
            });
        }
    }

    results.sort((a, b) => b.similarity - a.similarity);
    return results.slice(0, topK);
}

/**
 * 按字符长度切分文本,并在句号处尽量断句,同时保留上下文重叠
 * @param content 
 * @param chunkSize 
 * @param overlap 
 * @returns 
 */
export function splitIntoChunks(
    content: string,
    chunkSize: number = 500,
    overlap: number = 100,
): string[] {
    const chunks: string[] = [];
    let start = 0;
    while (start < content.length) {
        const end = Math.min(start + chunkSize, content.length);
        let splitPoint = end;
        if (end < content.length) {
            const lastPeriod = content.lastIndexOf("。", start + chunkSize);
            if (lastPeriod > start + chunkSize * 0.5) {
                splitPoint = lastPeriod + 1;
            }
    }
    chunks.push(content.slice(start, splitPoint).trim());
    start = splitPoint - overlap;
    if (start < splitPoint * 0.5) start = splitPoint;
}

return chunks.filter((c) => c.length > 20);
}

/**
 * 为某个文档的所有 chunk 预计算 TF‑IDF 向量并写回数据库
 * @param docId 
 */
export async function indexDocumentChunks(docId: number) {
  const db = getDb();

  const chunks = await db
    .select()
    .from(documentChunks)
    .where(eq(documentChunks.docId, docId));
  //解释下 这里调用函数为啥要用await,因为getIdf是用async修饰了。
  // 表示异步,但是我们不想这个方法异步操作,而是同步,就需要用await
  // 所以await和async是一起存在的
 // 还有因为数据库是I/0操作,也就是要等,但是Node.js是单线程的,不能因为这个操作就卡住其他的功能,所以用都用了异步操作
 // 而java这种语言就是多线程,即使一个线程卡住,不影响其他的请求。
  const idf = await getIDF();

  for (const chunk of chunks) {
    const tokens = tokenize(chunk.content);
    const tf = computeTF(tokens);
    const tfidf = computeTFIDF(tf, idf);

    const vectorObj: Record<string, number> = {};
    for (const [k, v] of tfidf) {
      vectorObj[k] = v;
    }

    await db
      .update(documentChunks)
      .set({
        tfidfVector: vectorObj,
        keywords: Array.from(tf.keys()).slice(0, 20).join(","),
      })
      .where(eq(documentChunks.id, chunk.id));
        }

  await db
    .update(knowledgeDocs)
    .set({ chunkCount: chunks.length })
    .where(eq(knowledgeDocs.id, docId));
    globalIDF = null;
}


export async function clearAllChunks() {
  const db = getDb();
  await db.delete(documentChunks);
  globalIDF = null;
  lastChunkCount = 0;
}

/**
 *  统计知识库的整体规模与分类信息
 * 
 * 注意这个方法没有定义返回类型,因为TS 会自动推断出返回类型 Promise<{
  totalDocs: number;
  totalChunks: number;
  categories: string[];
}>
  但是建议 强制写返回类型,防止不小心改返回值,可以在编码阶段就发现错误。
 * @returns 
 */
export async function getKnowledgeStats() {
  const db = getDb();
  const docsCount = await db
    .select({ count: sql<number>`count(*)` })
    .from(knowledgeDocs);
  const chunksCount = await db
    .select({ count: sql<number>`count(*)` })
    .from(documentChunks);
  const categories = await db
    .selectDistinct({ category: knowledgeDocs.category })
    .from(knowledgeDocs);

  return {
    totalDocs: docsCount[0]?.count || 0,
    totalChunks: chunksCount[0]?.count || 0,
    categories: categories.map((c: { category: string }) => c.category),
  };
}
  • ollama.ts对接ollama暴露的接口,目前只用到了POST /api/chat:多轮对话(对应 ollama run交互模式)接口,后期可接入高级embedding接口,作为微调

/**
 * Ollama 客户端封装
 * 支持对话、流式输出、模型管理
 * 
 * 先了解下ollama对外暴露的接口有哪些
 * Ollama 默认在 http://localhost:11434​ 暴露两套 REST 接口:一套原生 Ollama API,一套OpenAI 兼容 API。

原生 Ollama API(/api/*)
POST /api/generate:单轮文本生成(对应 ollama run)
POST /api/chat:多轮对话(对应 ollama run交互模式)
POST /api/embeddings:生成文本向量嵌入(RAG 用)
GET /api/tags:列出本地已拉取模型(对应 ollama list)
POST /api/show:查看模型详情(参数/模板/license 等)
POST /api/pull:从仓库拉取模型(对应 ollama pull)
DELETE /api/delete:删除本地模型(对应 ollama rm)
POST /api/create:从 Modelfile 创建自定义模型(对应 ollama create)
POST /api/copy:复制模型(对应 ollama cp)
POST /api/push:上传模型到仓库(对应 ollama push)
GET /api/ps:查看当前加载到内存的模型[caption:5]
OpenAI 兼容 API(/v1/*)
POST /v1/chat/completions:兼容 OpenAI 聊天接口,可直接换 baseURL 使用
POST /v1/embeddings:兼容 OpenAI embeddings 接口
GET /v1/models​ 等:模型列举接口
你代码里如果用 stream: true(默认),/api/generate和 /api/chat会流式返回 NDJSON,每个 chunk 是一行 JSON,
最后一行 done: true,表示输出结束
 */

const OLLAMA_BASE_URL = process.env.OLLAMA_URL || "http://localhost:11434";

export interface OllamaMessage {
    role: "system" | "user" | "assistant";
    content: string;
}
export interface OllamaOptions {
    temperature?: number;
    top_p?: number;
    top_k?: number;
    num_ctx?: number;
    repeat_penalty?: number;
    num_predict?: number;
    seed?: number;
    stop?: string[];
}


export interface OllamaModelInfo {
    name: string;
    size: number;
    parameter_size?: string;
    quantization_level?: string;
    format?: string;
    family?: string;
    families?: string[];
    parent_model?: string;
}
// 检查 Ollama 是否运行
export async function checkOllamaStatus(): Promise<{
    ok: boolean;
    version?: string;
    error?: string;
}> {
    try {
        const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
            method: "GET",
            headers: { "Content-Type": "application/json" },
        });

        if (!res.ok) {
            return { ok: false, error: `Ollama 返回状态码 ${res.status}` };
        }

        const data = (await res.json()) as { models?: OllamaModelInfo[] };
        return {
            ok: true,
            version: `${data.models?.length || 0} 个模型可用`,
        };
    } catch (err) {
        return {
            ok: false,
            error: `无法连接到 Ollama: ${err instanceof Error ? err.message : String(err)}`,
        };
    }
}

// 获取本地模型列表
export async function listModels(): Promise<OllamaModelInfo[]> {
    try {
        /**
         * fetch是 浏览器和现代 Node.js 里用来发 HTTP 请求的“标准 API”。
         * 常见的通用请求方式是:
         * fetch(url, {
      method: "POST",        // GET | POST | PUT | DELETE
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer xxx",
      },
      body: JSON.stringify({
        model: "gpt-4",
        messages: [],
      }),
    });
         */
        const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`);
        if (!res.ok) return [];
        const data = (await res.json()) as { models?: OllamaModelInfo[] };

        // 过滤出适合对话的模型
        const chatModels = (data.models || []).filter((m) => {
            const name = m.name?.toLowerCase() || "";
            return (
                name.includes("qwen") ||
                name.includes("llama") ||
                name.includes("mistral") ||
                name.includes("gemma") ||
                name.includes("chat") ||
                name.includes("instruct")
            );
        });

        return chatModels.length > 0 ? chatModels : (data.models || []);
    } catch {
        return [];
    }
}


// 非流式对话
export async function chat(
    model: string,
    messages: OllamaMessage[],
    options: OllamaOptions = {}
): Promise<{ content: string; done: boolean }> {
    const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            model,
            messages,
            //  这个是否要流式输出的决定性参数
            stream: false,
            options: {
                temperature: options.temperature ?? 0.7,
                top_p: options.top_p ?? 0.9,
                top_k: options.top_k ?? 40,
                num_ctx: options.num_ctx ?? 4096,
                repeat_penalty: options.repeat_penalty ?? 1.1,
                num_predict: options.num_predict ?? 2048,
                seed: options.seed,
                stop: options.stop,
            },
        }),
    });

    if (!res.ok) {
        throw new Error(`Ollama API 错误: ${res.status} ${await res.text()}`);
    }

    const data = (await res.json()) as {
        message?: { content?: string };
        done?: boolean;
    };

    return {
        content: data.message?.content || "",
        done: data.done ?? true,
    };
}

// 流式对话(返回 ReadableStream)
export function chatStream(
    model: string,
    messages: OllamaMessage[],
    options: OllamaOptions = {}
): ReadableStream<Uint8Array> {
    const encoder = new TextEncoder();

    return new ReadableStream({
        async start(controller) {
            try {
                const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        model,
                        messages,
                        stream: true,
                        options: {
                            temperature: options.temperature ?? 0.7,
                            top_p: options.top_p ?? 0.9,
                            top_k: options.top_k ?? 40,
                            num_ctx: options.num_ctx ?? 4096,
                            repeat_penalty: options.repeat_penalty ?? 1.1,
                            num_predict: options.num_predict ?? 2048,
                        },
                    }),
                });

                if (!res.ok || !res.body) {
                    controller.error(new Error(`Ollama API 错误: ${res.status}`));
                    return;
                }

                const reader = res.body.getReader();
                const decoder = new TextDecoder();

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value, { stream: true });
                    const lines = chunk.split("\n").filter((l) => l.trim());

                    for (const line of lines) {
                        try {
                            const json = JSON.parse(line) as {
                                message?: { content?: string };
                                done?: boolean;
                                eval_count?: number;
                                prompt_eval_count?: number;
                            };

                            if (json.message?.content) {
                                controller.enqueue(
                                    encoder.encode(
                                        JSON.stringify({
                                            type: "token",
                                            content: json.message.content,
                                        }) + "\n"
                                    )
                                );
                            }

                            if (json.done) {
                                controller.enqueue(
                                    encoder.encode(
                                        JSON.stringify({
                                            type: "done",
                                            stats: {
                                                evalCount: json.eval_count,
                                                promptEvalCount: json.prompt_eval_count,
                                            },
                                        }) + "\n"
                                    )
                                );
                                controller.close();
                                return;
                            }
                        } catch {
                            // 忽略解析失败的行
                        }
                    }
                }

                controller.close();
            } catch (err) {
                controller.error(err);
            }
        },
    });
}

// 生成 Embedding(用于高级 RAG)
export async function generateEmbedding(
    model: string,
    text: string
): Promise<number[]> {
    try {
        const res = await fetch(`${OLLAMA_BASE_URL}/api/embeddings`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ model, prompt: text }),
        });

        if (!res.ok) return [];

        const data = (await res.json()) as { embedding?: number[] };
        return data.embedding || [];
    } catch {
        return [];
    }
}

// 默认系统提示词(法律助手)
export function getLegalSystemPrompt(sources?: string[]): string {
    let prompt = `你是一位专业的中国法律顾问,精通宪法、民法典、刑法、行政法、商法、劳动法等各领域的法律法规。

工作原则:
1. 基于用户提供的参考资料回答问题,优先引用具体法条
2. 如果问题涉及多个法律领域,分层次说明
3. 对复杂问题,先给出结论,再展开法律依据
4. 不确定的问题明确告知用户需要咨询专业律师
5. 回答使用中文,保持专业、客观、严谨
6. 引用法条时标注法律名称和具体条款`;

    if (sources && sources.length > 0) {
        prompt += `\n\n参考资料:\n${sources.join("\n---\n")}`;
    }

    return prompt;
}
  • drizzle.config.ts配置文件可以在不启动后端的情况下操作数据库,主要是drizzle-kit客户端操作数据库 可以通过npm run db:push在启动服务前初始化数据库

import "dotenv/config";
import { defineConfig } from "drizzle-kit";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error("DATABASE_URL is required to run drizzle commands");
}

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./db/migrations",
  dialect: "mysql",
  dbCredentials: {
    url: connectionString,
  },
});
  • package.json和package-lock.json

这里主要写下package.json中的脚本信息。对于dependencies依赖包的控制我们不要手动改,都通过npm install ***引入即可,这里会自动修改的。

  "scripts": {
    "dev": "vite ",
    "build": "vite build && esbuild api/boot.ts --platform=node --bundle --format=esm --outdir=dist --banner:js=\"import { createRequire } from 'module';const require = createRequire(import.meta.url);\"",
    "lint": "eslint .",
    "start": "vite",
    "preview": "vite preview",
    "check": "tsc -b",
    "format": "prettier --write .",
    "test": "vitest run",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push"
  },

  • tsconfig.json 负责引入tsconfig.node.json tsconfig.server.json  tsconfig.app.json

对这里的认知有点模糊,简单介绍就是:

tsconfig.app.json是指定前端信息的包括,前端代码目录。

tsconfig.server.json是指定后端代码的。

tsconfig.node.json是指定启动前的操作脚本信息,也就是非前后端的配置信息文件的,属于自己指定自己了。

记录问题:在drizzle.config.ts 中就是无法引入 process.env.DATABASE_URL; 报process不存在。但是在node_modules包管理中确实是存在的,怎么无法引入。后来在tsconfig三个文件的include配置中都把drizzle.config.ts加进去就行了。所以tsconfig对于指定代码位置很重要。

tsconfig.json

/**
* 解释下tsconfig.json的作用 跟vite.config的区别?
* 二者要结合使用。先tsconfig帮助认识代码,比如说管理文件间的调用。vite.config管跑起来怎么处理
* 至于  "@db/*": ["db/*"]就是 “当你看到 @db/schema,就当成 db/schema处理”
*/
{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.app.json"
    },
    {
      "path": "./tsconfig.node.json"
    },
    {
      "path": "./tsconfig.server.json"
    }
  ],
  
  "compilerOptions": {
    "types": [
      "node"
    ],
    "moduleResolution": "node",
    "baseUrl": ".",
    "ignoreDeprecations": "6.0",
    "noEmitOnError": true,
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "@contracts/*": [
        "./contracts/*"
      ],
      "@db/*": [
        "./db/*"
      ]
    }
  }
}

tsconfig.app.json

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "types": [
      "vite/client",
      "node"
    ],
    "skipLibCheck": true,
    "baseUrl": ".",
      "ignoreDeprecations": "6.0",
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "@contracts/*": [
        "./contracts/*"
      ],
      "@db/*": [
        "./db/*"
      ]
    },
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": [
    "src"
  ]
}

tsconfig.node.json

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "es2023",
    "lib": [
      "ES2023"
    ],
    "module": "esnext",
    "types": [
      "node"
    ],
    "skipLibCheck": true,
    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": [
    "vite.config.ts"
  ]
}

tsconfig.server.json

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
    "target": "ES2022",
    "lib": [
      "ES2022"
    ],
    "module": "ESNext",
    "types": [
      "node"
    ],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "esModuleInterop": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": ".",
    "ignoreDeprecations": "6.0",
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "@contracts/*": [
        "./contracts/*"
      ],
      "@db/*": [
        "./db/*"
      ]
    }
  },
  "include": [
    "api",
    "contracts",
    "db",
    "drizzle.config.ts"
  ]
}
  • 最后是vite.config.ts

相对于之前 主要加了@db的引入别名配置,也很重要
 

import { defineConfig } from 'vite'
import path from "path"
// import.meta.dirname 是获取当前文件所在目录的绝对路径
const __dirname = import.meta.dirname
import devServer from "@hono/vite-dev-server"
import react from '@vitejs/plugin-react'

// 什么是vite?是一个前端构建工具+开发服务器。
// 在我们这个项目中 vite需要启动开发服务器,需要ts,tsx为js.还能通过devServer集成后端Hono
// vite只是在开发环境才有的,方便我们开发,生产是没有的
// 这里配置devServer 才能连接到后端文件入口,才都能访问成功。

// vite怎么引入环境变量?不用我们手动指定,都是约定的.env文件
// 引入环境变量方式 Node 端用 process.env 前端用 import.meta.env
// https://vite.dev/config/
export default defineConfig({
  plugins: [
    devServer({
      entry: "api/boot.ts",
      exclude: [/^\/(?!api\/).*$/]
    }),
    react()],
  server: {
    port: 3000, // 👈 改成你想要的端口
    strictPort: true, // 可选:端口被占用直接报错(而不是换端口)
    host: true, // 可选:允许局域网访问
    open: false,// 启动时不会自动打开浏览器
  },
  resolve: {
    alias: {
      "@db": path.resolve(__dirname, "./db"),
      // "db": path.resolve(__dirname, "./db"),

    },
  },
})

7. 执行数据库脚本,启动服务,测试接口,日志

build后,执行npm run db:push(见package.json的配置,-》通过drizzle执行schema表结构)

执行命令 npx tsx db/seed.ts 就能够执行数据库初始化脚本,能自动建表、导入数据。

如果执行seed失败,可能需要先设置环境变量,因为这个脚本是在项目启动前执行的。

$env:DATABASE_URL="mysql://root:root@localhost:3306/legal_ai"

postman调接口:

http://localhost:3000/api/chat/stream

请求体:

{"conversationId":12,"content":"劳动合同纠纷如何处理?","model":"qwen2.5:7b","useRAG":true,"temperature":1.4,"topP":0.4,"topK":40,"numCtx":4096,"repeatPenalty":1.1}

日志:

PS D:\workplace\rag-tf-tf-legal-ai-app> npm run dev

> rag-tf-tf-legal-ai-app@0.0.0 dev
> vite


  VITE v7.3.0  ready in 576 ms

  ➜  Local:   http://localhost:3000/
  ➜  press h + enter to show help
sddssd

========================================
[Chat] 收到新请求: "劳动合同纠纷如何处理?..."
[Chat] 模型: qwen2.5:7b | RAG: true
========================================

[Chat] 继续对话: ID=12
[Chat] 用户消息已保存(11 字)
[RAG]开始检索知识库。。。
[RAG] 警告!未找到相关内容(耗时 6ms),将使用模型自身知识回答

[Chat] 系统提示词长度: 191 字
[Chat] 历史消息: 3 条

[Ollama] 发送请求到 http://localhost:11434
[Ollama] 模型: qwen2.5:7b
[Ollama] 参数: temperature=1.4, top_p=0.4, num_ctx=4096
[Ollama] 提示词:[object Object],[object Object],[object Object],[object Object]
[Ollama] 连接成功(耗时 31168ms),开始接收流...


========================================
[Chat] 生成完成!总耗时 95156ms
[Chat] 生成 token 数: ~506
[Chat] 回复长度: 947 字
[Chat] 使用了 0 条参考资料
========================================

sddssd

========================================
[Chat] 收到新请求: "劳动合同纠纷如何处理?..."
[Chat] 模型: qwen2.5:7b | RAG: true
========================================

[Chat] 继续对话: ID=12
[Chat] 用户消息已保存(11 字)
[RAG]开始检索知识库。。。
[RAG] 警告!未找到相关内容(耗时 2ms),将使用模型自身知识回答

[Chat] 系统提示词长度: 191 字
[Chat] 历史消息: 5 条

[Ollama] 发送请求到 http://localhost:11434
[Ollama] 模型: qwen2.5:7b
[Ollama] 参数: temperature=1.4, top_p=0.4, num_ctx=4096
[Ollama] 提示词: [
  {
    "role": "system",
    "content": "你是一位专业的中国法律顾问,精通宪法、民法典、刑法、行政法、商法、劳动法等各领域的法律法规。\n\n工作原则:\n1. 基于用户提供的参考资料回答问题,优先引用具体法条\n2. 如果问题涉及多个法律领域,分层次说明\n3. 对复杂问题,先给出结论,再展开法律依据\n4. 不确定的问题明确告知用户需要咨询专业律师\n5. 回答使用中文,保持专业、客观、严谨\n6. 引用法条时标注法律名称和具体条款"
  },
  {
    "role": "user",
    "content": "劳动合同纠纷如何处理?"
  },
  {
    "role": "assistant",
    "content": "劳动合同纠纷的处理通常涉及以下几个步骤:\n\n### 1. 协商解决\n\n首先,双方当事人应尽量通过协商的方式解决争议。根据《中华人民共和国劳动争议调解仲裁法》第五条的规定:“发生劳动争议,当事人可以依法申请调解、仲裁、提起诉讼,也可以协商解决。”\n\n### 2. 调解\n\n如果协商不成,可以根据劳动合同中的约定或法律规定向本单位的劳动争议调解委员会申请调解;或者在没有约定的情况下,向第三方如工会组织等申请调解。根据《中华人民共和国劳动争议调解仲裁法》第十条的规定:“发生劳动争议,当事人可以到下列调解组织申请调解:(一)企业劳动争议调解委员会;(二)依法设立的基层人民调解组织;(三)在乡镇、街道设立的具有劳动争议调解职能的组织。”\n\n### 3. 仲裁\n\n如果调解不成或未达成协议,当事人可向劳动争议仲裁委员会申请仲裁。根据《中华人民共和国劳动争议调解仲裁法》第四条的规定:“发生劳动争议,劳动者可以与用人单位协商,也可以请工会或者第三方共同与用人单位协商,达成和解协议。”以及第五条的规定:“发生劳动争议,当事人不愿协商、协商不成或者达成和解协议后不履行的,可以向调解组织申请调解;不愿调解、调解不成或者达成调解协议后不履行的,可以向劳动争议仲裁委员会申请仲裁。”\n\n### 4. 诉讼\n\n对仲裁裁决不服的,可依法向人民法院提起诉讼。根据《中华人民共和国劳动争议调解仲裁法》第五十三条的规定:“劳动争议当事人对仲裁裁决不服的,可以自收到仲裁裁决书之日起十五日内向人民法院提起诉讼。”需要注意的是,如果在法定期限内未提起诉讼,则仲裁裁决将发生法律效力。\n\n### 5. 法律援助\n\n对于经济困难的劳动者,可以根据《中华人民共和国法律援助条例》申请法律援助。根据该条例第二条的规定:“本条例所称法律援助,是国家对经济困难或者特殊案件的当事人给予帮助,使其获得必要的法律服务的一种法律保障制度。”\n\n在处理劳动合同纠纷时,建议尽量通过协商解决争议;如需进一步协助,可以寻求专业律师的帮助以确保合法权益得到充分保护。"
  },
  {
    "role": "user",
    "content": "劳动合同纠纷如何处理?"
  },
  {
    "role": "assistant",
    "content": "劳动合同纠纷的处理通常涉及以下几个步骤:\n\n### 1. 协商解决\n\n首先,双方当事人应尽量通过协商的方式解决争议。根据《中华人民共和国劳动争议调解仲裁法》第五条的规定:“发生劳动争议,当事人可以依法申请调解、仲裁、提起诉讼,也可以协商解决。”\n\n### 2. 调解\n\n如果协商不成,可以根据劳动合同中的约定或法律规定向本单位的劳动争议调解委员会申请调解;或者在没有约定的情况下,向第三方如工会组织等申请调解。根据《中华人民共和国劳动争议调解仲裁法》第十条的规定:“发生劳动争议,当事人可以到下列调解组织申请调解:(一)企业劳动争议调解委员会;(二)依法设立的基层人民调解组织;(三)在乡镇、街道设立的具有劳动争议调解职能的组织。”\n\n### 3. 仲裁\n\n如果调解不成或未达成协议,当事人可向劳动争议仲裁委员会申请仲裁。根据《中华人民共和国劳动争议调解仲裁法》第四条的规定:“发生劳动争议,劳动者可以与用人单位协商,也可以请工会或者第三方共同与用人单位协商,达成和解协议。”以及第五条的规定:“发生劳动争议,当事人不愿协商、协商不成或者达成和解协议后不履行的,可以向调解组织申请调解;不愿调解、调解不成或者达成调解协议后不履行的,可以向劳动争议仲裁委员会申请仲裁。”\n\n### 4. 诉讼\n\n对仲裁裁决不服的,可依法向人民法院提起诉讼。根据《中华人民共和国劳动争议调解仲裁法》第五十三条的规定:“劳动争议当事人对仲裁裁决不服的,可以自收到仲裁裁决书之日起十五日内向人民法院提起诉讼。”需要注意的是,如果在法定期限内未提起诉讼,则仲裁裁决将发生法律效力。\n\n### 5. 法律援助\n\n对于经济困难的劳动者,可以根据《中华人民共和国法律援助条例》申请法律援助。根据该条例第二条的规定:“本条例所称法律援助,是国家对经济困难或者特殊案件的当事人给予帮助,使其获得必要的法律服务的一种法律保障制度。”\n\n### 结论\n劳动合同纠纷通常可以通过协商、调解和仲裁等途径解决。如果这些方式无法解决问题,则可以向人民法院提起诉讼。在处理过程中,建议尽量通过协商解决争议;如需进一步协助,可以寻求专业律师的帮助以确保合法权益得到充分保护。\n\n希望以上信息对您有所帮助!如有具体问题或需要进一步咨询,请随时告知。"
  },
  {
    "role": "user",
    "content": "劳动合同纠纷如何处理?"
  }
]
[Ollama] 连接成功(耗时 53086ms),开始接收流...


========================================
[Chat] 生成完成!总耗时 123886ms
[Chat] 生成 token 数: ~532
[Chat] 回复长度: 995 字
[Chat] 使用了 0 条参考资料
========================================

可以看到请求ollama的内容,有用户输入的,有助手录入的也就是rag检索增强 后的结果。还有系统提示词。

8.打包 运行,下载源码地址 等命令

如果我们要打包压缩项目,不能把依赖包内容也打进来,所以需要类似java 中mvn clean的操作,然后再从本地的npm仓库中下载,也是不需要网络的。

查看本地npm仓库地址:

npm config get cache

移除项目中的node_modules:


Remove-Item -Recurse -Force node_modules

重新安装

npm install

等一会后,

然后打包 运行

 npm run build
 npm run dev

源码地址:https://download.csdn.net/download/csdnliuxin123524/92902541

下载下来后,放到vscode中,执行上面的命令就能运行了。当然了要先安装好npm。

9.总体项目说明:

React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

@vitejs/plugin-react uses Oxc
@vitejs/plugin-react-swc uses SWC
React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.

Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...

      // Remove tseslint.configs.recommended and replace with this
      tseslint.configs.recommendedTypeChecked,
      // Alternatively, use this for stricter rules
      tseslint.configs.strictTypeChecked,
      // Optionally, add this for stylistic rules
      tseslint.configs.stylisticTypeChecked,

      // Other configs...
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
You can also install eslint-plugin-react-x and eslint-plugin-react-dom for React-specific lint rules:

// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...
      // Enable lint rules for React
      reactX.configs['recommended-typescript'],
      // Enable lint rules for React DOM
      reactDom.configs.recommended,
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
法律 AI 助手 - 使用指南
一、React 基础概念(快速入门)
1. 什么是 React?
React 是一个用于构建用户界面的 JavaScript 库。核心思想:

数据(State)变化 → 界面自动更新
你不需要手动操作 DOM(如 document.getElementById),React 会自动比较新旧数据差异,只更新变化的部分。

2. 核心概念
组件(Component)
界面由一个个独立的"组件"拼搭而成,类似乐高积木:

// 按钮组件
function MyButton() {
  return <button>点击我</button>;
}

// 页面使用组件
function App() {
  return (
    <div>
      <h1>标题</h1>
      <MyButton />    {/* 像 HTML 标签一样使用 */}
    </div>
  );
}
本项目中的组件:

ChatMessage - 单条消息显示
ChatInput - 输入框
ParameterPanel - 参数调节面板
AppLayout - 整体布局(侧边栏 + 主内容)
状态(State)
状态是组件的"记忆",数据变化时界面自动刷新:

import { useState } from "react";

function Counter() {
  // count 是状态值,setCount 是修改函数
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>点击了 {count} 次</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}
本项目中:useChat.ts 中的 messages 就是状态,每次新消息到达,setMessages 会自动刷新消息列表。

Props(属性)
父组件向子组件传递数据的方式:

// 子组件:接收 title 和 onClick 两个 props
function MyButton({ title, onClick }: { title: string; onClick: () => void }) {
  return <button onClick={onClick}>{title}</button>;
}

// 父组件:传递数据
function App() {
  return <MyButton title="点我" onClick={() => alert("Hello")} />;
}
Hook(钩子)
React 提供的特殊函数,以 use 开头:

Hook	用途
useState	定义状态(数据变化自动刷新 UI)
useEffect	副作用(数据请求、DOM 操作)
useCallback	缓存函数(优化性能)
useRef	引用 DOM 元素或持久化数据
import { useState, useEffect, useRef } from "react";

function Example() {
  const [data, setData] = useState([]);        // 状态
  const divRef = useRef<HTMLDivElement>(null);  // DOM 引用

  // 组件加载时执行一次(类似 onload)
  useEffect(() => {
    fetch("/api/data").then(r => r.json()).then(setData);
  }, []);

  return <div ref={divRef}>{data.length} 条数据</div>;
}
二、本项目的代码流转说明
整体架构
浏览器(前端 React)          后端(Hono + tRPC)           Ollama(本地 AI)
     │                              │                            │
     │  1. 用户输入问题              │                            │
     │ ──────────────────────────>  │                            │
     │                              │                            │
     │                              │  2. RAG 检索知识库          │
     │                              │  (console 输出日志)        │
     │                              │                            │
     │                              │  3. 组装提示词              │
     │                              │  (包含法律条文)            │
     │                              │                            │
     │                              │  4. 请求 Ollama            │
     │                              │ ─────────────────────────> │
     │                              │                            │
     │                              │  5. 流式返回 token          │
     │                              │ <───────────────────────── │
     │                              │                            │
     │  6. SSE 流逐字推送            │                            │
     │ <──────────────────────────  │                            │
     │                              │                            │
     │  7. 保存到数据库              │                            │
     │                              │                            │
详细数据流
发送消息流程
用户点击发送 → ChatInput.tsx 的 handleSubmit
调用 useChat.sendMessage → use-chat.ts
保存用户消息到数据库 → chat-router.ts(tRPC)
RAG 检索 → rag.ts 的 searchRelevantChunks
输出日志:[RAG] 检索完成!找到 X 条相关内容
组装系统提示词 → 拼接法律条文 + 用户问题
发送 SSE 请求 → fetch("/api/chat/stream")
后端调用 Ollama → chat-stream-handler.ts
输出日志:[Ollama] 模型: deepseek-r1:8b
逐 token 推送 → 前端实时显示
流结束保存 → 助手回复存入数据库
查看日志
启动后端后,在终端窗口中会看到:

========================================
[Chat] 收到新请求: "劳动合同纠纷如何处理?"...
[Chat] 模型: deepseek-r1:8b | RAG: true
========================================

[RAG] 开始检索知识库...
[RAG] 检索完成!找到 3 条相关内容(耗时 15ms)
[RAG]  结果 1: [相似度 85.3%] 中华人民共和国民法典 - 总则编
[RAG]         内容: 第一百二十六条 民事主体依照法律规定...

[Ollama] 发送请求到 http://localhost:11434
[Ollama] 模型: deepseek-r1:8b
[Ollama] 参数: temperature=0.7, top_p=0.9, num_ctx=4096
[Ollama] 连接成功(耗时 1200ms),开始接收流...

========================================
[Chat] 生成完成!总耗时 8520ms
[Chat] 生成 token 数: ~156
[Chat] 回复长度: 420 字
[Chat] 使用了 3 条参考资料
========================================
三、npm 常用命令速查表
命令	作用
npm install	安装项目依赖(根据 package.json)
npm install 包名	安装某个包
npm install 包名 --save-dev	安装开发依赖(如 TypeScript)
npm run build	构建项目(生成 dist 目录)
npm start	启动生产环境服务
npm run check	类型检查(TypeScript)
npm run dev	开发模式(热更新,修改代码自动刷新)
Windows PowerShell 启动命令
cd D:\workplace\untitled\app

# 安装依赖(首次或依赖变化时执行)
npm install

# 构建(代码修改后必须重新构建)
npm run build

# 启动服务
npm start

# 如果 npm start 报错,手动执行:
$env:NODE_ENV="production"
node dist/boot.js
四、目录结构说明
app/
├── .env                          # 环境变量配置
├── package.json                   # 项目依赖和脚本
├── vite.config.ts                 # Vite 构建配置
├── tsconfig.json                  # TypeScript 配置
├── api/                           # ===== 后端代码 =====
│   ├── boot.ts                    # 服务器入口(Hono 路由注册)
│   ├── router.ts                  # tRPC 路由汇总
│   ├── context.ts                 # tRPC 上下文
│   ├── middleware.ts              # tRPC 中间件
│   ├── lib/                       # 工具库
│   │   ├── rag.ts                 # RAG 检索引擎(TF-IDF)
│   │   ├── ollama.ts              # Ollama 客户端封装
│   │   ├── chat-stream-handler.ts # SSE 流式对话处理
│   │   └── env.ts                 # 环境变量读取
│   └── routers/                   # tRPC 路由(按功能分组)
│       ├── chat.ts                # 对话管理
│       ├── knowledge.ts           # 知识库管理
│       ├── settings.ts            # 参数配置
│       └── ollama.ts              # Ollama 状态
├── db/                            # ===== 数据库 =====
│   ├── schema.ts                  # 数据表定义
│   └── seed.ts                    # 种子数据(预置法律条文)
├── src/                           # ===== 前端代码 =====
│   ├── main.tsx                   # 前端入口(挂载 React)
│   ├── App.tsx                    # 路由配置(/ /knowledge /settings)
│   ├── index.css                  # 全局样式
│   ├── types/                     # TypeScript 类型定义
│   │   └── index.ts               # Message, ChatParameters 等类型
│   ├── hooks/                     # 自定义 Hook(状态管理)
│   │   ├── use-chat.ts            # 聊天状态(消息、发送、停止)
│   │   └── use-settings.ts        # 参数配置状态
│   ├── providers/                 # 上下文提供者
│   │   └── trpc.tsx               # tRPC 客户端配置
│   ├── components/                # 可复用组件
│   │   ├── ui/                    # shadcn/ui 基础组件
│   │   ├── layout/                # 布局组件
│   │   │   └── AppLayout.tsx      # 侧边栏 + 主内容布局
│   │   ├── chat/                  # 聊天相关组件
│   │   │   ├── ChatMessage.tsx    # 单条消息渲染
│   │   │   └── ChatInput.tsx      # 输入框
│   │   └── settings/              # 设置组件
│   │       └── ParameterPanel.tsx # 参数调节面板
│   └── pages/                     # 页面级组件
│       ├── Home.tsx               # 对话主页面
│       ├── Knowledge.tsx          # 知识库管理页
│       └── Settings.tsx           # 系统设置页
└── dist/                          # 构建输出(自动生成,不修改)
    ├── boot.js                    # 后端打包文件
    └── public/                    # 前端静态资源
        └── index.html             # 前端入口
五、关键文件详解
5.1 use-chat.ts(聊天核心逻辑)
┌─────────────────────────────────────┐
│  useChat Hook                        │
│                                      │
│  状态(useState)                     │
│    ├── messages: Message[]          │  消息列表,驱动 UI 渲染
│    ├── isStreaming: boolean         │  是否正在生成(控制按钮状态)
│    └── currentConversationId        │  当前对话 ID
│                                      │
│  Ref(useRef)                       │
│    ├── abortControllerRef           │  取消 fetch 请求用
│    └── readerRef                    │  关闭流读取器用
│                                      │
│  方法                                │
│    ├── sendMessage(content, params) │  发送消息(核心流程)
│    ├── stopGeneration()             │  调用 abort() 取消生成
│    ├── startNewConversation()       │  创建新对话
│    ├── loadConversation(id)         │  加载历史对话
│    └── deleteConv(id)               │  删除对话
└─────────────────────────────────────┘
5.2 chat-stream-handler.ts(后端 SSE 流处理)
POST /api/chat/stream
  │
  ├─ 1. 保存用户消息到数据库
  ├─ 2. RAG 检索(输出 [RAG] 日志)
  ├─ 3. 组装系统提示词 + 历史消息
  ├─ 4. 调用 Ollama(输出 [Ollama] 日志)
  ├─ 5. 读取 SSE 流,逐 token 推送给前端
  └─ 6. 流结束,保存助手回复到数据库
       输出 [Chat] 完成日志
5.3 rag.ts(RAG 检索引擎)
searchRelevantChunks(query, options)
  │
  ├─ 1. 中文分词(tokenize)
  │     把用户问题和每个文档块切成关键词
  │
  ├─ 2. 计算 TF-IDF 向量
  │     TF = 词频(这个词在文档中出现几次)
  │     IDF = 逆文档频率(这个词在多少文档中出现)
  │     TF-IDF = TF × IDF(越高表示越重要)
  │
  ├─ 3. 计算余弦相似度
  │     把用户问题的向量和每个文档块的向量对比
  │     相似度 0-1,越接近 1 越相关
  │
  └─ 4. 排序返回 topK 结果
       按相似度从高到低排序,取前 K 个
六、常见问题
Q1:知识库没生效?
查看终端日志是否有 [RAG] 开头的输出:

有:说明检索正常,模型可能没按参考回答
没有:知识库为空或检索失败
Q2:怎么确认模型用了知识库?
看 [Chat] 日志中的 使用了 X 条参考资料。如果为 0,说明没检索到相关内容。

Q3:修改代码后没生效?
必须重新构建:

npm run build
npm start
Q4:怎么换模型?
修改 .env 不需要(模型在代码里)。修改:

src/hooks/use-chat.ts 第 25 行:model: "deepseek-r1:8b"
src/types/index.ts 第 33 行:model: "deepseek-r1:8b"
api/routers/settings.ts 第 11 行:"model.default": "deepseek-r1:8b"
然后 npm run build && npm start

七、技术栈总结
层级	技术	说明
前端 UI	React + TypeScript + Tailwind CSS + shadcn/ui	React 负责界面逻辑,Tailwind 负责样式
前端状态	useState + useCallback + useRef	React 内置 Hook,无需 Redux
前后端通信	tRPC + SSE	tRPC 处理非流式请求,SSE 处理流式对话
后端框架	Hono	类似 Express 的轻量框架
后端 API	tRPC 11.x	端到端类型安全的 API
数据库	MySQL + Drizzle ORM	Drizzle 是 TypeScript ORM
AI 模型	Ollama(本地部署)	支持 DeepSeek、Qwen、Llama 等
检索引擎	自研 TF-IDF + 余弦相似度	纯本地,无需 Embedding API
构建工具	Vite	前端打包,速度快
  • 备注:React学习总结

vsCode快捷键:向下换一行:Ctrl + Enter。向上换一行:Ctrl + shift+ Enter. 全局搜索:Ctrl + shift+f. 复制到上方:Shift + Alt + ↑.复制到下方:Shift + Alt + ↓.格式化当前文件Shift + Alt + F.Ctrl + Shift + K:删除当前行.

F5:启动调试(对应你刚才问的 React 调试)。

F9:切换断点(设置/取消)。

F10:单步跳过(调试时执行下一行)。

F11:单步进入(进入函数内部)。

npx是npm的一个工具

state是组件要用的数据

特点:可以在function中写html

function中可以包含function,可以层层定义,return <div></div>的写法很常见

npx create-react-app <name> 一种比较老的创建项目结构的方式

组件通常是.jsx文件

function Test(){} 组件名首字母必须大写

有extended继承、super,class的概念

render渲染。render()是必须实现的实例方法,用于描述组件应该渲染什么内容

数组加元素用push, 数组.map有返回新数组,foreach就是循环。跟java stream流很像。

条件渲染:useState让无状态的函数变成有状态的组件

形如这种写法的含义:let[show,changeShow]= useState(true);hook声明状态变量的写法,true是变量show的默认值,changeShow是唯一用来修改show的函数。

事件传参调用可以用比如:changeShow.bind(123)的方式传,不能直接changeShow(123),因为这会在加载时就立即执行,而bind是等待点击时执行,返回一个新的函数。

css module 可以制定css的生效的作用域

可以通过cls("tab1",“tab2”)来制定多个样式,也可以通过style字符串拼接的方式指定:style.tab1+" "+style.tab2

style\classNames\styled\lifeCircle都是npm已成熟的包

类组件的生命周期(包括但不限于):渲染阶段(render、componentDidMount) 更新(shouldComponentUpdate 是否应该重新渲染) componentDidUpdate(完成更新)  卸载(componentWillUnmount).这些都是npm子代的方法,可以重写这些方法来主动控制组件的存活。

父组件变量,不能直接在子组件中修改,但是可以直接引用。即单向数据流。那么怎么修改呢,类型与java的private字段,不能直接修改,但父组件可以提供set方法给子组件修改。

<CssTransition>也是一个css标签。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐