文章详情页 AI 快速阅读功能实现

文章目录

文章详情页 AI 快速阅读功能实现

最近给博客加了个小功能:用户进入文章详情页时,自动生成一段不超过 200 字的概要,用浅紫色半透明卡片展示。这样用户不用看完整个文章,就能快速了解文章的核心内容。

一、功能需求

  • 进入文章详情页时,自动触发 AI 生成概要
  • 概要不超过 200 字
  • 用浅紫色半透明卡片展示,带 AI 图标
  • 支持关闭和重试

二、技术选型

  • 后端:Spring Boot + OkHttp + DeepSeek/硅基流动 API
  • 前端:Vue 3 + TypeScript + Axios

三、后端实现

1. 添加依赖

如果你的项目还没有 OkHttp 依赖,需要在 pom.xml 中添加:

xml 复制代码
<!-- OkHttp (用于 AI 流式请求) -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>

注意:快速阅读功能用的是同步调用,不需要 SSE 依赖。但如果你已经有 AI 对话功能,应该已经添加了。

2. 配置文件

application-dev.ymlapplication.yml 中添加 AI 配置:

yaml 复制代码
# AI 对话配置
ai:
  deepseek:
    # API Key(从 https://siliconflow.cn 或 https://platform.deepseek.com 获取)
    api-key: ${AI_API_KEY:sk-你的API密钥}
    # API 地址(硅基流动免费,DeepSeek 需要付费)
    api-url: ${AI_API_URL:https://api.siliconflow.cn/v1/chat/completions}
    # 模型名称
    model: ${AI_MODEL:deepseek-ai/DeepSeek-V3-0324}
    # 系统提示词
    system-prompt: ${AI_SYSTEM_PROMPT:你是一个博客智能助手,帮助用户解答技术问题。请用简洁、专业的中文回答,支持 Markdown 格式。}

获取 API Key

3. 配置属性类

创建 DeepSeekProperties.java(如果还没有的话):

java 复制代码
package com.ican.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * DeepSeek AI 配置属性
 *
 * @author ican
 */
@Data
@Component
@ConfigurationProperties(prefix = "ai.deepseek")
public class DeepSeekProperties {

    /**
     * API Key
     */
    private String apiKey;

    /**
     * API 地址
     */
    private String apiUrl = "https://api.deepseek.com/chat/completions";

    /**
     * 模型名称
     */
    private String model = "deepseek-chat";

    /**
     * 系统提示词
     */
    private String systemPrompt = "你是一个博客智能助手,帮助用户解答技术问题。请用简洁、专业的中文回答,支持 Markdown 格式。";
}

4. AI 服务类

AiService.java 中添加快速阅读方法:

java 复制代码
package com.ican.service;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ican.config.properties.DeepSeekProperties;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class AiService {

    @Autowired
    private DeepSeekProperties deepSeekProperties;

    private final OkHttpClient httpClient = new OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(120, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build();

    /**
     * 快速阅读(200字概要)
     *
     * @param content 文章内容
     * @return 200字左右的概要
     */
    public String quickRead(String content) {
        // 截取前 4000 字,避免 token 过长
        String truncated = content.length() > 4000 ? content.substring(0, 4000) : content;

        JSONArray messagesArray = new JSONArray();

        // 系统提示词
        JSONObject systemMsg = new JSONObject();
        systemMsg.put("role", "system");
        systemMsg.put("content", "你是一个文章速读助手。请根据文章内容,生成一段不超过200字的中文概要。" +
                "要求:抓住文章核心要点,语言简洁流畅,不要使用 Markdown 格式,不要加前缀,直接输出概要。");
        messagesArray.add(systemMsg);

        // 用户消息
        JSONObject userMsg = new JSONObject();
        userMsg.put("role", "user");
        userMsg.put("content", "请为以下文章生成200字以内的快速阅读概要:\n\n" + truncated);
        messagesArray.add(userMsg);

        JSONObject requestBody = new JSONObject();
        requestBody.put("model", deepSeekProperties.getModel());
        requestBody.put("messages", messagesArray);
        requestBody.put("stream", false); // 非流式,同步返回
        requestBody.put("temperature", 0.3); // 降低随机性,概要更稳定
        requestBody.put("max_tokens", 400); // 限制输出长度,约 200 字

        Request request = new Request.Builder()
                .url(deepSeekProperties.getApiUrl())
                .addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
                .addHeader("Content-Type", "application/json")
                .post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                String body = response.body() != null ? response.body().string() : "";
                log.error("AI 快速阅读失败: {} {}", response.code(), body);
                throw new RuntimeException("AI 快速阅读失败: " + response.code());
            }
            String responseBody = response.body().string();
            JSONObject jsonResponse = JSON.parseObject(responseBody);
            JSONArray choices = jsonResponse.getJSONArray("choices");
            if (choices != null && !choices.isEmpty()) {
                JSONObject message = choices.getJSONObject(0).getJSONObject("message");
                if (message != null) {
                    String result = message.getString("content");
                    return result != null ? result.trim() : "";
                }
            }
            return "";
        } catch (IOException e) {
            log.error("AI 快速阅读异常", e);
            throw new RuntimeException("AI 快速阅读异常: " + e.getMessage());
        }
    }
}

关键点说明

  • stream: false:非流式调用,同步返回结果
  • temperature: 0.3:降低随机性,概要更稳定
  • max_tokens: 400:限制输出长度,约 200 字
  • 截取前 4000 字:避免文章太长导致 token 超限
  • 系统提示词明确要求:不要 Markdown,不要前缀,直接输出概要

5. 控制器

AiController.java 中添加接口:

java 复制代码
package com.ican.controller;

import com.ican.model.vo.Result;
import com.ican.service.AiService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * AI 对话控制器
 *
 * @author ican
 */
@Api(tags = "AI 模块")
@RestController
public class AiController {

    @Autowired
    private AiService aiService;

    /**
     * AI 快速阅读(200字概要,前台文章详情页使用)
     *
     * @param body 请求体,包含 content 字段(文章内容)
     * @return 200字左右的概要
     */
    @ApiOperation(value = "AI 快速阅读")
    @PostMapping("/ai/quick-read")
    public Result<String> quickRead(@RequestBody Map<String, String> body) {
        String content = body.get("content");
        if (content == null || content.trim().isEmpty()) {
            return Result.fail("文章内容不能为空");
        }
        return Result.success(aiService.quickRead(content));
    }
}

接口放在 /ai/ 路径下,不需要登录(前台功能)。

四、前端实现

1. API 函数

api/article/index.ts 中添加:

typescript 复制代码
import { PageQuery, PageResult, Result } from "@/model";
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { Article, ArticleInfo, ArticleRank, ArticleRecommend, ArticleSearch } from "./types";

/**
 * AI 快速阅读
 * @param content 文章内容
 * @returns 200字概要
 */
export function quickReadArticle(content: string): AxiosPromise<Result<string>> {
  return request({
    url: "/ai/quick-read",
    method: "post",
    timeout: 60000, // AI 调用可能需要较长时间,设置 60 秒超时
    data: { content },
  });
}

注意设置了 timeout: 60000(60 秒),因为 AI 调用可能需要几秒到十几秒。

2. 文章详情页添加快速阅读卡片

views/Article/Article.vue 中:

vue 复制代码
<template>
    <div class="bg">
        <div class="main-container" v-if="article">
            <div class="left-container" :class="app.sideFlag ? 'w-full' : ''">
                <!-- AI 快速阅读 -->
                <div class="quick-read-card" v-if="quickReadVisible">
                    <div class="quick-read-header">
                        <div class="quick-read-title">
                            <svg-icon icon-class="ai" size="1.2rem" style="margin-right: 0.4rem"></svg-icon>
                            <span>AI 快速阅读</span>
                        </div>
                        <button class="quick-read-close" @click="quickReadVisible = false">
                            <svg-icon icon-class="close" size="0.8rem"></svg-icon>
                        </button>
                    </div>
                    <div class="quick-read-body">
                        <div v-if="quickReadLoading" class="quick-read-loading">
                            <span class="dot-loading"></span>
                            AI 正在阅读文章...
                        </div>
                        <div v-else-if="quickReadError" class="quick-read-error">
                            {{ quickReadError }}
                            <button class="retry-btn" @click="fetchQuickRead">重试</button>
                        </div>
                        <p v-else class="quick-read-content">{{ quickReadText }}</p>
                    </div>
                </div>
                <!-- 文章内容 -->
                <div class="article-container">
                    <!-- ... 原有文章内容 ... -->
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { getArticle, likeArticle, quickReadArticle } from "@/api/article";
import { ArticleInfo, ArticlePagination } from "@/api/article/types";
import { useAppStore, useBlogStore, useUserStore } from "@/store";
import { formatDate } from "@/utils/date";

const user = useUserStore();
const app = useAppStore();
const blog = useBlogStore();
const route = useRoute();

// 文章数据
const article = ref<ArticleInfo | null>(null);

// AI 快速阅读
const quickReadVisible = ref(true);
const quickReadLoading = ref(false);
const quickReadText = ref("");
const quickReadError = ref("");

const fetchQuickRead = () => {
    if (!article.value?.articleContent) return;
    quickReadLoading.value = true;
    quickReadError.value = "";
    quickReadArticle(article.value.articleContent)
        .then(({ data }) => {
            if (data.flag && data.data) {
                quickReadText.value = data.data;
            } else {
                quickReadError.value = data.msg || "生成失败";
            }
        })
        .catch(() => {
            quickReadError.value = "AI 服务暂时不可用,请稍后重试";
        })
        .finally(() => {
            quickReadLoading.value = false;
        });
};

onMounted(() => {
    getArticle(Number(route.params.id)).then(({ data }) => {
        article.value = data.data;
        // ... 其他逻辑 ...
        // 自动触发 AI 快速阅读
        fetchQuickRead();
    });
});
</script>

<style lang="scss" scoped>
/* AI 快速阅读卡片 */
.quick-read-card {
    margin-bottom: 1rem;
    border-radius: 0.75rem;
    background: rgba(147, 112, 219, 0.12);
    backdrop-filter: blur(10px);
    border: 1px solid rgba(147, 112, 219, 0.25);
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(147, 112, 219, 0.1);
    animation: fadeInDown 0.5s ease;
}

@keyframes fadeInDown {
    from {
        opacity: 0;
        transform: translateY(-10px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.quick-read-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.75rem 1rem;
    border-bottom: 1px solid rgba(147, 112, 219, 0.15);
}

.quick-read-title {
    display: flex;
    align-items: center;
    font-size: 0.95rem;
    font-weight: 600;
    color: #6a3fbf;
}

.quick-read-close {
    background: none;
    border: none;
    cursor: pointer;
    padding: 0.25rem;
    border-radius: 50%;
    color: var(--grey-5);
    transition: all 0.2s;

    &:hover {
        background: rgba(0, 0, 0, 0.06);
        color: var(--grey-7);
    }
}

.quick-read-body {
    padding: 1rem 1.25rem;
}

.quick-read-content {
    margin: 0;
    font-size: 0.9rem;
    line-height: 1.7;
    color: var(--grey-7);
}

.quick-read-loading {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.9rem;
    color: #6a3fbf;
}

.dot-loading {
    display: inline-block;
    width: 1.5rem;
    height: 0.5rem;

    &::after {
        content: "...";
        animation: dotAnim 1.5s steps(3, end) infinite;
        font-size: 1.2rem;
        letter-spacing: 2px;
    }
}

@keyframes dotAnim {
    0% { content: "."; }
    33% { content: ".."; }
    66% { content: "..."; }
}

.quick-read-error {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.9rem;
    color: #e85d5d;
}

.retry-btn {
    background: rgba(147, 112, 219, 0.15);
    border: 1px solid rgba(147, 112, 219, 0.3);
    border-radius: 0.3rem;
    padding: 0.15rem 0.5rem;
    color: #6a3fbf;
    font-size: 0.8rem;
    cursor: pointer;
    transition: all 0.2s;

    &:hover {
        background: rgba(147, 112, 219, 0.25);
    }
}
</style>

五、使用效果

  1. 用户进入文章详情页
  2. 页面加载完成后,自动触发 AI 快速阅读
  3. 顶部显示浅紫色半透明卡片,显示「AI 正在阅读文章...」
  4. 几秒后显示生成的概要(不超过 200 字)
  5. 用户可以关闭卡片,也可以点击重试

六、遇到的问题

1. AI 返回格式问题

问题:AI 可能返回带 Markdown 格式的文本,或者带前缀「概要:」。

解决:在系统提示词中明确要求「不要使用 Markdown 格式,不要加任何前缀,直接输出概要」。后端再做一些清理。

2. 文章内容过长

问题:文章可能有几万字,全部传给 AI 会超出 token 限制。

解决:后端截取前 4000 字,足够生成概要,也避免 token 超限。

3. 用户体验

问题:AI 调用可能需要几秒到十几秒,用户等待时需要有反馈。

解决:显示 loading 状态和加载动画,失败时显示错误信息和重试按钮。

七、总结

这个功能实现起来不算复杂:

  1. 后端:新增一个同步调用的 AI 接口,专门用于生成快速阅读概要
  2. 前端:在文章详情页顶部添加卡片,进入页面时自动调用接口

关键是:

  • 系统提示词要明确(不要 Markdown,不要前缀)
  • 设置合适的 temperaturemax_tokens
  • 前端设置足够的超时时间
  • 处理错误情况,给用户友好提示(loading、重试)

现在用户进入文章页时,可以快速了解文章内容,不用看完整个文章。如果生成的不满意,也可以关闭卡片,不影响正常阅读。

八、完整代码文件清单

后端

  • pom.xml - 添加 OkHttp 依赖
  • application-dev.yml - 添加 AI 配置
  • DeepSeekProperties.java - 配置属性类
  • AiService.java - 添加 quickRead 方法
  • AiController.java - 添加 /ai/quick-read 接口

前端

  • api/article/index.ts - 添加 quickReadArticle 函数
  • views/Article/Article.vue - 添加快速阅读卡片和逻辑

按照上面的步骤,就可以在自己的项目中实现这个功能了。

相关推荐
安科士andxe3 小时前
深入解析|安科士1.25G CWDM SFP光模块核心技术,破解中长距离传输痛点
服务器·网络·5g
九.九5 小时前
ops-transformer:AI 处理器上的高性能 Transformer 算子库
人工智能·深度学习·transformer
春日见5 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
恋猫de小郭5 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
YJlio6 小时前
1.7 通过 Sysinternals Live 在线运行工具:不下载也能用的“云端工具箱”
c语言·网络·python·数码相机·ios·django·iphone
deephub6 小时前
Agent Lightning:微软开源的框架无关 Agent 训练方案,LangChain/AutoGen 都能用
人工智能·microsoft·langchain·大语言模型·agent·强化学习
CTRA王大大6 小时前
【网络】FRP实战之frpc全套配置 - fnos飞牛os内网穿透(全网最通俗易懂)
网络
大模型RAG和Agent技术实践6 小时前
从零构建本地AI合同审查系统:架构设计与流式交互实战(完整源代码)
人工智能·交互·智能合同审核
老邋遢6 小时前
第三章-AI知识扫盲看这一篇就够了
人工智能
互联网江湖6 小时前
Seedance2.0炸场:长短视频们“修坝”十年,不如AI放水一天?
人工智能