AI智能博客平台实战:RAG增强写作、多Agent协作与语义检索的Vue3 + Spring AI全栈实现
本文将带你从零构建一个完整的 AI 智能博客平台,前端使用 Vue3 + TypeScript + Element Plus,后端使用 Spring Boot + Spring AI,实现 AI 辅助写作、智能标签生成、内容推荐等核心功能。
博客:
https://blog.csdn.net/badao_liumang_qizhi
一、项目架构设计
1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 前端 (Vue3 + Vite) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 博客列表 │ │ 文章编辑 │ │ AI 助手 │ │ 个人中心 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│ HTTP / SSE
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Spring Boot 3.x) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 博客服务 │ │ AI 服务 │ │ 用户服务 │ │ 搜索服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Spring AI (DashScope) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 数据层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │ Redis │ │ Milvus │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
1.2 技术栈选型
| 层级 | 技术选型 | 版本 |
|---|---|---|
| 前端框架 | Vue3 + TypeScript | 3.4+ |
| UI 组件库 | Element Plus | 2.5+ |
| 构建工具 | Vite | 5.0+ |
| 状态管理 | Pinia | 2.1+ |
| 后端框架 | Spring Boot | 3.2+ |
| AI 框架 | Spring AI | 1.0+ |
| 模型服务 | DashScope (通义千问) | - |
| 数据库 | MySQL | 8.0+ |
| 缓存 | Redis | 7.0+ |
| 向量库 | Milvus | 2.3+ |
1.3 核心功能模块与数据流
| 功能模块 | 核心能力 | 关键技术 |
|---|---|---|
| AI 辅助写作 | 摘要生成、标签推荐、文章润色、写作灵感 | Spring AI ChatClient |
| 语义搜索 | 基于向量的文章检索 | Milvus + Embedding |
| 流式对话 | AI 写作助手实时响应 | SSE + Flux |
| 浏览量统计 | Redis 去重计数 | Redis SetIfAbsent |
| 热门文章 | 基于浏览量的排序 | Redis ZSet |
| 文章向量化 | 内容语义索引 | EmbeddingClient + Milvus |
核心数据流:用户写作 → 保存文章 → AI 自动生成摘要和标签 → 文章向量化存入 Milvus → 用户搜索时进行语义匹配 → 返回相关文章。
二、后端实现
2.1 项目依赖配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>ai-blog-backend</artifactId>
<version>1.0.0</version>
<name>AI Blog Backend</name>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M4</spring-ai.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Milvus -->
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
<version>2.3.4</version>
</dependency>
</dependencies>
</project>
2.2 配置文件
yaml
# application.yml
server:
port: 8080
spring:
application:
name: ai-blog-backend
datasource:
url: jdbc:mysql://localhost:3306/ai_blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 5000ms
# Spring AI 配置 (DashScope)
spring:
ai:
openai:
api-key: ${DASHSCOPE_API_KEY}
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
chat:
options:
model: qwen-max
temperature: 0.7
max-tokens: 4096
# Milvus 配置
milvus:
host: localhost
port: 19530
collection: blog_vectors
# 自定义配置
blog:
ai:
summary-max-length: 200
tag-max-count: 5
recommend-count: 6
2.3 实体类定义
java
package com.example.aiblog.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Entity
@Table(name = "blog_post")
public class BlogPost {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "LONGTEXT")
private String content;
@Column(length = 500)
private String summary;
@Column(length = 500)
private String coverImage;
@ElementCollection
@CollectionTable(name = "blog_post_tags", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "tag")
private List<String> tags;
@Column(nullable = false)
private Long authorId;
@Column(length = 100)
private String authorName;
@Column(nullable = false)
private Integer viewCount = 0;
@Column(nullable = false)
private Integer likeCount = 0;
@Column(nullable = false)
private Integer status = 0; // 0-草稿 1-已发布 2-归档
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
private LocalDateTime publishedAt;
}
java
package com.example.aiblog.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "blog_ai_conversation")
public class BlogAiConversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private Long postId;
@Column(nullable = false, length = 20)
private String role; // user / assistant
@Column(columnDefinition = "TEXT")
private String content;
@CreationTimestamp
private LocalDateTime createdAt;
}
2.4 AI 服务层
java
package com.example.aiblog.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiBlogService {
private final ChatClient.Builder chatClientBuilder;
private static final String SYSTEM_PROMPT = """
你是一个专业的博客写作助手,擅长帮助用户:
1. 优化文章结构和表达
2. 生成文章摘要
3. 推荐合适的标签
4. 提供写作灵感和建议
5. 润色和改进文字表达
请始终用中文回答,保持专业友好的语气。
""";
/**
* 生成文章摘要
*/
public String generateSummary(String content) {
String prompt = String.format(
"请为以下文章生成一个简洁的摘要(不超过150字):\n\n%s",
content.substring(0, Math.min(content.length(), 3000))
);
return chatClientBuilder.build()
.prompt(prompt)
.call()
.content();
}
/**
* 生成推荐标签
*/
public List<String> generateTags(String title, String content) {
String prompt = String.format(
"请根据以下文章标题和内容,推荐%d个最相关的标签(用逗号分隔):\n\n标题:%s\n\n内容:%s",
5, title, content.substring(0, Math.min(content.length(), 2000))
);
String response = chatClientBuilder.build()
.prompt(prompt)
.call()
.content();
return parseTags(response);
}
/**
* 流式对话 - AI 写作助手
*/
public Flux<String> streamChat(Long userId, Long postId, String userMessage,
List<BlogAiConversation> history) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(SYSTEM_PROMPT));
// 添加历史对话
for (BlogAiConversation conv : history) {
if ("user".equals(conv.getRole())) {
messages.add(new UserMessage(conv.getContent()));
} else {
messages.add(new AssistantMessage(conv.getContent()));
}
}
// 添加当前用户消息
messages.add(new UserMessage(userMessage));
Prompt prompt = new Prompt(messages, OpenAiChatOptions.builder()
.withModel("qwen-max")
.withTemperature(0.7)
.withMaxTokens(2048)
.build());
return chatClientBuilder.build()
.prompt(prompt)
.stream()
.content();
}
/**
* AI 文章润色
*/
public String polishContent(String content, String style) {
String prompt = String.format(
"请将以下文章润色为%s风格,保持原意不变,优化表达和结构:\n\n%s",
style, content
);
return chatClientBuilder.build()
.prompt(prompt)
.call()
.content();
}
/**
* 生成写作灵感
*/
public List<String> generateWritingIdeas(String topic) {
String prompt = String.format(
"请以'%s'为主题,提供5个有创意的写作角度或子话题,每个一行:",
topic
);
String response = chatClientBuilder.build()
.prompt(prompt)
.call()
.content();
return List.of(response.split("\\n")).stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(5)
.toList();
}
private List<String> parseTags(String response) {
Pattern pattern = Pattern.compile("[\"']([^\"']+)[\"']|([\\u4e00-\\u9fa5a-zA-Z0-9]+)");
Matcher matcher = pattern.matcher(response);
List<String> tags = new ArrayList<>();
while (matcher.find() && tags.size() < 5) {
String tag = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
if (tag != null && !tag.isBlank()) {
tags.add(tag.trim());
}
}
return tags;
}
}
代码解析 :generateSummary 和 generateTags 是两个核心 AI 辅助功能。generateSummary 截取内容的前 3000 字符(避免超出模型上下文窗口),让模型生成不超过 150 字的摘要。generateTags 要求模型返回 5 个最相关的标签,然后通过正则表达式解析模型返回的文本。parseTags 的正则同时匹配带引号的标签和中文/英文标签,确保解析的健壮性。
生活类比:AI 服务层就像一位全能编辑助理。 你写完稿子交给它,它可以帮你写摘要(提炼核心)、贴标签(分类归档)、润色文章(改错别字和语病)、想选题(头脑风暴)。每个功能都是一个独立的"技能",通过 ChatClient 调用 LLM 来实现。
2.5 博客服务层
java
package com.example.aiblog.service;
import com.example.aiblog.entity.BlogPost;
import com.example.aiblog.repository.BlogPostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class BlogService {
private final BlogPostRepository blogPostRepository;
private final AiBlogService aiBlogService;
private final RedisTemplate<String, Object> redisTemplate;
private final VectorService vectorService;
private static final String VIEW_COUNT_KEY = "blog:view:";
private static final String HOT_POSTS_KEY = "blog:hot_posts";
/**
* 创建文章(带 AI 辅助)
*/
@Transactional
public BlogPost createPost(BlogPost post, boolean useAiAssist) {
if (useAiAssist && (post.getSummary() == null || post.getSummary().isBlank())) {
// 自动生成摘要
String summary = aiBlogService.generateSummary(post.getContent());
post.setSummary(summary);
}
if (useAiAssist && (post.getTags() == null || post.getTags().isEmpty())) {
// 自动生成标签
List<String> tags = aiBlogService.generateTags(post.getTitle(), post.getContent());
post.setTags(tags);
}
post.setStatus(1);
post.setPublishedAt(LocalDateTime.now());
post.setViewCount(0);
post.setLikeCount(0);
BlogPost saved = blogPostRepository.save(post);
// 存入向量数据库用于语义搜索
vectorService.insertPostVector(saved);
// 清除热门文章缓存
redisTemplate.delete(HOT_POSTS_KEY);
return saved;
}
/**
* 获取文章详情(带浏览量统计)
*/
public BlogPost getPost(Long id, String viewerIp) {
BlogPost post = blogPostRepository.findById(id)
.orElseThrow(() -> new RuntimeException("文章不存在"));
// 使用 Redis 统计浏览量(同一IP 24小时内只算一次)
String viewKey = VIEW_COUNT_KEY + id + ":" + viewerIp;
Boolean notViewed = redisTemplate.opsForValue()
.setIfAbsent(viewKey, "1", Duration.ofHours(24));
if (Boolean.TRUE.equals(notViewed)) {
post.setViewCount(post.getViewCount() + 1);
blogPostRepository.save(post);
// 更新热门文章有序集合
redisTemplate.opsForZSet().incrementScore(HOT_POSTS_KEY, id.toString(), 1);
}
return post;
}
/**
* 获取热门文章
*/
@SuppressWarnings("unchecked")
public List<BlogPost> getHotPosts(int limit) {
// 先从缓存获取
List<BlogPost> cached = (List<BlogPost>) redisTemplate.opsForValue().get(HOT_POSTS_KEY + ":list");
if (cached != null) {
return cached;
}
// 从数据库获取
List<BlogPost> posts = blogPostRepository.findByStatusOrderByViewCountDesc(
1, PageRequest.of(0, limit)).getContent();
// 缓存10分钟
redisTemplate.opsForValue().set(HOT_POSTS_KEY + ":list", posts, 10, TimeUnit.MINUTES);
return posts;
}
/**
* 语义搜索
*/
public List<BlogPost> semanticSearch(String query, int limit) {
List<Long> ids = vectorService.searchSimilarPosts(query, limit);
return blogPostRepository.findAllById(ids);
}
/**
* 分页获取文章列表
*/
public Page<BlogPost> getPostList(int page, int size, String tag, String keyword) {
if (tag != null && !tag.isBlank()) {
return blogPostRepository.findByTagsContainingAndStatus(
tag, 1, PageRequest.of(page, size));
}
if (keyword != null && !keyword.isBlank()) {
return blogPostRepository.findByTitleContainingAndStatus(
keyword, 1, PageRequest.of(page, size));
}
return blogPostRepository.findByStatusOrderByPublishedAtDesc(
1, PageRequest.of(page, size));
}
}
2.6 向量服务(语义搜索)
java
package com.example.aiblog.service;
import com.example.aiblog.entity.BlogPost;
import io.milvus.client.MilvusServiceClient;
import io.milvus.param.ConnectParam;
import io.milvus.param.collection.CollectionParam;
import io.milvus.param.dml.InsertParam;
import io.milvus.param.dml.SearchParam;
import io.milvus.response.SearchResultsWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class VectorService {
private final EmbeddingClient embeddingClient;
@Value("${milvus.host}")
private String milvusHost;
@Value("${milvus.port}")
private Integer milvusPort;
@Value("${milvus.collection}")
private String collectionName;
private MilvusServiceClient milvusClient;
@PostConstruct
public void init() {
milvusClient = new MilvusServiceClient(
ConnectParam.newBuilder()
.withHost(milvusHost)
.withPort(milvusPort)
.build()
);
createCollectionIfNotExists();
}
private void createCollectionIfNotExists() {
try {
CollectionParam collectionParam = CollectionParam.newBuilder()
.withCollectionName(collectionName)
.withDimension(1536) // text-embedding-3-small 维度
.withMetricType("COSINE")
.build();
try {
milvusClient.createCollection(collectionParam);
log.info("创建 Milvus collection: {}", collectionName);
} catch (Exception e) {
log.info("Collection 已存在: {}", collectionName);
}
} catch (Exception e) {
log.error("初始化 Milvus 失败", e);
}
}
/**
* 插入文章向量
*/
public void insertPostVector(BlogPost post) {
try {
// 生成文本嵌入
String textToEmbed = post.getTitle() + " " +
(post.getSummary() != null ? post.getSummary() : "");
float[] embedding = embeddingClient.embed(textToEmbed);
// 准备数据
List<Long> ids = List.of(post.getId());
List<String> titles = List.of(post.getTitle());
List<List<Float>> embeddings = List.of(
Arrays.stream(embedding).boxed().collect(Collectors.toList())
);
// 插入 Milvus
InsertParam insertParam = InsertParam.newBuilder()
.withCollectionName(collectionName)
.withFields(Arrays.asList(
new InsertParam.Field("id", ids),
new InsertParam.Field("title", titles),
new InsertParam.Field("embedding", embeddings)
))
.build();
milvusClient.insert(insertParam);
milvusClient.flush(Collections.singletonList(collectionName));
} catch (Exception e) {
log.error("插入向量失败: {}", post.getId(), e);
}
}
/**
* 语义搜索相似文章
*/
public List<Long> searchSimilarPosts(String query, int topK) {
try {
// 生成查询向量
float[] queryEmbedding = embeddingClient.embed(query);
List<List<Float>> queryVectors = List.of(
Arrays.stream(queryEmbedding).boxed().collect(Collectors.toList())
);
// 搜索
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVector(queryVectors)
.withTopK(topK)
.withMetricType("COSINE")
.withParams("{\"nprobe\": 10}")
.withOutFields(List.of("id"))
.build();
SearchResultsWrapper results = new SearchResultsWrapper(
milvusClient.search(searchParam).getData()
);
return results.getIDScore(0).stream()
.map(idScore -> idScore.getLongID())
.collect(Collectors.toList());
} catch (Exception e) {
log.error("语义搜索失败", e);
return Collections.emptyList();
}
}
}
代码解析 :向量服务是语义搜索的核心。insertPostVector 将文章标题和摘要拼接后通过 EmbeddingClient 生成向量(默认维度 1536),存入 Milvus 向量数据库。searchSimilarPosts 将用户查询同样转为向量,通过余弦相似度搜索最相似的 TopK 篇文章。
生活类比:向量搜索就像"按意思找文章",而非"按关键词找文章"。 传统搜索要求文章标题必须包含"Java 多线程"才能匹配;向量搜索则能理解"并发编程"和"多线程"是同一个意思------即使文章标题里没有"并发"二字,也能被检索出来。
2.7 REST 控制器
java
package com.example.aiblog.controller;
import com.example.aiblog.entity.BlogAiConversation;
import com.example.aiblog.entity.BlogPost;
import com.example.aiblog.service.AiBlogService;
import com.example.aiblog.service.BlogService;
import com.example.aiblog.service.ConversationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/blog")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class BlogController {
private final BlogService blogService;
private final AiBlogService aiBlogService;
private final ConversationService conversationService;
/**
* 创建文章
*/
@PostMapping("/posts")
public BlogPost createPost(@RequestBody CreatePostRequest request) {
BlogPost post = new BlogPost();
post.setTitle(request.getTitle());
post.setContent(request.getContent());
post.setAuthorId(request.getAuthorId());
post.setAuthorName(request.getAuthorName());
post.setCoverImage(request.getCoverImage());
return blogService.createPost(post, request.isUseAiAssist());
}
/**
* 获取文章详情
*/
@GetMapping("/posts/{id}")
public BlogPost getPost(@PathVariable Long id,
@RequestHeader(value = "X-Forwarded-For", required = false) String ip) {
return blogService.getPost(id, ip != null ? ip : "unknown");
}
/**
* 文章列表
*/
@GetMapping("/posts")
public Page<BlogPost> getPostList(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String keyword) {
return blogService.getPostList(page, size, tag, keyword);
}
/**
* 热门文章
*/
@GetMapping("/posts/hot")
public List<BlogPost> getHotPosts(@RequestParam(defaultValue = "10") int limit) {
return blogService.getHotPosts(limit);
}
/**
* 语义搜索
*/
@GetMapping("/search")
public List<BlogPost> search(@RequestParam String q,
@RequestParam(defaultValue = "10") int limit) {
return blogService.semanticSearch(q, limit);
}
/**
* AI 写作助手 - 流式响应
*/
@GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> aiChat(
@RequestParam Long userId,
@RequestParam Long postId,
@RequestParam String message) {
List<BlogAiConversation> history = conversationService
.getRecentConversations(userId, postId, 10);
return aiBlogService.streamChat(userId, postId, message, history)
.doOnNext(content ->
conversationService.saveConversation(userId, postId, "user", message))
.doOnComplete(() ->
conversationService.saveConversation(userId, postId, "assistant", "[完成]"));
}
/**
* AI 生成摘要
*/
@PostMapping("/ai/summary")
public Map<String, String> generateSummary(@RequestBody Map<String, String> request) {
String summary = aiBlogService.generateSummary(request.get("content"));
return Map.of("summary", summary);
}
/**
* AI 生成标签
*/
@PostMapping("/ai/tags")
public Map<String, List<String>> generateTags(@RequestBody Map<String, String> request) {
List<String> tags = aiBlogService.generateTags(
request.get("title"), request.get("content"));
return Map.of("tags", tags);
}
/**
* AI 润色文章
*/
@PostMapping("/ai/polish")
public Map<String, String> polishContent(@RequestBody PolishRequest request) {
String polished = aiBlogService.polishContent(
request.getContent(), request.getStyle());
return Map.of("content", polished);
}
/**
* 获取写作灵感
*/
@GetMapping("/ai/ideas")
public Map<String, List<String>> getIdeas(@RequestParam String topic) {
List<String> ideas = aiBlogService.generateWritingIdeas(topic);
return Map.of("ideas", ideas);
}
}
// DTO 类
@Data
class CreatePostRequest {
private String title;
private String content;
private Long authorId;
private String authorName;
private String coverImage;
private boolean useAiAssist = true;
}
@Data
class PolishRequest {
private String content;
private String style = "专业";
}
三、前端实现
3.1 项目初始化
bash
npm create vite@latest ai-blog-frontend -- --template vue-ts
cd ai-blog-frontend
npm install
npm install element-plus @element-plus/icons-vue
npm install pinia vue-router axios
npm install @vueuse/core marked highlight.js
3.2 路由配置
typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/post/:id',
name: 'PostDetail',
component: () => import('@/views/PostDetail.vue')
},
{
path: '/edit',
name: 'PostEdit',
component: () => import('@/views/PostEdit.vue')
},
{
path: '/edit/:id',
name: 'PostEditWithId',
component: () => import('@/views/PostEdit.vue')
},
{
path: '/search',
name: 'Search',
component: () => import('@/views/Search.vue')
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
3.3 API 封装
typescript
// src/api/blog.ts
import axios from 'axios'
import type { BlogPost, PageResponse, AiChatMessage } from '@/types'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 30000
})
// 请求拦截器
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export const blogApi = {
// 获取文章列表
getPosts(params: {
page?: number
size?: number
tag?: string
keyword?: string
}) {
return api.get<PageResponse<BlogPost>>('/blog/posts', { params })
},
// 获取文章详情
getPost(id: number) {
return api.get<BlogPost>(`/blog/posts/${id}`)
},
// 创建文章
createPost(data: Partial<BlogPost> & { useAiAssist?: boolean }) {
return api.post<BlogPost>('/blog/posts', data)
},
// 更新文章
updatePost(id: number, data: Partial<BlogPost>) {
return api.put<BlogPost>(`/blog/posts/${id}`, data)
},
// 删除文章
deletePost(id: number) {
return api.delete(`/blog/posts/${id}`)
},
// 热门文章
getHotPosts(limit = 10) {
return api.get<BlogPost[]>('/blog/posts/hot', { params: { limit } })
},
// 语义搜索
search(query: string, limit = 10) {
return api.get<BlogPost[]>('/blog/search', { params: { q: query, limit } })
},
// AI 生成摘要
generateSummary(content: string) {
return api.post<{ summary: string }>('/blog/ai/summary', { content })
},
// AI 生成标签
generateTags(title: string, content: string) {
return api.post<{ tags: string[] }>('/blog/ai/tags', { title, content })
},
// AI 润色
polishContent(content: string, style: string) {
return api.post<{ content: string }>('/blog/ai/polish', { content, style })
},
// 获取写作灵感
getIdeas(topic: string) {
return api.get<{ ideas: string[] }>('/blog/ai/ideas', { params: { topic } })
}
}
// SSE 流式请求
export function createAiChatStream(
userId: number,
postId: number,
message: string,
onMessage: (content: string) => void,
onError?: (error: Error) => void
): EventSource {
const url = `${import.meta.env.VITE_API_BASE_URL}/blog/ai/chat?userId=${userId}&postId=${postId}&message=${encodeURIComponent(message)}`
const eventSource = new EventSource(url)
eventSource.onmessage = (event) => {
onMessage(event.data)
}
eventSource.onerror = (error) => {
onError?.(new Error('连接中断'))
eventSource.close()
}
return eventSource
}
3.4 类型定义
typescript
// src/types/index.ts
export interface BlogPost {
id: number
title: string
content: string
summary: string
coverImage?: string
tags: string[]
authorId: number
authorName: string
viewCount: number
likeCount: number
status: number
createdAt: string
updatedAt: string
publishedAt?: string
}
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
}
export interface AiChatMessage {
id: number
role: 'user' | 'assistant'
content: string
createdAt: string
}
export interface UserInfo {
id: number
username: string
nickname: string
avatar?: string
email: string
}
3.5 文章编辑组件(带 AI 助手)
vue
<!-- src/views/PostEdit.vue -->
<template>
<div class="post-edit-container">
<div class="edit-layout">
<!-- 左侧编辑区 -->
<div class="editor-section">
<el-input
v-model="post.title"
placeholder="请输入文章标题..."
class="title-input"
size="large"
/>
<div class="editor-toolbar">
<el-button-group>
<el-button @click="handleBold" :icon="Bold" />
<el-button @click="handleItalic" :icon="Italic" />
<el-button @click="handleHeading" :icon="Heading" />
<el-button @click="handleLink" :icon="Link" />
<el-button @click="handleImage" :icon="Picture" />
<el-button @click="handleCode" :icon="Code" />
</el-button-group>
<div class="ai-actions">
<el-button type="primary" @click="handleAiPolish" :loading="aiLoading">
<el-icon><Magic /></el-icon>
AI 润色
</el-button>
<el-button @click="handleAiSummary">
<el-icon><Document /></el-icon>
生成摘要
</el-button>
<el-button @click="handleAiTags">
<el-icon><PriceTag /></el-icon>
智能标签
</el-button>
</div>
</div>
<el-input
v-model="post.content"
type="textarea"
:rows="25"
placeholder="开始写作..."
class="content-editor"
@input="handleContentChange"
/>
<div class="tags-section">
<el-form-item label="标签">
<el-select
v-model="post.tags"
multiple
filterable
allow-create
placeholder="选择或输入标签"
style="width: 100%"
>
<el-option
v-for="tag in suggestedTags"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
</el-form-item>
</div>
<div class="summary-section">
<el-form-item label="摘要">
<el-input
v-model="post.summary"
type="textarea"
:rows="3"
placeholder="文章摘要,可点击上方按钮自动生成"
/>
</el-form-item>
</div>
<div class="action-buttons">
<el-button @click="handleSaveDraft">保存草稿</el-button>
<el-button type="primary" @click="handlePublish">发布文章</el-button>
</div>
</div>
<!-- 右侧 AI 助手 -->
<div class="ai-assistant">
<AiChatPanel
:post-id="postId"
:user-id="currentUser.id"
@insert-content="handleInsertContent"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Magic, Document, PriceTag, Bold, Italic, Heading, Link, Picture } from '@element-plus/icons-vue'
import { blogApi } from '@/api/blog'
import { useUserStore } from '@/stores/user'
import AiChatPanel from '@/components/AiChatPanel.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const postId = computed(() => route.params.id ? Number(route.params.id) : null)
const currentUser = computed(() => userStore.userInfo)
const post = reactive({
title: '',
content: '',
summary: '',
tags: [] as string[],
coverImage: '',
authorId: currentUser.value?.id || 0,
authorName: currentUser.value?.nickname || ''
})
const aiLoading = ref(false)
const suggestedTags = ref<string[]>([])
// 加载文章数据
onMounted(async () => {
if (postId.value) {
try {
const { data } = await blogApi.getPost(postId.value)
Object.assign(post, data)
} catch (error) {
ElMessage.error('加载文章失败')
}
}
})
// AI 润色
const handleAiPolish = async () => {
if (!post.content.trim()) {
ElMessage.warning('请先输入文章内容')
return
}
aiLoading.value = true
try {
const { data } = await blogApi.polishContent(post.content, '专业')
post.content = data.content
ElMessage.success('润色完成')
} catch (error) {
ElMessage.error('AI 润色失败')
} finally {
aiLoading.value = false
}
}
// 生成摘要
const handleAiSummary = async () => {
if (!post.content.trim()) {
ElMessage.warning('请先输入文章内容')
return
}
try {
const { data } = await blogApi.generateSummary(post.content)
post.summary = data.summary
ElMessage.success('摘要生成成功')
} catch (error) {
ElMessage.error('生成摘要失败')
}
}
// 智能标签
const handleAiTags = async () => {
if (!post.title.trim() || !post.content.trim()) {
ElMessage.warning('请先输入标题和内容')
return
}
try {
const { data } = await blogApi.generateTags(post.title, post.content)
post.tags = [...new Set([...post.tags, ...data.tags])]
ElMessage.success('标签生成成功')
} catch (error) {
ElMessage.error('生成标签失败')
}
}
// 保存草稿
const handleSaveDraft = async () => {
try {
if (postId.value) {
await blogApi.updatePost(postId.value, { ...post, status: 0 })
} else {
await blogApi.createPost({ ...post, status: 0 })
}
ElMessage.success('草稿保存成功')
} catch (error) {
ElMessage.error('保存失败')
}
}
// 发布文章
const handlePublish = async () => {
if (!post.title.trim() || !post.content.trim()) {
ElMessage.warning('标题和内容不能为空')
return
}
try {
if (postId.value) {
await blogApi.updatePost(postId.value, { ...post, status: 1 })
} else {
await blogApi.createPost({ ...post, useAiAssist: true })
}
ElMessage.success('发布成功')
router.push('/')
} catch (error) {
ElMessage.error('发布失败')
}
}
// 插入 AI 生成的内容
const handleInsertContent = (content: string) => {
post.content += '\n' + content
}
// 编辑器工具栏方法
const handleBold = () => insertMarkdown('**', '**')
const handleItalic = () => insertMarkdown('*', '*')
const handleHeading = () => insertMarkdown('## ', '')
const handleLink = () => insertMarkdown('[', '](url)')
const handleImage = () => insertMarkdown('')
const insertMarkdown = (prefix: string, suffix: string) => {
// 简化实现,实际应使用专业编辑器
post.content += `${prefix}文本${suffix}`
}
const handleContentChange = () => {
// 防抖处理,可用于自动保存
}
</script>
<style scoped>
.post-edit-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.edit-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 20px;
}
.editor-section {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.title-input {
margin-bottom: 16px;
font-size: 24px;
}
.title-input :deep(.el-input__inner) {
font-size: 24px;
font-weight: bold;
border: none;
padding-left: 0;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.ai-actions {
display: flex;
gap: 8px;
}
.content-editor :deep(.el-textarea__inner) {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 16px;
line-height: 1.8;
}
.tags-section,
.summary-section {
margin-top: 20px;
}
.action-buttons {
margin-top: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.ai-assistant {
position: sticky;
top: 20px;
}
</style>
3.6 AI 聊天面板组件
vue
<!-- src/components/AiChatPanel.vue -->
<template>
<div class="ai-chat-panel">
<div class="chat-header">
<el-icon><ChatDotRound /></el-icon>
<span>AI 写作助手</span>
<el-button
:icon="Delete"
circle
size="small"
@click="clearHistory"
/>
</div>
<div class="chat-messages" ref="messagesContainer">
<div
v-for="msg in messages"
:key="msg.id"
:class="['message', msg.role]"
>
<div class="message-avatar">
<el-avatar :size="32">
{{ msg.role === 'user' ? '我' : 'AI' }}
</el-avatar>
</div>
<div class="message-content">
<div v-if="msg.role === 'assistant'" class="ai-response">
<div v-html="formatMessage(msg.content)" />
<div v-if="msg.role === 'assistant' && msg.content" class="message-actions">
<el-button size="small" @click="insertToEditor(msg.content)">
插入文章
</el-button>
<el-button size="small" @click="copyContent(msg.content)">
复制
</el-button>
</div>
</div>
<div v-else>{{ msg.content }}</div>
</div>
</div>
<div v-if="isLoading" class="message assistant">
<div class="message-avatar">
<el-avatar :size="32">AI</el-avatar>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<div class="quick-actions">
<el-button
v-for="action in quickActions"
:key="action.label"
size="small"
@click="sendQuickAction(action.prompt)"
>
{{ action.label }}
</el-button>
</div>
<div class="chat-input">
<el-input
v-model="inputMessage"
type="textarea"
:rows="3"
placeholder="向 AI 助手提问..."
@keydown.enter.exact.prevent="sendMessage"
/>
<el-button
type="primary"
@click="sendMessage"
:loading="isLoading"
:disabled="!inputMessage.trim()"
>
<el-icon><Promotion /></el-icon>
发送
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Delete, Promotion } from '@element-plus/icons-vue'
import { marked } from 'marked'
import { createAiChatStream } from '@/api/blog'
const props = defineProps<{
postId: number
userId: number
}>()
const emit = defineEmits<{
(e: 'insertContent', content: string): void
}>()
interface ChatMessage {
id: number
role: 'user' | 'assistant'
content: string
}
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
let eventSource: EventSource | null = null
const quickActions = [
{ label: '优化开头', prompt: '请帮我优化这段文章的开头,使其更吸引人:' },
{ label: '扩展内容', prompt: '请帮我扩展以下内容,增加更多细节:' },
{ label: '总结要点', prompt: '请帮我总结这篇文章的核心要点:' },
{ label: '检查逻辑', prompt: '请帮我检查这段内容的逻辑是否通顺:' }
]
const sendMessage = () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userMsg: ChatMessage = {
id: Date.now(),
role: 'user',
content: inputMessage.value
}
messages.value.push(userMsg)
// 创建 AI 回复占位
const aiMsg: ChatMessage = {
id: Date.now() + 1,
role: 'assistant',
content: ''
}
messages.value.push(aiMsg)
isLoading.value = true
scrollToBottom()
// 关闭之前的连接
eventSource?.close()
// 建立 SSE 连接
eventSource = createAiChatStream(
props.userId,
props.postId,
inputMessage.value,
(content) => {
aiMsg.content += content
scrollToBottom()
},
(error) => {
ElMessage.error('连接出错,请重试')
isLoading.value = false
}
)
eventSource.onerror = () => {
isLoading.value = false
eventSource?.close()
}
inputMessage.value = ''
}
const sendQuickAction = (prompt: string) => {
inputMessage.value = prompt
sendMessage()
}
const clearHistory = () => {
messages.value = []
ElMessage.success('对话已清空')
}
const insertToEditor = (content: string) => {
emit('insertContent', content)
ElMessage.success('已插入到编辑器')
}
const copyContent = (content: string) => {
navigator.clipboard.writeText(content)
ElMessage.success('已复制到剪贴板')
}
const formatMessage = (content: string) => {
return marked(content)
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
onUnmounted(() => {
eventSource?.close()
})
</script>
<style scoped>
.ai-chat-panel {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
}
.chat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
border-bottom: 1px solid #eee;
font-weight: bold;
}
.chat-header span {
flex: 1;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.message.user {
flex-direction: row-reverse;
}
.message.user .message-content {
background: #409eff;
color: #fff;
border-radius: 12px 12px 4px 12px;
}
.message.assistant .message-content {
background: #f5f7fa;
border-radius: 12px 12px 12px 4px;
}
.message-content {
padding: 12px 16px;
max-width: 80%;
line-height: 1.6;
}
.message-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #eee;
}
.chat-input {
padding: 16px;
border-top: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 8px;
}
.typing-indicator {
display: flex;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #409eff;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
</style>
3.7 文章详情页
vue
<!-- src/views/PostDetail.vue -->
<template>
<div class="post-detail-container" v-if="post">
<article class="post-content">
<header class="post-header">
<h1 class="post-title">{{ post.title }}</h1>
<div class="post-meta">
<el-avatar :size="40" :src="authorAvatar" />
<div class="meta-info">
<span class="author">{{ post.authorName }}</span>
<span class="date">{{ formatDate(post.publishedAt) }}</span>
</div>
<div class="post-stats">
<el-tag size="small">{{ post.viewCount }} 阅读</el-tag>
<el-tag size="small" type="success">{{ post.likeCount }} 点赞</el-tag>
</div>
</div>
<div class="post-tags" v-if="post.tags?.length">
<el-tag
v-for="tag in post.tags"
:key="tag"
size="small"
class="tag-item"
@click="searchByTag(tag)"
>
{{ tag }}
</el-tag>
</div>
</header>
<div class="post-body" v-html="renderedContent" />
<div class="post-actions">
<el-button
:type="isLiked ? 'primary' : 'default'"
@click="handleLike"
:icon="isLiked ? Star : StarFilled"
>
{{ post.likeCount }}
</el-button>
<el-button @click="handleShare" :icon="Share">分享</el-button>
<el-button @click="handleCollect" :icon="Collection">收藏</el-button>
</div>
</article>
<!-- 相关文章推荐 -->
<aside class="related-posts">
<h3>相关推荐</h3>
<div v-for="related in relatedPosts" :key="related.id" class="related-item">
<router-link :to="`/post/${related.id}`">
{{ related.title }}
</router-link>
<span class="related-meta">{{ related.viewCount }} 阅读</span>
</div>
</aside>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Star, StarFilled, Share, Collection } from '@element-plus/icons-vue'
import { marked } from 'marked'
import { blogApi } from '@/api/blog'
import type { BlogPost } from '@/types'
const route = useRoute()
const router = useRouter()
const post = ref<BlogPost | null>(null)
const relatedPosts = ref<BlogPost[]>([])
const isLiked = ref(false)
const renderedContent = computed(() => {
return post.value ? marked(post.value.content) : ''
})
const authorAvatar = computed(() => {
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${post.value?.authorName}`
})
const formatDate = (date?: string) => {
if (!date) return ''
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const loadPost = async () => {
const id = Number(route.params.id)
try {
const { data } = await blogApi.getPost(id)
post.value = data
loadRelatedPosts(data.tags?.[0])
} catch (error) {
ElMessage.error('加载文章失败')
}
}
const loadRelatedPosts = async (tag?: string) => {
if (!tag) return
try {
const { data } = await blogApi.getPosts({ tag, size: 5 })
relatedPosts.value = data.content.filter(p => p.id !== post.value?.id)
} catch (error) {
console.error('加载相关文章失败', error)
}
}
const handleLike = async () => {
if (!post.value) return
isLiked.value = !isLiked.value
post.value.likeCount += isLiked.value ? 1 : -1
ElMessage.success(isLiked.value ? '点赞成功' : '已取消点赞')
}
const handleShare = () => {
navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制')
}
const handleCollect = () => {
ElMessage.success('收藏成功')
}
const searchByTag = (tag: string) => {
router.push({ path: '/search', query: { tag } })
}
onMounted(loadPost)
</script>
<style scoped>
.post-detail-container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.post-content {
background: #fff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.post-title {
font-size: 32px;
font-weight: bold;
margin-bottom: 20px;
line-height: 1.4;
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.meta-info {
display: flex;
flex-direction: column;
}
.author {
font-weight: bold;
}
.date {
color: #999;
font-size: 14px;
}
.post-stats {
margin-left: auto;
display: flex;
gap: 8px;
}
.post-tags {
margin-bottom: 20px;
}
.tag-item {
margin-right: 8px;
cursor: pointer;
}
.post-body {
font-size: 16px;
line-height: 1.8;
color: #333;
}
.post-body :deep(h2) {
margin-top: 32px;
margin-bottom: 16px;
}
.post-body :deep(p) {
margin-bottom: 16px;
}
.post-body :deep(pre) {
background: #f5f7fa;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.post-body :deep(code) {
font-family: 'Monaco', 'Menlo', monospace;
}
.post-actions {
display: flex;
gap: 12px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.related-posts {
margin-top: 20px;
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.related-posts h3 {
margin-bottom: 16px;
}
.related-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.related-item a {
color: #333;
text-decoration: none;
}
.related-item a:hover {
color: #409eff;
}
.related-meta {
color: #999;
font-size: 14px;
}
</style>
四、部署与运行
4.1 Docker Compose 配置
yaml
# docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ai_blog
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
milvus:
image: milvusdb/milvus:v2.3.4
environment:
ETCD_USE_EMBED: "true"
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
volumes:
- milvus_data:/var/lib/milvus
backend:
build: ./backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY}
depends_on:
- mysql
- redis
- milvus
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
volumes:
mysql_data:
redis_data:
milvus_data:
4.2 启动命令
bash
# 启动所有服务
docker-compose up -d
# 查看日志
docker-compose logs -f backend
# 初始化数据库
docker-compose exec backend mysql -uroot -proot ai_blog < init.sql
五、总结
本文详细介绍了如何使用 Vue3 + Spring AI 构建一个完整的 AI 智能博客平台,涵盖了:
- 后端架构:Spring Boot + Spring AI + Milvus 向量数据库
- 前端架构:Vue3 + TypeScript + Element Plus
- AI 功能:智能摘要、标签生成、文章润色、流式对话
- 语义搜索:基于 Milvus 的向量检索
- 部署方案:Docker Compose 一键部署
通过这个项目,你可以掌握全栈 AI 应用开发的核心技能,为构建更复杂的 AI 应用打下基础。
参考资源: