博主智算菩萨,专注于人工智能、Python编程、音视频处理及UI窗体程序设计等方向。致力于以通俗易懂的方式拆解前沿技术,从零基础入门到高阶实战,陪伴开发者共同成长。目前已开设五大技术专栏,累计发布多篇原创技术文章,深受读者好评。

📌 专栏导航

  • 人工智能前沿知识:深度剖析Transformer架构、生成式AI、强化学习、具身智能、神经符号系统、大模型及智能体(Agent)技术,系统性解析AI核心技术体系与前沿趋势。
  • Python基础小白编程:从零开始,以保姆式教程讲解变量、数据类型、流程控制、函数等核心语法,配有大量实战代码与避坑指南,真正做到学以致用。
  • 机器学习与深度学习:系统化拆解线性模型、决策树、随机森林、梯度提升树、神经网络等算法原理与工程实践,覆盖从公式推导到代码实现的全链路内容。
  • 音频、图像与视频处理理论与实战:涵盖FFmpeg多媒体处理、audio_shop开源工具、ComfyUI-WanVideoWrapper视频生成等实用技术,从基础操作到高级应用一应俱全。
  • UI窗体程序设计实战:深入讲解UI设计、动态窗体生成、游戏UI框架设计等实战技巧,提供从配置到编码的完整解决方案。
    智算菩萨,以代码为经,以算法为纬,在人工智能的星辰大海中,做你前行路上最可靠的导航者。

本文手把手带你实现一个功能完整的AI智能体应用:基于Flask后端与原生前端,支持多轮对话、百度实时搜索(三种强度)、流式Markdown渲染、多会话管理。所有代码均已开源,可直接运行。


1. 为什么你需要自己搭建一个智能体?

大语言模型的API调用门槛已经极低,几行代码就能让模型回答一个问题。然而,在真实业务场景中,单次问答远远不够。一个可用的智能体应用必须解决上下文断裂信息滞后体验生硬配置匮乏四大痛点。很多开发者尝试在Jupyter Notebook中调用API觉得效果惊艳,但一旦要集成到网站或企业内部工具中,就暴露出工程能力的不足。

本文的目标是让你彻底掌握构建生产级智能体应用的全部技能。我们将从零开始,使用Flask作为后端框架,配合API聚合站提供的多种模型接口,再结合百度搜索实现免API密钥的实时联网检索。前端不依赖任何现代框架(Vue/React),而是用纯HTML/CSS/JS实现流式输出、Markdown实时渲染、多会话切换等功能。整个项目代码量适中,结构清晰,你可以直接用于自己的个人助手或团队内部工具。

最终效果如下:用户在浏览器中填写API密钥,选择模型(免费或付费),勾选联网搜索并决定快速/标准/深度三档强度,然后输入问题。AI不仅能根据历史对话连贯回答,还能实时检索百度最新的网页内容并提取正文,生成带有信息来源(右侧面板展示链接)的回答。整个过程逐字流式输出,而且所有对话都会自动保存,即使重启服务也不会丢失。
在这里插入图片描述

2. 项目整体架构与技术选型

2.1 核心技术组件一览

层次 技术 作用
后端框架 Flask 提供REST API和SSE流式响应
模型调用 OpenAI SDK 统一接口调用多种大模型(兼容OpenAI格式)
联网搜索 baidusearch + requests 百度检索 + 并发抓取全文,国内可用、无密钥
正文提取 正则清洗 去掉广告/脚本,保留中文正文
前端 原生HTML/CSS/JS 轻量、无构建步骤
Markdown解析 marked.js 实时渲染代码块、表格、列表
持久化 JSON文件 存储在系统临时目录,自动保存加载

Flask之所以被选中,是因为它内置了开发服务器且对Server‑Sent Events有极好的支持。OpenAI SDK的stream=True模式与SSE天然契合,后端只需要将模型返回的token逐块转发给前端即可。baidusearch库是当前国内维护最活跃的百度搜索非官方包,它定期更新以适配百度的前端变化,相比自己写正则表达式要稳定得多。全文抓取部分我们使用concurrent.futures.ThreadPoolExecutor实现并发,配合重试机制和超时控制,即使部分网页慢或失败也不影响整体。

2.2 核心数据流详解

用户在前端输入消息并点击发送后,前端会构造一个POST请求发送到/api/chat接口,请求体中包含API密钥、模型名称、消息内容、对话ID(若无则后端生成)、是否开启搜索及强度、以及其他参数(温度、top_p等)。后端首先校验API密钥的格式和有效性,然后根据enable_search标志决定是否调用搜索模块。若需要搜索,WebSearcher类会执行百度检索,并根据强度决定是否并发抓取网页正文,最终将搜索结果格式化为一段自然语言描述,附加到系统提示词中。接着,后端从conversation_store中加载该对话的历史消息,连同用户新消息和系统提示词(内含搜索结果)一起构造成OpenAI API所要求的消息列表。调用模型时设置stream=True,模型会以增量方式返回token。后端接收到每个token后,立即通过SSE事件(data: {type:"content", content:"..."})发送给前端。前端使用ReadableStream逐块读取,并实时将内容追加到当前助手消息气泡中,同时调用marked.parse进行Markdown渲染。当模型输出结束后,后端发送done事件,并将完整的助手回复存入对话历史。此外,如果开启了搜索,后端还会在流开始前发送一个search_results事件,携带原始搜索结果列表(标题、URL、摘要),前端收到后会在右侧面板中渲染可点击的链接,方便用户核实信息来源。整个流程中,对话列表的侧边栏会随时更新,用户可以新建会话或切换历史会话。

3. 后端开发详解:从零搭建Flask服务

3.1 初始化、全局配置与持久化设计

app.py文件的开头,我们需要导入必要的库:json用于处理对话和缓存文件的读写,uuid生成唯一对话ID,re用于正文提取的正则,concurrent.futures实现并发抓取,requests发送HTTP请求,ostempfile确定临时存储路径,atexit注册退出钩子,datetime记录时间戳,以及flaskopenai。下面这段代码展示了应用的初始化、模型配置以及持久化目录的创建。

import json
import uuid
import re
import time
import hashlib
import concurrent.futures
import requests
import os
import tempfile
import atexit
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response, stream_with_context
from openai import OpenAI

app = Flask(__name__)

API_BASE_URL = "https://api.aigc.bar/v1"
DEFAULT_MODEL = "gemini-2.5-flash-lite"
AVAILABLE_MODELS = {
    "gemini-2.5-flash-lite": {
        "name": "Gemini 2.5 Flash Lite",
        "group": "free",
        "label": "免费",
        "description": "免费模型,适合日常对话",
        "max_tokens_limit": 65535,
        "supports_vision": False
    },
    "gpt-5.5-xhigh": {
        "name": "GPT-5.5 XHigh",
        "group": "paid",
        "label": "最强付费",
        "description": "付费模型,高质量生成",
        "max_tokens_limit": 65535,
        "supports_vision": True
    }
}

conversation_store = {}
search_cache = {}
SEARCH_CACHE_TTL = 600

TEMP_DIR = os.path.join(tempfile.gettempdir(), "ai_chat_assistant")
os.makedirs(TEMP_DIR, exist_ok=True)
CONVERSATIONS_FILE = os.path.join(TEMP_DIR, "conversations.json")
SEARCH_CACHE_FILE = os.path.join(TEMP_DIR, "search_cache.json")

为了避免重启后数据丢失,我们实现了两个持久化函数:save_conversationsload_conversations分别负责将conversation_store写入JSON文件和从文件恢复;类似地,save_search_cacheload_search_cache处理搜索缓存。使用atexit.register确保Python进程正常退出时自动保存,而在启动时立即调用加载函数恢复之前的状态。这种设计没有引入额外数据库,部署简单,对个人项目或小团队完全够用。

3.2 联网搜索模块:免API密钥的百度搜索与全文抓取

3.2.1 集成baidusearch库及其导入兼容性

由于百度官方没有公开的搜索API,常规做法是爬取搜索结果页面。但手动构造请求并解析HTML极易因百度改版而失效。baidusearch库(安装命令pip install --upgrade baidusearch)封装了这些细节,是目前最稳定的免费中文搜索方案。为了兼容不同版本的包结构,代码中使用了两层try‑except,如下所示。

try:
    from baidusearch import search as baidu_search_legacy
except ImportError:
    try:
        from baidusearch.baidusearch import search as baidu_search_legacy
    except ImportError:
        baidu_search_legacy = None
        print("Warning: baidusearch not found. Please run: pip install --upgrade baidusearch")

3.2.2 网页正文提取算法:从噪音中抓取有效内容

百度返回的摘要很短,为了给模型提供足够的参考信息,我们需要打开每个搜索结果链接,提取其中的可读正文。extract_readable_text(html)函数承担这一任务,其核心是移除所有与正文无关的HTML元素,然后进行文本清洗和过滤。具体代码如下。

def extract_readable_text(html):
    text = re.sub(r'<(script|style|nav|header|footer|aside|form|iframe|noscript)[^>]*>.*?</\1>',
                  '', html, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
    text = re.sub(r'</(div|p|br|li|h[1-6]|tr|td|th|section|article)>', '\n', text, flags=re.IGNORECASE)
    text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
    text = re.sub(r'<[^>]+>', ' ', text)
    text = re.sub(r'[ \t]+', ' ', text)
    text = re.sub(r'\n\s*\n', '\n', text)
    lines = [line.strip() for line in text.split('\n') if line.strip()]
    filtered = []
    for line in lines:
        chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', line))
        if chinese_chars >= 3 or len(line) >= 30:
            filtered.append(line)
    return '\n'.join(filtered)

该函数首先移除脚本、样式、导航等标签及其内容,再移除注释,然后将块级标签替换为换行,删除剩余HTML标签,压缩空白,最后按行过滤(保留中文不少于3个或长度≥30的行),从而得到干净的正文。

3.2.3 强度配置与并发抓取逻辑

为了兼顾不同场景下的速度与信息量,我们设计了三种搜索强度,其参数配置直接在WebSearcher.search()方法中定义。快速搜索(强度1)只从百度获取10条结果的标题、URL和摘要,不抓取任何网页全文;标准搜索(强度2)检索20条结果,并并发抓取全部20个网页的全文,每页最多2000字符;深度搜索(强度3)检索50条结果,每页最多40000字符,并发线程数增加到30。并发抓取使用ThreadPoolExecutor,并设置了超时和重试。相关代码片段如下。

def _fetch_pages_concurrently(self, urls, max_chars, max_workers):
    results = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_url = {executor.submit(fetch_page_content, url, max_chars): url for url in urls}
        for future in concurrent.futures.as_completed(future_to_url):
            url = future_to_url[future]
            try:
                results[url] = future.result()
            except Exception as e:
                results[url] = {'url': url, 'content': '', 'success': False, 'error': str(e)}
    return results

每个网页的抓取函数fetch_page_content内部会调用extract_readable_text并截断到指定长度,同时支持最多2次重试,超时时间12秒。搜索结果和抓取到的全文最终被格式化为一个结构清晰的文本块,并附加引用提示。

3.2.4 搜索缓存与去重

相同关键词和相同强度的搜索在10分钟内会被缓存。缓存的键通过hashlib.md5query + intensity计算得到,值是一个二元组:(时间戳, 结果字典)。结果字典中包含text(格式化的长文本)、results(原始搜索结果列表)、queryintensitytimestamp。每次搜索前先检查缓存是否命中且未过期,命中则直接返回,否则进行新的搜索并将结果存入缓存,同时持久化到磁盘。

3.3 对话管理:历史存储与消息构建

conversation_store是一个字典,键为字符串类型的conversation_id,值为一个列表,列表中的每个元素是一个消息对象,包含rolecontenttimestamprole可以是userassistant。每次用户发消息时,后端先调用save_message(conv_id, "user", message)将用户消息存入存储;在模型完整回复后,再调用save_message(conv_id, "assistant", full_response)保存助手消息。构建发送给模型的消息列表由build_messages函数完成,其核心代码如下。

NO_CITATION_PROMPT = "\n\n【回答要求】请基于以上搜索结果回答用户问题,但正文中禁止出现任何引用标记(如[1]、[2]等)或'根据搜索结果'等提示性文字,直接给出完整、流畅、自然的回答。"

def build_messages(conv_id, user_msg, system_prompt=None, search_context=None):
    messages = []
    final_system = system_prompt or ""
    if search_context:
        if final_system:
            final_system = final_system + "\n\n" + search_context + NO_CITATION_PROMPT
        else:
            final_system = search_context + NO_CITATION_PROMPT
    if final_system:
        messages.append({"role": "system", "content": final_system})
    if conv_id and conv_id in conversation_store:
        messages.extend(conversation_store[conv_id])
    messages.append({"role": "user", "content": user_msg})
    return messages

注意最后的NO_CITATION_PROMPT,它强制模型在回答正文中不出现任何引用标记,因为前端右侧面板已经展示了搜索来源,内嵌引用反而冗余。

3.4 流式输出与错误处理

流式输出的核心是Server‑Sent Events。在Flask路由/api/chat中,当stream参数为True时,我们定义一个生成器函数generate(),然后通过Response(stream_with_context(generate()), mimetype="text/event-stream")返回。生成器内部首先发送搜索结果事件,然后调用模型API并逐块转发。以下是该生成器函数的关键部分。

def generate():
    full = ""
    try:
        if enable_search and search_results_data:
            yield f"data: {json.dumps({'type': 'search_results', 'query': message, 'intensity': search_intensity, 'results': search_results_data['results']})}\n\n"
        response = client.chat.completions.create(**api_params)
        for chunk in response:
            if chunk.choices and chunk.choices[0].delta.content:
                content = chunk.choices[0].delta.content
                full += content
                yield f"data: {json.dumps({'type': 'content', 'content': content, 'done': False})}\n\n"
        save_message(conv_id, "assistant", full)
        yield f"data: {json.dumps({'type': 'done', 'conversation_id': conv_id})}\n\n"
    except Exception as e:
        yield f"data: {json.dumps({'type': 'error', 'error': _friendly_error(str(e), model)})}\n\n"

其中_friendly_error将技术错误转换为用户可读的提示(如API密钥无效、请求频率过高等)。非流式模式作为备选,但前端只使用了流式,以提供更佳体验。

4. 前端开发详解:打造现代化对话界面

4.1 页面布局与响应式设计

前端文件templates/index.html是一个完整的HTML文档,包含样式、脚本和结构。整体布局采用Flex,顶部工具栏使用flex-wrap: wrap适配小屏幕。工具栏左侧是API密钥输入框、验证按钮和获取密钥的链接;中间是模型选择下拉;右侧是联网搜索复选框和强度选择下拉,以及参数设置按钮。点击参数设置按钮会展开一个隐藏面板,里面可以调整系统提示词、温度、Top P和Max Tokens。中间区域分为三列:左侧侧边栏(对话列表)、中间聊天消息区、右侧搜索结果面板(默认隐藏,获得搜索结果后显示)。为了在手机或窄屏上也能使用,媒体查询@media (max-width: 768px)会将侧边栏和右侧面板都隐藏掉,只保留聊天区域。聊天消息区域采用自动滚动,最新消息始终可见。底部输入区包含一个多行文本框和一个发送按钮,支持Enter发送、Shift+Enter换行。

4.2 Markdown实时渲染与复制功能

为了支持富文本展示,我们引入了marked.min.js库(通过CDN)。在流式输出过程中,前端每收到一个content事件,就将该片段拼接到全局变量fullContent中,然后找到当前助手消息的气泡中的.md-content元素,调用marked.parse(fullContent),将解析后的HTML设置进去。同时,每条助手消息的气泡右上角有一个“复制”按钮,点击后会将气泡的data-raw-content属性(存放未渲染的原始文本)复制到剪贴板。这部分前端逻辑的核心代码如下。

// 在 SSE 处理中
if (parsed.type === 'content') {
    fullContent += parsed.content;
    tempBubble.dataset.rawContent = fullContent;
    const mdDiv = tempBubble.querySelector('.md-content');
    if (mdDiv) {
        mdDiv.innerHTML = marked.parse(fullContent);
    }
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

4.3 搜索结果展示侧边栏

前端监听search_results事件,当收到数据后,提取其中的results数组,并动态生成右侧面板的HTML。每个搜索结果项包含一个可点击的标题(链接指向原文URL)、URL显示以及摘要。面板默认隐藏,有搜索结果时显示,切换对话时清除。

4.4 多会话切换与持久化

在前端,每个对话有一个唯一的conversation_id(使用crypto.randomUUID()或降级为Date.now())。新建会话时,前端将currentConversationId置为null,清空当前消息列表和右侧面板。侧边栏的对话列表通过/api/conversations接口获取,点击某个对话项时,前端会先更新currentConversationId,然后调用/api/conversation/{id}获取该对话的完整消息历史,重新渲染聊天区域。对话历史由后端持久化,前端只负责展示和交互。

4.5 API密钥本地存储与验证

为了避免用户每次刷新页面都要重新输入API密钥,前端使用localStorage保存密钥。在初始化时调用loadApiKey()将存储的值填入输入框,当输入框内容变化时通过saveApiKey()更新存储。同时,提供了一个“验证密钥”按钮,点击后会向后端/api/validate-key发送请求,后端使用该密钥尝试调用模型API(max_tokens=5,极短内容)来验证有效性。如果返回valid: true,前端显示“密钥有效”;否则显示相应的错误信息。

5. 性能优化与安全建议

5.1 搜索缓存的命中率优化

搜索缓存不仅放在内存中,还通过JSON文件持久化到磁盘。在实际使用中,用户常常会反复询问相似的问题,缓存基于精确的关键词和强度,对于同义不同词的情况不会命中。未来可以引入简单的同义词扩展或向量检索来提升命中率,但对于当前项目来说,10分钟的生命周期已经能覆盖绝大部分重复查询场景。

5.2 并发抓取时的限流与反爬

虽然我们使用线程池并发提高了抓取效率,但也需要注意对目标网站的压力。标准模式15个并发线程同时请求15~20个不同的网站,每个网站只请求一次,通常不会触发反爬虫机制。但如果你频繁使用深度模式(50个并发),某些网站可能会暂时封锁你的IP。代码中已经实现了重试机制(每个网页最多重试2次),并且每次请求都带有常见的浏览器User-Agent头。如果仍然被封锁,建议降低并发数(修改MAX_WORKERS_DEEP)或在请求间隔中增加随机休眠(但会显著增加总耗时)。

5.3 API密钥的安全存储与传输

在前端,API密钥保存在localStorage中,虽然方便,但也存在XSS攻击泄露的风险。在生产环境中,更安全的做法是让后端代理所有API调用,用户密钥只存在后端的环境变量或会话中,前端完全不接触密钥。但由于本项目是面向个人或小团队使用的,且API聚合站的密钥通常不需要极高安全性,当前方案已经足够。此外,所有与后端的通信都是HTTP明文,建议在公网部署时启用HTTPS,防止中间人窃听。

5.4 生产环境部署注意事项

Flask自带的开发服务器(app.run(debug=True))性能较差且不安全,只适合本地测试。若要部署到公网或局域网给多人使用,应使用生产级WSGI服务器,例如waitress(跨平台)或gunicorn(Linux)。同时,需要关闭debug模式,并将host绑定到0.0.0.0以允许外部访问。如果希望使用域名访问,可以在前面加一层Nginx反向代理,并配置SSL证书。持久化的JSON文件存放在系统临时目录,该目录在Linux下通常是/tmp,重启操作系统会丢失;如果需要长期保存,可以将TEMP_DIR改为项目目录下的data文件夹。

6. 运行与使用指南

6.1 环境准备与依赖安装

运行本项目需要Python 3.8以上版本,并安装以下依赖:flaskopenairequestsbaidusearch。你可以通过以下命令一次性安装:

pip install flask openai requests baidusearch

如果需要升级baidusearch到最新版本,可以使用pip install --upgrade baidusearch。前端依赖的marked.js通过CDN引入,无需额外安装。

6.2 启动服务与首次访问

在命令行中进入app.py所在的目录,执行python app.py。控制台会打印出服务器的访问地址,通常是http://127.0.0.1:5001。打开浏览器访问该地址,你会看到对话界面。首次使用时需要填写API密钥,你可以前往API聚合站注册并获取免费密钥。建议先点击“验证密钥”确保密钥有效。

注意:令牌创建时选择free分组就能使用gemini-2.5-flash-lite模型。

6.3 不同搜索强度的测试场景

为了体验三种搜索强度的差异,你可以尝试以下测试用例:快速搜索(强度1)适用于简单的事实查询,例如“今天是星期几”,模型会基于百度摘要直接回答,几乎感觉不到延迟。标准搜索(强度2)适合大多数信息需求,比如“2025年9月1日新国标电动自行车”,模型会先搜索20个网页并抓取全文,然后生成综合回答,右侧面板会显示20个来源链接。深度搜索(强度3)则可以让你探索复杂话题,例如“新能源汽车行业2025年发展趋势”,模型会抓取50个网页的全文(每页最多40000字符),回答会非常详实,但首次搜索可能需要等待20~30秒,缓存后再次询问秒回。

7. 常见问题与调试技巧

在实际使用中,你可能会遇到联网搜索无结果的情况。首先检查baidusearch库是否安装正确,可以在Python交互环境中执行from baidusearch import search; print(search("测试", num_results=1)),如果返回空列表或报错,说明网络无法访问百度或者库与当前百度页面结构不匹配,可以尝试升级库或更换网络环境。其次,百度搜索偶尔会返回验证码页面,此时搜索会失败,等待几分钟后再试通常可恢复。如果全文抓取频繁超时,可以适当增加REQUEST_TIMEOUT的值(默认12秒)或减少并发数。

关于模型返回内容被截断,通常是max_tokens参数设置过小。免费模型默认支持4096,付费模型可达65535。如果联网搜索结果本身非常长,模型生成时可能达到token限制,此时可以降低搜索强度(例如从深度改为标准)或手动增大max_tokens

如果流式输出时界面卡顿,尤其是深度搜索后模型回复很长(>2000字),前端每次接收到新chunk都重新解析完整Markdown,可能会造成短暂延迟。可以改为增量渲染,但考虑到实现复杂度,目前方案在大多数机器上仍然流畅。

对话历史丢失的排查思路:检查/tmp/ai_chat_assistant/conversations.json文件是否存在且内容完整。如果该文件存在但启动后没有加载,确认代码中load_conversations被正确调用(在if __name__ == "__main__"之前调用了一次)。另外,Windows系统的临时目录可能不同,但tempfile.gettempdir()会自动适配。

8. 扩展方向:让智能体更强大

8.1 增加更多外部工具

目前智能体只具备联网搜索这一种工具,但你可以方便地扩展其他工具。例如,识别用户消息中的“计算”关键词,调用Python的eval(需要沙箱安全)并返回计算结果;或者接入免费天气API,根据用户提供的位置返回天气信息。在架构上,可以在后端增加一个工具路由,由模型根据提示词决定是否调用,或者在前端通过正则匹配触发。更高级的做法是让模型自己选择工具(ReAct模式),这需要更复杂的提示词设计和循环调用。

8.2 支持多模态输入

虽然当前模型gemini-2.5-flash-lite不支持视觉,但gpt-5.5-xhigh是支持多模态的。你可以在前端增加图片上传组件,将图片转为Base64或URL,然后按照OpenAI Vision API的格式嵌入到消息中。后端需要调整build_messages函数,支持content为数组结构,包含文本和图片URL。这一扩展能让你的智能体“看懂”图像,应用场景大大拓宽。

8.3 用户认证与用量管理

对于多用户环境,可以引入Flask-Login实现登录注册,并将对话历史与用户ID关联。同时记录每个用户的API调用次数,设置每日限额,防止滥用。用量统计可以存储在SQLite数据库中,方便查询和重置。另外,还可以开发一个管理员面板,查看全局的搜索缓存命中率、平均响应时间等指标。

9. 总结

本文全面讲解了如何从零开始构建一个功能完整的AI智能体应用,核心成果包括:一个基于Flask的后端服务,实现了多轮对话记忆、三种强度的百度实时搜索(含全文并发抓取)、流式SSE输出、对话持久化;一个原生前端界面,支持多会话切换、Markdown实时渲染、搜索结果侧边栏展示。整个项目代码清晰,没有复杂的框架依赖,非常适合作为学习智能体开发的起点。通过本文,你应该能够理解大模型API集成、网页爬虫与清洗、流式交互设计、前端事件驱动等关键技术,并且有能力在此基础上添加新的工具和功能。希望你能动手运行项目,并把它应用到自己感兴趣的领域中。

附录(完整代码实现)

app.py:

"""
智能体一:通用对话助手(基础版)- DeepSeek风格
支持联网搜索(基于稳定的 baidusearch 库)、流式输出、全参数配置
"""

import json
import uuid
import re
import time
import hashlib
import concurrent.futures
import requests
import os
import tempfile
import atexit
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response, stream_with_context
from openai import OpenAI

# 尝试导入 baidusearch(优先使用新导入方式,兼容旧版本)
try:
    from baidusearch import search as baidu_search_legacy
except ImportError:
    try:
        from baidusearch.baidusearch import search as baidu_search_legacy
    except ImportError:
        baidu_search_legacy = None
        print("Warning: baidusearch not found. Please run: pip install --upgrade baidusearch")

app = Flask(__name__)

# ============================================================
# 全局配置
# ============================================================
API_BASE_URL = "https://api.aigc.bar/v1"
DEFAULT_MODEL = "gemini-2.5-flash-lite"
AVAILABLE_MODELS = {
    "gemini-2.5-flash-lite": {
        "name": "Gemini 2.5 Flash Lite",
        "group": "free",
        "label": "免费",
        "description": "免费模型,适合日常对话",
        "max_tokens_limit": 65535,
        "supports_vision": False
    },
    "gpt-5.5-xhigh": {
        "name": "GPT-5.5 XHigh",
        "group": "paid",
        "label": "最强付费",
        "description": "付费模型,高质量生成",
        "max_tokens_limit": 65535,
        "supports_vision": True
    }
}

conversation_store = {}
search_cache = {}
SEARCH_CACHE_TTL = 600

# 临时文件持久化配置
TEMP_DIR = os.path.join(tempfile.gettempdir(), "ai_chat_assistant")
os.makedirs(TEMP_DIR, exist_ok=True)
CONVERSATIONS_FILE = os.path.join(TEMP_DIR, "conversations.json")
SEARCH_CACHE_FILE = os.path.join(TEMP_DIR, "search_cache.json")


def save_conversations():
    try:
        with open(CONVERSATIONS_FILE, 'w', encoding='utf-8') as f:
            json.dump(conversation_store, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(f"[保存对话失败] {e}")


def load_conversations():
    global conversation_store
    try:
        if os.path.exists(CONVERSATIONS_FILE):
            with open(CONVERSATIONS_FILE, 'r', encoding='utf-8') as f:
                conversation_store = json.load(f)
            print(f"[加载对话] 从 {CONVERSATIONS_FILE} 加载了 {len(conversation_store)} 个对话")
    except Exception as e:
        print(f"[加载对话失败] {e}")
        conversation_store = {}


def save_search_cache():
    try:
        with open(SEARCH_CACHE_FILE, 'w', encoding='utf-8') as f:
            now = time.time()
            valid_cache = {k: v for k, v in search_cache.items() if now - v[0] < SEARCH_CACHE_TTL}
            json.dump(valid_cache, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(f"[保存搜索缓存失败] {e}")


def load_search_cache():
    global search_cache
    try:
        if os.path.exists(SEARCH_CACHE_FILE):
            with open(SEARCH_CACHE_FILE, 'r', encoding='utf-8') as f:
                loaded = json.load(f)
            now = time.time()
            search_cache = {}
            for k, v in loaded.items():
                if isinstance(v, list) and len(v) == 2:
                    ts, data = v
                    if now - ts < SEARCH_CACHE_TTL:
                        if isinstance(data, dict):
                            search_cache[k] = (ts, data)
                        elif isinstance(data, str):
                            # 兼容旧格式缓存
                            search_cache[k] = (ts, {"text": data, "results": [], "query": "", "intensity": 2, "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S')})
            print(f"[加载搜索缓存] 从 {SEARCH_CACHE_FILE} 加载了 {len(search_cache)} 条缓存")
    except Exception as e:
        print(f"[加载搜索缓存失败] {e}")
        search_cache = {}

# 请求头
REQUEST_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
REQUEST_TIMEOUT = 12
MAX_RETRIES = 2

# 并发抓取配置
MAX_WORKERS_STANDARD = 15
MAX_WORKERS_DEEP = 30


def extract_readable_text(html):
    """从HTML中提取可读正文,过滤广告和导航元素"""
    text = re.sub(r'<(script|style|nav|header|footer|aside|form|iframe|noscript)[^>]*>.*?</\1>',
                  '', html, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
    text = re.sub(r'</(div|p|br|li|h[1-6]|tr|td|th|section|article)>', '\n', text, flags=re.IGNORECASE)
    text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
    text = re.sub(r'<[^>]+>', ' ', text)
    text = re.sub(r'[ \t]+', ' ', text)
    text = re.sub(r'\n\s*\n', '\n', text)
    lines = [line.strip() for line in text.split('\n') if line.strip()]
    filtered = []
    for line in lines:
        chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', line))
        if chinese_chars >= 3 or len(line) >= 30:
            filtered.append(line)
    return '\n'.join(filtered)


def fetch_page_content(url, max_chars=2000):
    """获取单个网页内容并提取可读正文,支持重试"""
    for attempt in range(MAX_RETRIES):
        try:
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
            response.raise_for_status()
            if response.encoding == 'ISO-8859-1':
                response.encoding = response.apparent_encoding
            text = extract_readable_text(response.text)
            if len(text) > max_chars:
                text = text[:max_chars] + "..."
            return {'url': url, 'content': text, 'success': True}
        except Exception as e:
            if attempt == MAX_RETRIES - 1:
                return {'url': url, 'content': '', 'success': False, 'error': str(e)}
            time.sleep(1)


class WebSearcher:
    def __init__(self):
        self.search_cache = {}

    def _get_cache_key(self, query, intensity):
        return hashlib.md5(f"{query}_{intensity}".encode()).hexdigest()

    def _search_baidu(self, query, num_results):
        """使用 baidusearch 库进行百度搜索"""
        if baidu_search_legacy is None:
            print("[BaiduSearch] 库未安装,请运行: pip install --upgrade baidusearch")
            return []

        try:
            # 调用 baidusearch 的搜索函数,返回列表,每个元素包含 title, url, abstract
            results_data = baidu_search_legacy(query, num_results=num_results)
            if not results_data:
                print("[BaiduSearch] 未获取到结果")
                return []

            # 统一字段名:baidusearch 返回的是 'abstract',我们内部用 'snippet'
            results = []
            for item in results_data:
                results.append({
                    'title': item.get('title', '无标题'),
                    'url': item.get('url', ''),
                    'snippet': item.get('abstract', item.get('snippet', ''))[:300]
                })
            return results
        except Exception as e:
            print(f"[BaiduSearch] 搜索失败: {e}")
            return []

    def _fetch_pages_concurrently(self, urls, max_chars, max_workers):
        """并发批量获取网页内容"""
        results = {}
        with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
            future_to_url = {executor.submit(fetch_page_content, url, max_chars): url for url in urls}
            for future in concurrent.futures.as_completed(future_to_url):
                url = future_to_url[future]
                try:
                    results[url] = future.result()
                except Exception as e:
                    results[url] = {'url': url, 'content': '', 'success': False, 'error': str(e)}
        return results

    def search(self, query, intensity=2):
        """
        执行联网搜索,返回结构化数据 {"text": ..., "results": [...], ...}
        intensity: 1=快速(10条摘要,不抓取全文)
                   2=标准(20条结果,全部抓取全文)
                   3=深度(50条结果,全部抓取全文)
        """
        config = {
            1: {'num_results': 10, 'fetch_full': False, 'max_chars': 0, 'max_workers': 5},
            2: {'num_results': 20, 'fetch_full': True,  'max_chars': 2000, 'max_workers': MAX_WORKERS_STANDARD},
            3: {'num_results': 50, 'fetch_full': True,  'max_chars': 40000, 'max_workers': MAX_WORKERS_DEEP}
        }
        cfg = config.get(intensity, config[2])

        cache_key = self._get_cache_key(query, intensity)
        if cache_key in search_cache:
            cached_time, cached_result = search_cache[cache_key]
            if time.time() - cached_time < SEARCH_CACHE_TTL:
                print(f"[缓存命中] 关键词: {query}")
                return cached_result

        print(f"[搜索开始] {query} | 强度={intensity} | 目标结果数={cfg['num_results']}")
        search_results = self._search_baidu(query, cfg['num_results'])
        if not search_results:
            return None

        page_contents = {}
        if cfg['fetch_full'] and search_results:
            urls_to_fetch = [r['url'] for r in search_results if r.get('url')]
            print(f"[抓取全文] 共 {len(urls_to_fetch)} 个网页,并发数={cfg['max_workers']}")
            page_contents = self._fetch_pages_concurrently(urls_to_fetch, cfg['max_chars'], cfg['max_workers'])

        formatted = self._format_results(query, search_results, page_contents, intensity)

        result_data = {
            "text": formatted,
            "results": search_results,
            "query": query,
            "intensity": intensity,
            "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        }
        search_cache[cache_key] = (time.time(), result_data)
        save_search_cache()
        return result_data

    def _format_results(self, query, search_results, page_contents, intensity):
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        mode_desc = {
            1: f"快速搜索({len(search_results)}条摘要)",
            2: f"标准搜索({len(search_results)}条结果 + 全文)",
            3: f"深度搜索({len(search_results)}条结果 + 全文)"
        }.get(intensity, "标准搜索")
        lines = [f"【联网搜索结果】查询:{query}", f"模式:{mode_desc}", f"时间:{timestamp}", ""]
        for idx, r in enumerate(search_results, 1):
            lines.append(f"[{idx}] {r['title']}")
            lines.append(f"    来源:{r['url']}")
            lines.append(f"    摘要:{r['snippet']}")
            if r['url'] in page_contents and page_contents[r['url']]['success']:
                content = page_contents[r['url']]['content']
                if content:
                    preview = content[:500] + "..." if len(content) > 500 else content
                    lines.append(f"    全文:{preview}")
            lines.append("")
        return '\n'.join(lines)


_searcher = WebSearcher()

def search_and_format(query, intensity=2):
    result = _searcher.search(query, intensity)
    return result["text"] if result else None


# ============================================================
# Flask 路由(与原代码一致)
# ============================================================
def get_openai_client(api_key):
    return OpenAI(api_key=api_key, base_url=API_BASE_URL)

def validate_api_key_format(api_key):
    return api_key and isinstance(api_key, str) and len(api_key.strip()) >= 10

NO_CITATION_PROMPT = "\n\n【回答要求】请基于以上搜索结果回答用户问题,但正文中禁止出现任何引用标记(如[1]、[2]等)或'根据搜索结果'等提示性文字,直接给出完整、流畅、自然的回答。"

def build_messages(conv_id, user_msg, system_prompt=None, search_context=None):
    messages = []
    final_system = system_prompt or ""
    if search_context:
        if final_system:
            final_system = final_system + "\n\n" + search_context + NO_CITATION_PROMPT
        else:
            final_system = search_context + NO_CITATION_PROMPT
    if final_system:
        messages.append({"role": "system", "content": final_system})
    if conv_id and conv_id in conversation_store:
        messages.extend(conversation_store[conv_id])
    messages.append({"role": "user", "content": user_msg})
    return messages

def save_message(conv_id, role, content):
    if conv_id not in conversation_store:
        conversation_store[conv_id] = []
    conversation_store[conv_id].append({"role": role, "content": content, "timestamp": datetime.now().isoformat()})
    save_conversations()

def _friendly_error(error_msg, model=""):
    if "401" in error_msg:
        return "API密钥无效,请检查。获取: https://api.aigc.bar/register?aff=UP4F"
    if "429" in error_msg:
        return "请求频率过高,请稍后再试。"
    if "timeout" in error_msg.lower():
        return "请求超时,请检查网络。"
    return f"错误: {error_msg}"


@app.route("/")
def index():
    return render_template("index.html")

@app.route("/api/chat", methods=["POST"])
def chat():
    data = request.get_json()
    if not data:
        return jsonify({"error": "请求体为空"}), 400

    api_key = data.get("api_key", "").strip()
    model = data.get("model", DEFAULT_MODEL)
    message = data.get("message", "").strip()
    conv_id = data.get("conversation_id", str(uuid.uuid4()))
    stream = data.get("stream", True)
    system_prompt = data.get("system_prompt", None)
    temperature = float(data.get("temperature", 1.0))
    max_tokens = int(data.get("max_tokens", 4096))
    top_p = float(data.get("top_p", 1.0))
    frequency_penalty = float(data.get("frequency_penalty", 0))
    presence_penalty = float(data.get("presence_penalty", 0))
    enable_search = data.get("enable_search", False)
    search_intensity = int(data.get("search_intensity", 2))

    if not validate_api_key_format(api_key):
        return jsonify({"error": "API密钥无效"}), 401
    if not message:
        return jsonify({"error": "消息不能为空"}), 400
    if model not in AVAILABLE_MODELS:
        return jsonify({"error": f"不支持的模型: {model}"}), 400

    search_context = None
    search_results_data = None
    if enable_search:
        print(f"[联网搜索请求] 关键词: {message}, 强度: {search_intensity}")
        search_result = _searcher.search(message, search_intensity)
        if search_result is None:
            search_context = "【联网搜索】未能获取到搜索结果,请稍后重试或降低搜索强度。"
        else:
            search_context = search_result["text"]
            search_results_data = search_result

    save_message(conv_id, "user", message)
    messages = build_messages(conv_id, message, system_prompt, search_context)

    try:
        client = get_openai_client(api_key)
        api_params = {
            "model": model,
            "messages": messages,
            "temperature": max(0, min(2, temperature)),
            "max_tokens": max(1, min(65535, max_tokens)),
            "top_p": max(0, min(1, top_p)),
            "frequency_penalty": max(-2, min(2, frequency_penalty)),
            "presence_penalty": max(-2, min(2, presence_penalty)),
            "stream": stream
        }

        if stream:
            def generate():
                full = ""
                try:
                    if enable_search and search_results_data:
                        yield f"data: {json.dumps({'type': 'search_results', 'query': message, 'intensity': search_intensity, 'results': search_results_data['results']})}\n\n"
                    response = client.chat.completions.create(**api_params)
                    for chunk in response:
                        if chunk.choices and chunk.choices[0].delta.content:
                            content = chunk.choices[0].delta.content
                            full += content
                            yield f"data: {json.dumps({'type': 'content', 'content': content, 'done': False})}\n\n"
                    save_message(conv_id, "assistant", full)
                    yield f"data: {json.dumps({'type': 'done', 'conversation_id': conv_id})}\n\n"
                except Exception as e:
                    yield f"data: {json.dumps({'type': 'error', 'error': _friendly_error(str(e), model)})}\n\n"
            return Response(stream_with_context(generate()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache"})
        else:
            api_params["stream"] = False
            response = client.chat.completions.create(**api_params)
            assistant_msg = response.choices[0].message.content
            save_message(conv_id, "assistant", assistant_msg)
            return jsonify({"type": "complete", "content": assistant_msg, "conversation_id": conv_id, "search_results": search_results_data['results'] if search_results_data else None})
    except Exception as e:
        return jsonify({"error": _friendly_error(str(e), model)}), 500


# 辅助接口
@app.route("/api/models", methods=["GET"])
def get_models():
    return jsonify({"models": AVAILABLE_MODELS, "default": DEFAULT_MODEL})

@app.route("/api/conversation/<conv_id>", methods=["GET"])
def get_conversation(conv_id):
    if conv_id in conversation_store:
        return jsonify({"conversation_id": conv_id, "messages": conversation_store[conv_id]})
    return jsonify({"error": "对话不存在"}), 404

@app.route("/api/conversation/<conv_id>", methods=["DELETE"])
def clear_conversation(conv_id):
    if conv_id in conversation_store:
        del conversation_store[conv_id]
    return jsonify({"message": "已清空"})

@app.route("/api/conversations", methods=["GET"])
def list_conversations():
    convs = []
    for cid, msgs in conversation_store.items():
        title = "新对话"
        for msg in msgs:
            if msg["role"] == "user":
                title = msg["content"][:50]
                break
        convs.append({"id": cid, "title": title, "message_count": len(msgs), "last_updated": msgs[-1]["timestamp"] if msgs else None})
    return jsonify({"conversations": convs})

@app.route("/api/validate-key", methods=["POST"])
def validate_key():
    data = request.get_json()
    api_key = data.get("api_key", "").strip()
    if not validate_api_key_format(api_key):
        return jsonify({"valid": False, "message": "密钥格式无效"})
    try:
        client = get_openai_client(api_key)
        client.chat.completions.create(model=DEFAULT_MODEL, messages=[{"role": "user", "content": "Hi"}], max_tokens=5)
        return jsonify({"valid": True, "message": "密钥有效"})
    except Exception as e:
        if "401" in str(e):
            return jsonify({"valid": False, "message": "密钥无效"})
        return jsonify({"valid": True, "message": "密钥有效但验证出错(可能限流)"})

@app.route("/api/search", methods=["POST"])
def search_only():
    data = request.get_json()
    query = data.get("query", "").strip()
    intensity = int(data.get("intensity", 2))
    if not query:
        return jsonify({"error": "搜索内容为空"}), 400
    result = search_and_format(query, intensity)
    if result is None:
        return jsonify({"error": "搜索失败"}), 500
    return jsonify({"query": query, "intensity": intensity, "result": result})


# 启动时加载持久化数据
load_conversations()
load_search_cache()
atexit.register(save_conversations)
atexit.register(save_search_cache)

if __name__ == "__main__":
    print("=" * 60)
    print("  智能体助手 - 稳定版联网搜索 (百度搜索)")
    print("  访问地址:http://localhost:5001")
    print("  使用前请安装依赖:pip install --upgrade baidusearch flask openai requests")
    print("=" * 60)
    app.run(host="0.0.0.0", port=5001, debug=True)

网页文件放在\templates\index.html,内容为

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>智能体助手 - 支持联网搜索</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: #f5f5f5;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }

        /* 顶部工具栏 */
        .toolbar {
            background: white;
            border-bottom: 1px solid #e0e0e0;
            padding: 12px 20px;
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            align-items: center;
            box-shadow: 0 1px 3px rgba(0,0,0,0.05);
        }

        .api-key-section {
            display: flex;
            gap: 8px;
            align-items: center;
            flex: 2;
            min-width: 250px;
        }

        .api-key-section input {
            flex: 1;
            padding: 8px 12px;
            border: 1px solid #ccc;
            border-radius: 6px;
            font-size: 14px;
        }

        .api-key-section button {
            padding: 8px 16px;
            background: #10a37f;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
        }

        .api-key-section button:hover {
            background: #0e8c6d;
        }

        .model-select {
            padding: 8px 12px;
            border-radius: 6px;
            border: 1px solid #ccc;
            font-size: 14px;
        }

        .search-control {
            display: flex;
            align-items: center;
            gap: 10px;
            background: #f0f0f0;
            padding: 5px 12px;
            border-radius: 20px;
        }

        .search-control label {
            font-size: 14px;
            cursor: pointer;
        }

        .search-intensity {
            padding: 4px 8px;
            border-radius: 16px;
            border: 1px solid #ccc;
            background: white;
            font-size: 13px;
        }

        .param-toggle {
            cursor: pointer;
            color: #666;
            font-size: 14px;
            padding: 6px 12px;
            background: #f0f0f0;
            border-radius: 20px;
        }

        .param-panel {
            background: #f9f9f9;
            padding: 12px 20px;
            border-bottom: 1px solid #e0e0e0;
            display: none;
            flex-wrap: wrap;
            gap: 15px;
            font-size: 14px;
        }

        .param-panel.show {
            display: flex;
        }

        .param-item {
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .param-item label {
            font-weight: 500;
        }

        .param-item input {
            width: 80px;
            padding: 4px 8px;
            border: 1px solid #ccc;
            border-radius: 4px;
        }

        /* 聊天区域 */
        .chat-container {
            flex: 1;
            display: flex;
            overflow: hidden;
        }

        /* 侧边栏(对话列表) */
        .sidebar {
            width: 260px;
            background: white;
            border-right: 1px solid #e0e0e0;
            display: flex;
            flex-direction: column;
            overflow-y: auto;
        }

        .sidebar-header {
            padding: 16px;
            border-bottom: 1px solid #eee;
            font-weight: bold;
        }

        .new-chat-btn {
            margin: 12px;
            padding: 10px;
            background: #10a37f;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 14px;
        }

        .conversation-list {
            flex: 1;
            overflow-y: auto;
        }

        .conv-item {
            padding: 12px 16px;
            border-bottom: 1px solid #eee;
            cursor: pointer;
            font-size: 14px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }

        .conv-item.active {
            background: #e6f7f2;
            border-left: 3px solid #10a37f;
        }

        .conv-item:hover {
            background: #f5f5f5;
        }

        /* 主聊天区 */
        .main {
            flex: 1;
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .messages {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }

        .message {
            display: flex;
            gap: 12px;
            max-width: 80%;
            animation: fadeIn 0.2s ease;
        }

        .message.user {
            align-self: flex-end;
            flex-direction: row-reverse;
        }

        .message.assistant {
            align-self: flex-start;
        }

        .avatar {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            background: #10a37f;
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            flex-shrink: 0;
        }

        .message.user .avatar {
            background: #8e8ea0;
        }

        .bubble {
            background: white;
            padding: 12px 16px;
            border-radius: 18px;
            box-shadow: 0 1px 2px rgba(0,0,0,0.1);
            line-height: 1.5;
            word-wrap: break-word;
            white-space: pre-wrap;
        }

        .message.user .bubble {
            background: #10a37f;
            color: white;
        }

        .input-area {
            background: white;
            border-top: 1px solid #e0e0e0;
            padding: 16px 20px;
            display: flex;
            gap: 12px;
            align-items: flex-end;
        }

        .input-area textarea {
            flex: 1;
            padding: 12px;
            border: 1px solid #ccc;
            border-radius: 24px;
            resize: none;
            font-family: inherit;
            font-size: 14px;
            max-height: 150px;
        }

        .input-area button {
            padding: 10px 24px;
            background: #10a37f;
            color: white;
            border: none;
            border-radius: 24px;
            cursor: pointer;
            font-size: 14px;
        }

        .input-area button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }

        .status {
            font-size: 12px;
            color: #666;
            padding: 5px 20px;
            background: #f9f9f9;
            border-top: 1px solid #eee;
        }

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

        .loading-dots::after {
            content: '...';
            animation: dots 1.5s steps(4, end) infinite;
            display: inline-block;
            width: 24px;
        }

        @keyframes dots {
            0%, 20% { content: ''; }
            40% { content: '.'; }
            60% { content: '..'; }
            80%, 100% { content: '...'; }
        }

        @media (max-width: 768px) {
            .sidebar { display: none; }
            .message { max-width: 95%; }
        }

        /* 获取密钥按钮 */
        .get-key-btn {
            padding: 8px 16px;
            background: #2563eb;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            text-decoration: none;
            display: inline-block;
        }
        .get-key-btn:hover {
            background: #1d4ed8;
        }

        /* Markdown 渲染样式 */
        .md-content pre {
            background: #f4f4f4;
            padding: 12px;
            border-radius: 6px;
            overflow-x: auto;
            margin: 8px 0;
        }
        .md-content code {
            background: #f0f0f0;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: 'Courier New', Courier, monospace;
            font-size: 13px;
        }
        .md-content pre code {
            padding: 0;
            background: none;
        }
        .md-content p { margin: 8px 0; }
        .md-content ul, .md-content ol { margin: 8px 0; padding-left: 20px; }
        .md-content blockquote {
            border-left: 4px solid #10a37f;
            margin: 8px 0;
            padding-left: 12px;
            color: #555;
        }
        .md-content table {
            border-collapse: collapse;
            margin: 8px 0;
        }
        .md-content th, .md-content td {
            border: 1px solid #ddd;
            padding: 6px 10px;
        }
        .md-content th { background: #f0f0f0; }
        .md-content {
            white-space: normal;
        }
        .md-content p {
            margin: 4px 0;
        }
        .md-content p:empty {
            display: none;
        }
        .md-content h1, .md-content h2, .md-content h3, .md-content h4 {
            margin: 8px 0 4px;
        }

        /* 复制按钮 */
        .copy-btn {
            position: absolute;
            top: 8px;
            right: 8px;
            padding: 4px 10px;
            background: rgba(0,0,0,0.05);
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 12px;
            cursor: pointer;
            opacity: 0;
            transition: opacity 0.2s;
        }
        .message.assistant .bubble {
            position: relative;
        }
        .message.assistant .bubble:hover .copy-btn {
            opacity: 1;
        }

        /* 联网搜索结果面板 */
        .search-panel {
            width: 320px;
            background: white;
            border-left: 1px solid #e0e0e0;
            display: none;
            flex-direction: column;
            overflow: hidden;
        }
        .search-panel.show {
            display: flex;
        }
        .search-panel-header {
            padding: 16px;
            border-bottom: 1px solid #eee;
            font-weight: bold;
            font-size: 14px;
        }
        .search-panel-content {
            flex: 1;
            overflow-y: auto;
            padding: 12px;
        }
        .search-result-item {
            padding: 10px 0;
            border-bottom: 1px solid #eee;
            font-size: 13px;
        }
        .search-result-item:last-child {
            border-bottom: none;
        }
        .search-result-item a {
            color: #1a0dab;
            text-decoration: none;
            font-weight: 500;
            font-size: 13px;
        }
        .search-result-item a:hover {
            text-decoration: underline;
        }
        .search-result-item .url {
            color: #006621;
            font-size: 11px;
            margin: 2px 0;
            word-break: break-all;
        }
        .search-result-item .snippet {
            color: #545454;
            font-size: 12px;
            line-height: 1.4;
            margin-top: 4px;
        }

        @media (max-width: 1024px) {
            .search-panel { display: none !important; }
        }
        @media (max-width: 768px) {
            .sidebar { display: none; }
            .message { max-width: 95%; }
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div class="toolbar">
    <div class="api-key-section">
        <input type="password" id="apiKey" placeholder="API密钥 (必填)" style="flex:2">
        <button id="validateKeyBtn">验证密钥</button>
        <a href="https://api.aigc.bar/register?aff=UP4F" target="_blank" class="get-key-btn">获取密钥</a>
    </div>
    <select id="modelSelect" class="model-select">
        <option value="gemini-2.5-flash-lite">Gemini 2.5 Flash Lite (免费)</option>
        <option value="gpt-5.5-xhigh">GPT-5.5 XHigh (付费)</option>
    </select>
    <div class="search-control">
        <input type="checkbox" id="enableSearch"> <label for="enableSearch">联网搜索</label>
        <select id="searchIntensity" class="search-intensity">
            <option value="1">快速 (10摘要)</option>
            <option value="2" selected>标准 (20全文)</option>
            <option value="3">深度 (50全文)</option>
        </select>
    </div>
    <div class="param-toggle" id="paramToggle">⚙️ 参数设置</div>
</div>
<div id="paramPanel" class="param-panel">
    <div class="param-item"><label>系统提示:</label><input type="text" id="systemPrompt" placeholder="可选"></div>
    <div class="param-item"><label>温度:</label><input type="number" id="temperature" value="1" step="0.1" min="0" max="2"></div>
    <div class="param-item"><label>Top P:</label><input type="number" id="topP" value="1" step="0.1" min="0" max="1"></div>
    <div class="param-item"><label>Max Tokens:</label><input type="number" id="maxTokens" value="4096" step="512" min="1" max="65535"></div>
</div>

<div class="chat-container">
    <div class="sidebar">
        <div class="sidebar-header">对话历史</div>
        <button class="new-chat-btn" id="newChatBtn">➕ 新对话</button>
        <div id="conversationList" class="conversation-list"></div>
    </div>
    <div class="main">
        <div id="messages" class="messages"></div>
        <div class="input-area">
            <textarea id="messageInput" rows="2" placeholder="输入消息... (Enter发送,Shift+Enter换行)"></textarea>
            <button id="sendBtn">发送</button>
        </div>
        <div id="status" class="status">就绪</div>
    </div>
    <div class="search-panel" id="searchPanel">
        <div class="search-panel-header">联网搜索结果</div>
        <div class="search-panel-content" id="searchPanelContent"></div>
    </div>
</div>

<script>
    // ---------- 全局变量 ----------
    let currentConversationId = null;
    let isWaiting = false;
    let conversations = {};      // 内存中存储对话标题等,实际历史由后端维护
    let currentMessages = [];    // 本地缓存当前对话消息(用于显示)
    let eventSource = null;

    // DOM 元素
    const apiKeyInput = document.getElementById('apiKey');
    const validateKeyBtn = document.getElementById('validateKeyBtn');
    const modelSelect = document.getElementById('modelSelect');
    const enableSearchCheck = document.getElementById('enableSearch');
    const searchIntensitySelect = document.getElementById('searchIntensity');
    const paramToggle = document.getElementById('paramToggle');
    const paramPanel = document.getElementById('paramPanel');
    const systemPromptInput = document.getElementById('systemPrompt');
    const temperatureInput = document.getElementById('temperature');
    const topPInput = document.getElementById('topP');
    const maxTokensInput = document.getElementById('maxTokens');
    const newChatBtn = document.getElementById('newChatBtn');
    const conversationListDiv = document.getElementById('conversationList');
    const messagesDiv = document.getElementById('messages');
    const messageInput = document.getElementById('messageInput');
    const sendBtn = document.getElementById('sendBtn');
    const statusDiv = document.getElementById('status');
    const searchPanel = document.getElementById('searchPanel');
    const searchPanelContent = document.getElementById('searchPanelContent');

    // ---------- 辅助函数 ----------
    function setStatus(text, isError = false) {
        statusDiv.textContent = text;
        if (isError) {
            setTimeout(() => { if (statusDiv.textContent === text) statusDiv.textContent = '就绪'; }, 3000);
        }
    }

    function getApiKey() {
        return apiKeyInput.value.trim();
    }

    // 保存/加载 API 密钥到 localStorage
    function saveApiKey() {
        const key = apiKeyInput.value.trim();
        if (key) {
            localStorage.setItem('savedApiKey', key);
        }
    }

    function loadApiKey() {
        const key = localStorage.getItem('savedApiKey');
        if (key) {
            apiKeyInput.value = key;
        }
    }

    // 保存对话ID到localStorage
    function saveConversationId(id) {
        if (id) localStorage.setItem('lastConvId', id);
    }

    function loadLastConversationId() {
        return localStorage.getItem('lastConvId');
    }

    // 加载对话列表(简单从后端获取)
    async function loadConversations() {
        try {
            const resp = await fetch('/api/conversations');
            const data = await resp.json();
            if (data.conversations) {
                renderConversationList(data.conversations);
            }
        } catch (e) {
            console.error('加载对话列表失败', e);
        }
    }

    function renderConversationList(convs) {
        conversationListDiv.innerHTML = '';
        for (const conv of convs) {
            const div = document.createElement('div');
            div.className = 'conv-item';
            if (conv.id === currentConversationId) div.classList.add('active');
            div.textContent = conv.title || '新对话';
            div.addEventListener('click', () => switchConversation(conv.id));
            conversationListDiv.appendChild(div);
        }
    }

    async function switchConversation(convId) {
        if (convId === currentConversationId) return;
        currentConversationId = convId;
        saveConversationId(convId);
        clearSearchResults();
        // 从后端加载历史消息
        await loadConversationHistory(convId);
        renderMessages();
        highlightActiveConversation();
    }

    async function loadConversationHistory(convId) {
        try {
            const resp = await fetch(`/api/conversation/${convId}`);
            if (resp.ok) {
                const data = await resp.json();
                currentMessages = data.messages || [];
            } else {
                currentMessages = [];
            }
        } catch (e) {
            currentMessages = [];
        }
    }

    function highlightActiveConversation() {
        document.querySelectorAll('.conv-item').forEach(el => {
            el.classList.remove('active');
            if (el.textContent.includes(currentConversationId) ||
                (el.getAttribute('data-id') === currentConversationId)) {
                el.classList.add('active');
            }
        });
        // 更准确的方式是重新渲染列表,简单起见重新加载列表后高亮
        loadConversations().then(() => {
            document.querySelectorAll('.conv-item').forEach(el => {
                if (el.textContent.includes(currentConversationId)) el.classList.add('active');
            });
        });
    }

    function renderSearchResults(results) {
        searchPanelContent.innerHTML = '';
        if (!results || results.length === 0) {
            searchPanel.classList.remove('show');
            return;
        }
        for (const r of results) {
            const div = document.createElement('div');
            div.className = 'search-result-item';
            div.innerHTML = `
                <a href="${r.url}" target="_blank" rel="noopener noreferrer">${r.title}</a>
                <div class="url">${r.url}</div>
                <div class="snippet">${r.snippet}</div>
            `;
            searchPanelContent.appendChild(div);
        }
        searchPanel.classList.add('show');
    }

    function clearSearchResults() {
        searchPanel.classList.remove('show');
        searchPanelContent.innerHTML = '';
    }

    async function newConversation() {
        currentConversationId = null;
        currentMessages = [];
        renderMessages();
        saveConversationId(null);
        clearSearchResults();
        await loadConversations();
    }

    function renderMessages() {
        messagesDiv.innerHTML = '';
        for (const msg of currentMessages) {
            addMessageToUI(msg.role, msg.content, false);
        }
        messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }

    function addMessageToUI(role, content, scroll = true) {
        const msgDiv = document.createElement('div');
        msgDiv.className = `message ${role}`;
        const avatar = document.createElement('div');
        avatar.className = 'avatar';
        avatar.textContent = role === 'user' ? 'U' : 'A';
        const bubble = document.createElement('div');
        bubble.className = 'bubble';
        if (role === 'assistant') {
            const contentDiv = document.createElement('div');
            contentDiv.className = 'md-content';
            bubble.appendChild(contentDiv);
            const copyBtn = document.createElement('button');
            copyBtn.className = 'copy-btn';
            copyBtn.textContent = '复制';
            copyBtn.onclick = () => {
                const textToCopy = bubble.dataset.rawContent || '';
                navigator.clipboard.writeText(textToCopy).then(() => {
                    copyBtn.textContent = '已复制';
                    setTimeout(() => copyBtn.textContent = '复制', 2000);
                });
            };
            bubble.appendChild(copyBtn);
            bubble.dataset.rawContent = content || '';
            contentDiv.innerHTML = marked.parse(content || '');
        } else {
            bubble.textContent = content;
        }
        msgDiv.appendChild(avatar);
        msgDiv.appendChild(bubble);
        messagesDiv.appendChild(msgDiv);
        if (scroll) messagesDiv.scrollTop = messagesDiv.scrollHeight;
        return bubble;
    }

    // 发送消息(流式)
    async function sendMessage() {
        if (isWaiting) return;
        const message = messageInput.value.trim();
        if (!message) return;
        const apiKey = getApiKey();
        if (!apiKey) {
            setStatus('请填写API密钥', true);
            return;
        }

        // 如果没有当前对话ID,创建一个新的
        if (!currentConversationId) {
            currentConversationId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
            saveConversationId(currentConversationId);
            await loadConversations();
        }

        // 添加用户消息到UI和历史
        addMessageToUI('user', message);
        currentMessages.push({ role: 'user', content: message, timestamp: new Date().toISOString() });

        // 清空输入框
        messageInput.value = '';
        isWaiting = true;
        sendBtn.disabled = true;
        setStatus('思考中...');

        // 添加一个临时助手消息用于流式填充
        const tempBubble = addMessageToUI('assistant', '');
        let fullContent = '';

        const payload = {
            api_key: apiKey,
            model: modelSelect.value,
            message: message,
            conversation_id: currentConversationId,
            stream: true,
            system_prompt: systemPromptInput.value || null,
            temperature: parseFloat(temperatureInput.value),
            max_tokens: parseInt(maxTokensInput.value),
            top_p: parseFloat(topPInput.value),
            enable_search: enableSearchCheck.checked,
            search_intensity: parseInt(searchIntensitySelect.value)
        };

        try {
            const response = await fetch('/api/chat', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
            });

            if (!response.ok) {
                const err = await response.json();
                throw new Error(err.error || '请求失败');
            }

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

            while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                buffer += decoder.decode(value, { stream: true });
                const lines = buffer.split('\n');
                buffer = lines.pop();
                for (const line of lines) {
                    if (line.startsWith('data: ')) {
                        const data = line.slice(6);
                        if (data === '[DONE]') continue;
                        try {
                            const parsed = JSON.parse(data);
                            if (parsed.type === 'content') {
                                fullContent += parsed.content;
                                tempBubble.dataset.rawContent = fullContent;
                                const mdDiv = tempBubble.querySelector('.md-content');
                                if (mdDiv) {
                                    mdDiv.innerHTML = marked.parse(fullContent);
                                }
                                messagesDiv.scrollTop = messagesDiv.scrollHeight;
                            } else if (parsed.type === 'search_results') {
                                setStatus(`已联网搜索 (强度${parsed.intensity})`);
                                renderSearchResults(parsed.results);
                            } else if (parsed.type === 'error') {
                                throw new Error(parsed.error);
                            } else if (parsed.type === 'done') {
                                // 完成
                                break;
                            }
                        } catch (e) {
                            console.error('解析错误', e);
                        }
                    }
                }
            }
            // 保存完整回复到历史
            currentMessages.push({ role: 'assistant', content: fullContent, timestamp: new Date().toISOString() });
            setStatus('就绪');
        } catch (err) {
            console.error(err);
            const mdDiv = tempBubble.querySelector('.md-content');
            if (mdDiv) {
                mdDiv.innerHTML = `❌ 错误:${err.message}`;
            } else {
                tempBubble.innerHTML = `❌ 错误:${err.message}`;
            }
            setStatus(err.message, true);
        } finally {
            isWaiting = false;
            sendBtn.disabled = false;
            messageInput.focus();
            // 刷新对话列表(更新标题)
            await loadConversations();
        }
    }

    // 验证API密钥
    async function validateApiKey() {
        const key = getApiKey();
        if (!key) {
            setStatus('请输入API密钥', true);
            return;
        }
        setStatus('验证中...');
        try {
            const resp = await fetch('/api/validate-key', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ api_key: key })
            });
            const data = await resp.json();
            if (data.valid) {
                setStatus('密钥有效 ✓');
            } else {
                setStatus(`密钥无效:${data.message}`, true);
            }
        } catch (e) {
            setStatus('验证失败,请检查网络', true);
        }
    }

    // 初始化:加载上次对话
    async function init() {
        paramToggle.addEventListener('click', () => {
            paramPanel.classList.toggle('show');
        });
        validateKeyBtn.addEventListener('click', validateApiKey);
        sendBtn.addEventListener('click', sendMessage);
        newChatBtn.addEventListener('click', newConversation);
        messageInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendMessage();
            }
        });
        apiKeyInput.addEventListener('change', saveApiKey);

        loadApiKey();

        const lastId = loadLastConversationId();
        if (lastId) {
            currentConversationId = lastId;
            await loadConversationHistory(currentConversationId);
            renderMessages();
        } else {
            currentConversationId = null;
            currentMessages = [];
            renderMessages();
        }
        await loadConversations();
        setStatus('就绪');
    }

    init();
</script>
</body>
</html>

使用时,运行app.py
在这里插入图片描述
点击左下角的访问地址即可使用。完整项目资源见https://download.csdn.net/download/nmdbbzcl/92836721。资源可能需要一定审核时间,点击后能打开了就下载。

更多推荐