📦 项目教程仓库:https://github.com/ZIQI-a/AI_Agent_study 🚀 成品项目地址:https://github.com/ZIQI-a/huamiao_Agent

本章目标

要做的事:实现文章导入、风格分析、风格库管理功能

学到的知识

  • 文件上传处理
  • 用 LLM 分析文章风格(结构化输出)
  • 风格标签系统

10.1 安装依赖

# 已经安装了 react-markdown 等,这里不需要额外安装

10.2 文件上传 API

创建 src/app/api/styles/upload/route.ts

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { styles } from "@/lib/db/schema";

export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const file = formData.get("file") as File;
    const name = formData.get("name") as string;

    if (!file) {
      return NextResponse.json({ error: "请上传文件" }, { status: 400 });
    }

    // 读取文件内容
    const content = await file.text();

    if (content.length < 50) {
      return NextResponse.json(
        { error: "文件内容太短,至少需要50字" },
        { status: 400 }
      );
    }

    // 保存到数据库
    const result = await db
      .insert(styles)
      .values({
        name: name || file.name,
        content,
      })
      .returning();

    return NextResponse.json({ success: true, style: result[0] });
  } catch (error: any) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

10.3 风格分析 API

创建 src/app/api/styles/analyze/route.ts

import { NextRequest, NextResponse } from "next/server";
import { createOpenAI } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
import { db } from "@/lib/db";
import { styles } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

const deepseek = createOpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});

// 风格分析的 Zod schema
const styleSchema = z.object({
  tone: z.enum(["正式", "轻松", "文艺", "幽默", "严肃", "客观"]),
  vocabulary: z.enum(["简单", "适中", "复杂", "学术"]),
  sentenceLength: z.enum(["短句为主", "长短结合", "长句为主"]),
  features: z.array(z.string()).describe("3-5个写作特点"),
  summary: z.string().describe("一句话总结风格"),
});

export async function POST(req: NextRequest) {
  try {
    const { id } = await req.json();

    // 从数据库读取文章
    const result = await db.select().from(styles).where(eq(styles.id, id));
    const style = result[0];

    if (!style) {
      return NextResponse.json({ error: "未找到文章" }, { status: 404 });
    }

    // 用 LLM 分析风格
    const { object } = await generateObject({
      model: deepseek("deepseek-chat"),
      schema: styleSchema,
      prompt: `分析以下文章的写作风格,用 JSON 格式输出:

文章内容:
${style.content.slice(0, 3000)} // 截取前3000字分析
`,
    });

    // 更新数据库
    await db
      .update(styles)
      .set({ analysis: JSON.stringify(object, null, 2) })
      .where(eq(styles.id, id));

    return NextResponse.json({ success: true, analysis: object });
  } catch (error: any) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

知识点:generateObject vs streamText

  • streamText:流式输出文本,适合长内容生成
  • generateObject:输出结构化 JSON,适合数据提取和分析
  • generateObject 使用 Zod schema 确保输出格式正确

10.4 风格库管理 API

// src/app/api/styles/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { styles } from "@/lib/db/schema";
import { desc } from "drizzle-orm";

export async function GET() {
  const allStyles = await db
    .select()
    .from(styles)
    .orderBy(desc(styles.createdAt));
  return NextResponse.json(allStyles);
}

// src/app/api/styles/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { styles } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  await db.delete(styles).where(eq(styles.id, Number(params.id)));
  return NextResponse.json({ success: true });
}

10.5 风格库页面

安装文件上传组件:

pnpm dlx shadcn@latest add badge

创建 src/app/styles/page.tsx

"use client";

import { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { PageContainer } from "@/components/layout/page-container";

interface StyleItem {
  id: number;
  name: string;
  content: string;
  analysis: string | null;
  createdAt: string;
}

export default function StylesPage() {
  const [styles, setStyles] = useState<StyleItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [uploading, setUploading] = useState(false);
  const [analyzing, setAnalyzing] = useState<number | null>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const nameRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    fetchStyles();
  }, []);

  const fetchStyles = async () => {
    const res = await fetch("/api/styles");
    const data = await res.json();
    setStyles(data);
    setLoading(false);
  };

  const handleUpload = async () => {
    const file = fileRef.current?.files?.[0];
    if (!file) return;

    setUploading(true);
    const formData = new FormData();
    formData.append("file", file);
    formData.append("name", nameRef.current?.value || file.name);

    try {
      const res = await fetch("/api/styles/upload", {
        method: "POST",
        body: formData,
      });
      const data = await res.json();

      if (data.success) {
        fetchStyles();
        if (fileRef.current) fileRef.current.value = "";
        if (nameRef.current) nameRef.current.value = "";
      } else {
        alert(data.error);
      }
    } finally {
      setUploading(false);
    }
  };

  const handleAnalyze = async (id: number) => {
    setAnalyzing(id);
    try {
      const res = await fetch("/api/styles/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id }),
      });
      const data = await res.json();

      if (data.success) {
        fetchStyles();
      } else {
        alert(data.error);
      }
    } finally {
      setAnalyzing(null);
    }
  };

  const handleDelete = async (id: number) => {
    if (!confirm("确定删除?")) return;
    await fetch(`/api/styles/${id}`, { method: "DELETE" });
    setStyles(styles.filter((s) => s.id !== id));
  };

  return (
    <PageContainer title="风格文库" description="导入文章,分析写作风格">
      {/* 上传区 */}
      <Card className="mb-6">
        <CardHeader>
          <CardTitle>导入文章</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="flex gap-4 items-end flex-wrap">
            <div className="space-y-2">
              <Label>文章名称(可选)</Label>
              <Input ref={nameRef} placeholder="如:鲁迅杂文风格" />
            </div>
            <div className="space-y-2">
              <Label>选择文件</Label>
              <input
                ref={fileRef}
                type="file"
                accept=".txt,.md"
                className="block text-sm file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-primary file:text-primary-foreground"
              />
            </div>
            <Button onClick={handleUpload} disabled={uploading}>
              {uploading ? "上传中..." : "导入"}
            </Button>
          </div>
        </CardContent>
      </Card>

      {/* 风格列表 */}
      {loading ? (
        <p className="text-muted-foreground">加载中...</p>
      ) : styles.length === 0 ? (
        <div className="text-center py-12 text-muted-foreground">
          <div className="text-5xl mb-4">📚</div>
          <p>还没有导入任何文章</p>
          <p className="text-sm">上传 .txt 或 .md 文件,AI 会分析其写作风格</p>
        </div>
      ) : (
        <div className="space-y-4">
          {styles.map((item) => (
            <Card key={item.id}>
              <CardHeader className="flex flex-row items-start justify-between">
                <div>
                  <CardTitle className="text-lg">{item.name}</CardTitle>
                  <p className="text-sm text-muted-foreground">
                    {item.content.length} 字 ·{" "}
                    {new Date(item.createdAt).toLocaleDateString("zh-CN")}
                  </p>
                </div>
                <div className="flex gap-2">
                  {!item.analysis && (
                    <Button
                      variant="outline"
                      size="sm"
                      onClick={() => handleAnalyze(item.id)}
                      disabled={analyzing === item.id}
                    >
                      {analyzing === item.id ? "分析中..." : "分析风格"}
                    </Button>
                  )}
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleDelete(item.id)}
                  >
                    删除
                  </Button>
                </div>
              </CardHeader>
              <CardContent>
                {/* 内容预览 */}
                <p className="text-sm text-muted-foreground line-clamp-3 mb-4">
                  {item.content.slice(0, 300)}...
                </p>

                {/* 风格分析结果 */}
                {item.analysis && (
                  <div className="bg-muted rounded-lg p-4">
                    <h4 className="font-medium mb-2">风格分析</h4>
                    <pre className="text-sm whitespace-pre-wrap">
                      {item.analysis}
                    </pre>
                  </div>
                )}
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </PageContainer>
  );
}

10.6 测试风格文库

  1. 准备一篇 .txt 或 .md 文件(比如你喜欢的博客文章)
  2. 上传到风格文库
  3. 点击「分析风格」,查看 AI 的分析结果
  4. 尝试上传不同风格的文章,对比分析结果

本章小结

概念 说明
文件上传 FormData + API Route 处理文件
generateObject Vercel AI SDK 的结构化输出函数
Zod schema 定义 JSON 结构,确保 AI 输出格式正确
风格分析 用 LLM 提取文章的写作特点

下一章预告

风格库有了,接下来实现核心的 RAG 功能 —— 把文章向量化,创作时检索相似文章作为参考,实现风格仿写。


如果这个教程对你有帮助,欢迎 ⭐ Star 支持一下!

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐