Spring AI的架构以及仿照小红书界面的博客实现

本次分享一下如何搭建Spring AI框架,构建属于自己的AI,以及由于项目后端由我实现,所以我总结一下项目的后端部分。

Spring AI

Spring AI 是一个由 Spring 团队开发的开源框架,可以使用来构建属于自己的智能机器人,接下来我将分享如何去构建Spring AI。

pom.xml的配置

Spring AI配置不难只需要在原有Spring boot的依赖上如下代码就可以。我使用的是java17和spring-ai的0.8.0版本(!这个很重要不然会出现依赖冲突)

 <properties>
        <java.version>17</java.version>
        <spring-ai.version>0.8.0</spring-ai.version> 
</properties> 

<dependencies>
 <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
      <version>${spring-ai.version}</version>
  </dependency>
 </dependencies>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <url>https://repo.spring.io/milestone</url>
        </repository>
        <repository>
            <id>spring-ai-releases</id>
            <url>https://repo.spring.ai/releases</url>
        </repository>
     </repositories>


配置Api-key和Base-url

由于我是maven配置的项目,所以在application.properties配置自己的api-key和base-url以及模型,一定要配置模型不然很可能请求访问不到。

spring.ai.openai.api-key=your api key
spring.ai.openai.base-url=https://api.deepseek.com
spring.ai.openai.chat.options.model=deepseek-chat

使用Controller来实现AI调用

我们将 AI 调用逻辑封装到一个控制器类中,同时要注意AI的输出一定要是流式的,不然会等很久才能得到反馈体验很不好。

流式输出

spring ai里面自带的有实现流式输出的对象,可以直接调用StreamingChatClient来实现。

package com.example.demo.Controller;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.Generation; 
import org.springframework.ai.chat.messages.AssistantMessage; 
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public Flux<String> stream(@RequestParam(value = "input", defaultValue = "Hello") String message) {
        Prompt prompt = new Prompt(message);
        return streamingChatClient.stream(prompt)
                .map(chatResponse -> {
                    if (chatResponse != null && chatResponse.getResults() != null && !chatResponse.getResults().isEmpty()) {
                         Generation generation = chatResponse.getResults().get(0);
                         if (generation != null && generation.getOutput() != null) {
                             AssistantMessage assistantMessage = generation.getOutput();
                             if (assistantMessage != null && assistantMessage.getContent() != null) {
                                  return assistantMessage.getContent(); // **使用 getContent()**
                             }
                         }
                    }
                    return "";
                });
    }

可以直接调用StreamingChatClient 的 stream 方法,将 Prompt 对象传递给聊天模型。 该方法返回一个 Flux<ChatResponse>,表示一个响应流。我们只需在前端解析这个响应流就可以实现流式输出了。

前端调用

我们在前端写一个简单的弹窗来测试一下输出的效果,首先要在前端编写函数来处理后端发来的Flux流

const processStreamingResponse = async (url, onMessageChunk, onError, onComplete) => {
    try {
        const response = await fetch(url);

        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';
        const dataPrefix = 'data:';

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

            if (done) {
                break;
            }

            buffer += decoder.decode(value, { stream: true });

            let newlineIndex;
            while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
                const line = buffer.substring(0, newlineIndex);
                buffer = buffer.substring(newlineIndex + 1);

                const dataIndex = line.indexOf(dataPrefix);

                if (dataIndex !== -1) {
                    const contentStartIndex = dataIndex + dataPrefix.length;
                    let payloadStartIndex = contentStartIndex;
                    if (payloadStartIndex < line.length && line[payloadStartIndex] === ' ') {
                        payloadStartIndex++;
                    }

                    const dataContent = line.substring(payloadStartIndex);
                    onMessageChunk(dataContent);
                }
            }
        }

        const dataIndex = buffer.indexOf(dataPrefix);
        if (dataIndex !== -1) {
            const contentStartIndex = dataIndex + dataPrefix.length;
            let payloadStartIndex = contentStartIndex;
            if (payloadStartIndex < buffer.length && buffer[payloadStartIndex] === ' ') {
                payloadStartIndex++;
            }
            const dataContent = buffer.substring(payloadStartIndex);
            onMessageChunk(dataContent);
        }

        const finalDecoded = decoder.decode(undefined, { stream: false });
        const dataIndexFinal = finalDecoded.indexOf(dataPrefix);
        if (dataIndexFinal !== -1) {
            const contentStartIndex = dataIndexFinal + dataPrefix.length;
            let payloadStartIndex = contentStartIndex;
            if (payloadStartIndex < finalDecoded.length && finalDecoded[payloadStartIndex] === ' ') {
                payloadStartIndex++;
            }
            const dataContent = finalDecoded.substring(payloadStartIndex);
            onMessageChunk(dataContent);
        }

        if (onComplete) {
            onComplete();
        }
    } catch (error) {
        onError(error);
    }
};

 由于有效token前面会有"data"这个前缀,所以我们这个函数用于识别 SSE 格式中的数据行,通过逐步读取和解码流数据,提取有效内容,并通过回调函数通知调用者。这种实现方式适用于与后端的实时交互场景,例如聊天应用或实时数据更新。

效果展示

可以看出这样流式的输出就不会等很久,如果是同步的输出那么就会等非常非常久,体验很不好。后续如何解决输出文本很好的转化为markdown格式是待完成的任务。

 

小红书风格的笔记页面实现

直接展示效果,仿照小红书的首页布局。点击聊天就能和我们上部分写的AI进行对话。

这是单个笔记的页面

前端部分我就不进行讲解,我主要讲解数据的爬取和导入。

爬虫的实现(只用于学习)

我大概爬取了100个旅游帖子的资源用于我们博客页面的初始化,爬取为csv的格式用于后续写道数据库中(当然也可以直接写到数据库中)

爬虫的部分代码

DrissionPage 是一个基于 Python 的自动化测试工具,它结合了 Selenium 和 Pyppeteer 的功能,提供了更灵活的网页操作接口。

如果没有安装库先在终端输入

pip install DrissionPage

 这里我们先设计关键词,爬取的轮数,输出的文件,谷歌浏览器的exe文件的地址以及csv的每个列的内容。

import time
import random
import csv
from DrissionPage import ChromiumPage, ChromiumOptions
KEYWORD = "旅游"
SCROLL_TIMES = 20
OUTPUT_CSV_FILE = "xiaohongshu_blogs.csv"
browser_exe_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
chromium_options = ChromiumOptions()
chromium_options.set_browser_path(browser_exe_path)
csv_headers = ["关键词", "笔记ID", "标题", "内容", "作者昵称", "作者头像URL", "点赞数", "评论数", "图片URL"]

 函数收集我们需要爬取的链接,用不断下滑的方式刷新帖子来爬取链接。

def search_and_collect_links(page, keyword, scroll_times):
    print(f"正在搜索关键词: {keyword}")
    search_url =url##替换实际的url
    page.get(search_url)
    try:
        page.wait.ele_displayed('.feeds-container', timeout=20)
    except Exception as e:
        return []

    note_links = set()
    processed_urls = set()
    xpath_selector_for_links = "element"##替换实际的元素的Xpath
    for i in range(scroll_times):
        print(f"正在滚动搜索结果页 ({i+1}/{scroll_times})...")
        link_eles = page.eles(f'xpath:{xpath_selector_for_links}')

        if not link_eles:
             print("未找到笔记链接元素,可能已滚到底部或选择器错误。")
             if i == 0:
                  break
             print("尝试再次滚动加载更多...")
             page.scroll.to_bottom()
             time.sleep(random.uniform(3, 5))
             new_link_eles = page.eles(f'xpath:{xpath_selector_for_links}')
             if not new_link_eles or len(new_link_eles) <= len(link_eles):
                 print("二次查找未找到新链接元素,可能已滚到底部。")
                 break
             else:
                 link_eles = new_link_eles
                 print("二次查找找到新链接元素。")
        current_scroll_new_links = 0
        for link_ele in link_eles:
            try:
                link = link_ele.link
                if link and link.startswith('http') and link not in processed_urls:
                     note_links.add(link)
                     processed_urls.add(link)
                     current_scroll_new_links += 1
            except Exception as e:
                 continue

 剩下的根据我们需要的元素去爬取对应的元素(在开发者模式找)即可。爬取的时间会很慢(避免封号和防爬),模拟人正常浏览的速度即可,不会对服务器造成压力。

,数据导入到mongodb中

由于我的数据是放在mongodb中,所以我需要将csv导入到我的mongdb中。

import csv
from pymongo import MongoClient
import os
import sys
CSV_FILE_PATH = "xiaohongshu_blogs.csv"
MONGO_URI = "your url"
MONGO_DATABASE = "your base"
MONGO_COLLECTION = "your collection"
def import_csv_to_mongodb(csv_file, mongo_uri, mongo_db, mongo_collection_name):
    if not os.path.exists(csv_file):
        print(f"错误:未找到 CSV 文件 '{csv_file}'。请检查文件路径是否正确。")
        return
    client = None
    try:
        client = MongoClient(mongo_uri)
        client.admin.command('ping')

        db = client[mongo_db]
        collection = db[mongo_collection_name]

        with open(csv_file, mode='r', newline='', encoding='utf-8') as infile:
            reader = csv.DictReader(infile)

            imported_count = 0
            skipped_count = 0

            for row in reader:
                mongo_document = {}

                mongo_document["title"] = row.get('标题', '')
                mongo_document["content"] = row.get('内容', '')

                image_urls_string = row.get('图片URL', '')
                mongo_document["images"] = [url.strip() for url in image_urls_string.split(';') if url.strip()]

                author_name = row.get('作者昵称', '')
                author_avatar = row.get('作者头像URL', '')
                if author_name or author_avatar:
                    mongo_document["author"] = {
                        "name": author_name,
                        "avatar": author_avatar
                    }

                try:
                    mongo_document["likes"] = int(row.get('点赞数', '0'))
                except (ValueError, TypeError):
                    mongo_document["likes"] = 0

                try:
                    mongo_document["comment_count"] = int(row.get('评论数', '0'))
                except (ValueError, TypeError):
                    mongo_document["comment_count"] = 0
                mongo_document["note_id"] = row.get('笔记ID', '')

                if mongo_document.get("title") or mongo_document.get("content") or mongo_document.get("images"):
                    try:
                        collection.insert_one(mongo_document)
                        imported_count += 1
                    except Exception as insert_e:
                        skipped_count += 1
                else:
                    skipped_count += 1

        print(f"数据导入完成。成功导入 {imported_count} 条记录,跳过 {skipped_count} 条记录。")

    except Exception as e:
        print(f"导入过程中发生错误: {e}")
    finally:
        if client:
            client.close()

def delete_blogs_by_author(mongo_uri, mongo_db, mongo_collection_name, author_name):
    client = None
    try:
        client = MongoClient(mongo_uri)
        client.admin.command('ping')

        db = client[mongo_db]
        collection = db[mongo_collection_name]

        delete_filter = { "author.name": author_name }
        result = collection.delete_many(delete_filter)

        print(f"删除操作完成。成功删除了 {result.deleted_count} 条记录,作者名字为 '{author_name}'。")

    except Exception as e:
        print(f"删除过程中发生错误: {e}")
        sys.exit(1)
    finally:
        if client:
            client.close()

if __name__ == '__main__':
    AUTHOR_NAME_TO_DELETE = "蓝战非vlog"
    delete_blogs_by_author(MONGO_URI, MONGO_DATABASE, MONGO_COLLECTION, AUTHOR_NAME_TO_DELETE)

通过运行上述脚本程序我们就能够将笔记存到信息中展示出来,展示的后端逻辑在第二次报告中提给出。

总结

任务已经过去了一半,后续着重于界面的美化和功能的拓展,争取打造出实用美观的网站。

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值