欢迎光临
我们一直在努力

07_前后端连接指南:文件上传与向量化集成

第一阶段:后端紧急补丁——解决跨域 (CORS)

因为你的前端在 3000 端口,后端在 8000 端口,浏览器默认会拦截它们之间的通信。我们需要在 apps/api/main.py 中打开"大门":

from fastapi.middleware.cors import CORSMiddleware

# 在 app = FastAPI() 之后添加
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境建议改为具体的域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

更新后的 main.py 头部导入部分和配置:

from fastapi import FastAPI, UploadFile, File, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware # 新增导入
from contextlib import asynccontextmanager
from sqlmodel import Session, text
from typing import List
import os
import io

# 数据库和模型导入
from database import init_db, engine, get_session
from models import User, Document
from services.embedding import generate_vector

@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
print("🚀 启动 KnoSphere API…")
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
conn.commit()
init_db()
print("✅ 数据库初始化完成")
yield
print("👋 关闭 KnoSphere API…")

app = FastAPI(
title="KnoSphere API",
description="2026 企业级智能知识库系统",
version="1.0.0",
lifespan=lifespan
)

# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 开发环境允许所有源
allow_credentials=True,
allow_methods=["*"], # 允许所有方法
allow_headers=["*"], # 允许所有头部
)

# … 其他代码保持不变


第二阶段:安装前端反馈组件

为了让上传过程有"高级感",我们需要 Shadcn/ui 的 Toast(弹出提示)组件。

cd apps/web
pnpm dlx shadcn@latest add toast

然后在 apps/web/src/app/layout.tsx 的 body 标签里加上 <Toaster />:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster"; // 新增导入

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "KnoSphere – 2026 企业级智能知识库",
description: "下一代 AI 驱动的知识管理系统",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="dark">
<body className={`${inter.className} bg-zinc-950 text-zinc-50`}>
{children}
<Toaster /> {/* 新增 Toaster 组件 */}
</body>
</html>
);
}


第三阶段:创建上传钩子

Shadcn/ui 的 Toast 组件需要配合一个自定义钩子使用。创建 apps/web/src/hooks/use-toast.ts:

"use client";

// 参考:https://ui.shadcn.com/docs/components/toast
import * as React from "react";

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;

type ToasterToast = {
id: string;
title?: string;
description?: string;
action?: React.ReactNode;
variant?: "default" | "destructive";
};

const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;

let count = 0;

function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}

type ActionType = typeof actionTypes;

type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> & { id: string };
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: string;
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: string;
};

interface State {
toasts: ToasterToast[];
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();

const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}

const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);

toastTimeouts.set(toastId, timeout);
};

export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
…state,
toasts: [action.toast, …state.toasts].slice(0, TOAST_LIMIT),
};

case "UPDATE_TOAST":
return {
…state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { …t, …action.toast } : t
),
};

case "DISMISS_TOAST": {
const { toastId } = action;

if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}

return {
…state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
…t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
…state,
toasts: [],
};
}
return {
…state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};

const listeners: Array<(state: State) => void> = [];

let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}

type Toast = {
(props: Omit<ToasterToast, "id"> & { id?: string }): void;
dismiss: (toastId?: string) => void;
error: (props: Omit<ToasterToast, "id" | "variant"> & { id?: string }) => void;
};

export const toast: Toast = (props) => {
const id = props.id || genId();

const update = (newProps: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { …newProps, id },
});

const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });

dispatch({
type: "ADD_TOAST",
toast: {
…props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});

return {
id: id,
dismiss,
update,
};
};

toast.dismiss = (toastId?: string) =>
dispatch({ type: "DISMISS_TOAST", toastId });
toast.error = (props) =>
toast({ …props, variant: "destructive", id: props.id });

export const useToast = () => {
const [state, setState] = React.useState<State>(memoryState);

React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);

return {
…state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
};


第四阶段:编写上传组件

在 apps/web/src/components 下新建 file-upload.tsx。我们将使用 React 19 的新特性来简化状态管理。

"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { Loader2, UploadCloud } from "lucide-react";

export function FileUpload() {
const [isUploading, setIsUploading] = useState(false);
const { toast } = useToast();

async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;

setIsUploading(true);

// 准备 FormData
const formData = new FormData();
formData.append("file", file);

try {
const response = await fetch("http://localhost:8000/upload", {
method: "POST",
body: formData,
});

const result = await response.json();

if (response.ok) {
toast({
title: "🎉 上传成功",
description: `${file.name} 已转化为向量存入知识库,向量维度:${result.vector_dimensions}维`
});
} else {
throw new Error(result.detail || "上传失败");
}
} catch (error: any) {
toast({
title: "❌ 出错啦",
description: error.message || "无法连接到后端服务器",
variant: "destructive"
});
} finally {
setIsUploading(false);
// 重置文件输入
event.target.value = "";
}
}

return (
<div className="flex flex-col items-center gap-6 p-8 border-2 border-dashed border-zinc-800 rounded-xl bg-zinc-900/50 hover:border-blue-500/50 transition-all duration-300">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-blue-500/20 to-emerald-500/20 flex items-center justify-center">
<UploadCloud className="w-10 h-10 text-blue-400" />
</div>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 rounded-full border-4 border-transparent border-t-blue-500 animate-spin"></div>
</div>
)}
</div>

<div className="text-center space-y-2">
<h3 className="text-lg font-semibold text-zinc-100">
{isUploading ? "AI 正在思考并向量化…" : "上传知识文档"}
</h3>
<p className="text-sm text-zinc-400">
支持 PDF, DOCX, TXT, MD 等格式
</p>
<p className="text-xs text-zinc-500">
文件将自动转换为 1536 维向量并存储
</p>
</div>

<div className="relative">
<Input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer z-10"
onChange={handleUpload}
disabled={isUploading}
accept=".txt,.md,.pdf,.docx"
/>
<Button
variant="default"
disabled={isUploading}
className="relative bg-gradient-to-r from-blue-600 to-emerald-600 hover:from-blue-700 hover:to-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-all"
>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
处理中…
</>
) : (
<>
<UploadCloud className="mr-2 h-4 w-4" />
选择文件并上传
</>
)}
</Button>
</div>
</div>
);
}


第五阶段:将组件集成到首页

修改 apps/web/src/app/page.tsx,把刚才写的组件放进去:

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { FileUpload } from "@/components/file-upload";

export default function Home() {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-50 p-4 md:p-8">
<div className="max-w-6xl mx-auto space-y-8">
{/* 顶部装饰 */}
<section className="text-center space-y-6 py-8">
<h1 className="text-4xl md:text-6xl font-extrabold tracking-tight bg-gradient-to-r from-blue-400 via-emerald-400 to-cyan-400 bg-clip-text text-transparent animate-gradient">
KnoSphere
</h1>
<p className="text-xl text-zinc-400 font-light">2026 企业级智能知识库中枢</p>
<div className="flex flex-wrap justify-center gap-3">
<Badge variant="secondary" className="bg-blue-500/10 text-blue-300 hover:bg-blue-500/20">React 19</Badge>
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20">FastAPI</Badge>
<Badge variant="secondary" className="bg-purple-500/10 text-purple-300 hover:bg-purple-500/20">pgvector</Badge>
<Badge variant="secondary" className="bg-amber-500/10 text-amber-300 hover:bg-amber-500/20">Python 3.14</Badge>
</div>
</section>

{/* 搜索与快捷操作 */}
<div className="flex flex-col md:flex-row gap-4 max-w-3xl mx-auto">
<Input
placeholder="输入您的问题或关键词,检索企业知识…"
className="bg-zinc-900 border-zinc-800 focus:border-blue-500 flex-1"
/>
<Button className="bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white px-6">
智能搜索
</Button>
</div>

{/* 状态卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-zinc-900/80 border-zinc-800 text-zinc-100 backdrop-blur-sm hover:border-blue-500/50 transition-colors">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
知识总量
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">0 篇</div>
<p className="text-zinc-400 text-sm mt-2">上传文档后开始增长</p>
</CardContent>
</Card>

<Card className="bg-zinc-900/80 border-zinc-800 text-zinc-100 backdrop-blur-sm hover:border-emerald-500/50 transition-colors">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
向量存储
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">1536 维</div>
<p className="text-zinc-400 text-sm mt-2">OpenAI text-embedding-3-small</p>
</CardContent>
</Card>

<Card className="bg-zinc-900/80 border-zinc-800 text-zinc-100 backdrop-blur-sm hover:border-purple-500/50 transition-colors">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-500"></div>
响应时间
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">~50ms</div>
<p className="text-zinc-400 text-sm mt-2">HNSW 索引加速</p>
</CardContent>
</Card>
</div>

{/* 上传区域 */}
<div className="space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold text-zinc-100">知识录入</h2>
<p className="text-zinc-400">上传您的文档,KnoSphere 将自动学习并构建知识网络</p>
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-zinc-900/80 border-zinc-800 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gradient-to-r from-blue-500 to-emerald-500"></div>
文档上传
</CardTitle>
<p className="text-zinc-400 text-sm">支持多种格式,自动向量化存储</p>
</CardHeader>
<CardContent>
<FileUpload />
</CardContent>
</Card>

<Card className="bg-zinc-900/80 border-zinc-800 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gradient-to-r from-purple-500 to-pink-500"></div>
最近上传
</CardTitle>
<p className="text-zinc-400 text-sm">您最近的文档记录</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-zinc-800/50 mb-4">
<div className="text-zinc-500 text-2xl">📁</div>
</div>
<p className="text-zinc-400">暂无上传记录</p>
<p className="text-zinc-500 text-sm mt-2">上传文档后,记录将显示在这里</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>

{/* 添加动画样式 */}
<style jsx global>{`
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 8s ease infinite;
}
`}</style>
</main>
);
}


2026 年的开发细节:为什么这么写?

  • “use client”: 虽然 Next.js 16 强推服务端组件,但涉及文件上传和按钮点击的交互,必须声明为客户端组件。
  • Streaming Feedback: 在 2026 年,如果文件很大,我们会配合 Server Actions 和 useFormStatus 来实现更细腻的进度条,目前这个版本对"小白第一步"来说最稳健。
  • Lucide React: 我们用了 UploadCloud 和 Loader2 图标,这能让界面瞬间有"大厂感"。

  • 第六阶段:安装图标库

    如果还没有安装 Lucide React,请在 apps/web 目录下运行:

    pnpm add lucide-react


    检查点:你的 KnoSphere 活过来了!

    现在,请按照以下步骤启动整个系统:

    1. 启动后端服务器

    # 在 apps/api 目录下
    uv run uvicorn main:app reload host 0.0.0.0 port 8000

    2. 启动前端开发服务器

    # 在 apps/web 目录下
    pnpm dev

    3. 确保数据库正在运行

    # 在项目根目录 KnoSphere 下
    docker-compose ps
    # 应该显示 postgres 容器正在运行

    4. 测试上传功能

  • 打开浏览器访问 http://localhost:3000
  • 点击"选择文件并上传"按钮
  • 选择一个文本文件(.txt 或 .md 格式)
  • 等待上传完成,你应该看到成功的 Toast 提示
  • 5. 验证数据存储

    你可以通过以下方式验证数据是否正确存储:

    # 进入数据库容器
    docker-compose exec db psql U admin d knosphere

    # 在 PostgreSQL 中运行
    SELECT id, title, created_at, array_length(embedding, 1) as vector_dim
    FROM document;


    故障排除

    问题 1: 前端无法连接到后端 (CORS 错误)

    • 确保后端服务器正在运行 (http://localhost:8000)
    • 检查 main.py 中已正确添加 CORS 中间件

    问题 2: 上传失败,显示"无法连接到后端服务器"

    • 检查后端服务是否正常运行
    • 确保 .env 文件中的 OPENAI_API_KEY 已正确设置

    问题 3: 文件读取失败

    • 确保上传的文件是支持的格式 (.txt, .md, .pdf, .docx)
    • 对于 PDF 和 DOCX 文件,需要安装额外依赖:# 在 apps/api 目录下
      uv add PyPDF2 python-docx

    问题 4: 数据库连接失败

    • 确保 Docker 容器正在运行:docker-compose up -d
    • 检查数据库连接字符串是否正确

    试试看:上传一个 .txt 文件,然后去数据库里看看 document 表,你会发现那串长长的向量数字已经静静地躺在那里了!

    !!!问题: 但目前只支持openai的,不支持大多数模型

    • 兼容大多数模型
    赞(0)
    未经允许不得转载:171主机测评 » 07_前后端连接指南:文件上传与向量化集成
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址