【导航台账】老蒋的技术博客全系列文章汇总(持续更新) 

      老攻城狮一枚,励志要好好学习天天向上,分享20多年实战项目中的点点滴滴,顺带引导家里小公举学AI编程。  编程点滴,留下足迹; 分享交流,共勉加油。也许朋友们会问:为什么一个20+年IT老兵决定系统性学习AI人工智能

        今天实践分享的内容很接地气,这个场景是非常典型的,最容易实施的。大家可以跟着一步一步来,和我一起动手搭建一个【橘子洲头景区AI客服】的完整AI应用场景。这个版本是延续之前跑通的Flask + Ollama方案:老攻城狮的AI开发环境搭建全记录:从零到跑通本地大模型(一日速通版),用核心景区“长沙橘子洲头”的案例做了个性化的场景化改造——加入景区知识库、定制Prompt、优化UI体验。

        话不多说,直接上干货,接下来我的每一步操作都配有完整代码和操作说明,亲爱的读者!您也可以和我一起码动起来(环境搭建等相关的基础内容请参考之前的文章,这里就不啰嗦了)

一、项目结构

        这次我们用一个新目录 orange_island_ai,与之前的知识库案例分开,保持工程清晰,目录结构如下:

~/ai_project/orange_island_ai/
├── app.py                      # Flask 主程序
├── templates/
│   └── index.html              # 前端页面
├── knowledge_base/
│   └── qa_pairs.json           # 橘子洲头问答库(30条)
├── static/
│   └── style.css               # (可选)自定义样式
└── requirements.txt            # 依赖清单

二、环境准备

2.1 进入项目目录

cd ~/ai_project
mkdir orange_island_ai && cd orange_island_ai
mkdir templates knowledge_base static

2.2 激活虚拟环境并安装依赖

source ../venv/bin/activate
pip install flask requests

2.3 确认Ollama服务运行

确保Ollama在后台运行,且qwen2.5:7b模型已下载。

ollama list
# 应显示 qwen2.5:7b

如果未下载,执行:

ollama run qwen2.5:7b 
# 下载完成后输入 /bye 退出

三、知识库数据:knowledge_base/qa_pairs.json

将以下30条问答对保存为 knowledge_base/qa_pairs.json

{
  "景区信息": [
    {
      "q": "橘子洲头景区的开放时间是什么?",
      "a": "橘子洲头景区全年开放,开放时间为07:00-22:00,21:00停止入园。建议游客在21:00前完成入园,景区闭园前离开即可。"
    },
    {
      "q": "橘子洲头需要门票吗?门票多少钱?",
      "a": "橘子洲头景区门票免费,但所有游客必须提前实名预约入园。景区无大门票费用,免费开放。"
    },
    {
      "q": "观光小火车的票价是多少?有优惠吗?",
      "a": "观光小火车环全洲线往返票40元/人。以下人群享受半价优惠(20元/人):18周岁以下未成年人、60周岁以上老年人、全日制大学本科及以下在校学生、残疾人、现役军人等。1.2米以下儿童免票。"
    },
    {
      "q": "观光小火车的运营时间是什么?",
      "a": "观光小火车运营时间分冬夏季:夏季(5月1日-10月31日)7:00-22:00,冬季(11月1日-4月30日)7:30-21:30。建议留意末班车时间,避免滞留。"
    },
    {
      "q": "湘江游轮的价格是多少?",
      "a": "湘江游轮日游票约120元/人起,夜游票约188元/人起。白天观光游轮航程约40分钟至1小时,夜景游轮航程约一个半小时,可欣赏湘江两岸灯光秀。"
    }
  ],
  "交通与停车": [
    {
      "q": "怎么去橘子洲头最方便?",
      "a": "地铁2号线是最佳选择,乘坐地铁2号线至橘子洲·青莲站,从1号口或2号口出站即达景区入口。出站后左手边就是观光小火车始发站,步行不到50米。"
    },
    {
      "q": "可以自驾去橘子洲头吗?",
      "a": "周末及节假日社会车辆禁行上岛。工作日自驾需提前在岳麓山橘子洲旅游区微信公众号预约车辆入园。景区周边停车场车位紧张且易堵车,强烈建议乘坐地铁。"
    },
    {
      "q": "橘子洲头有停车场吗?收费标准是什么?",
      "a": "景区有东停车场和西停车场两个停车场。收费标准为:首小时10元,后续每小时5元,单日最高50元。东停车场靠近毛主席雕像,位置较紧俏。"
    },
    {
      "q": "如果景区停车场满了怎么办?",
      "a": "可停到周边步步高广场、五一广场或溁湾镇地铁站附近的商业停车场,再坐地铁前往景区。停好车后坐地铁2号线到橘子洲站,仅需一两站。"
    },
    {
      "q": "可以坐公交车去橘子洲头吗?",
      "a": "可以。109路、152路、913路等公交车可到达橘子洲景区附近。但公交车下车点在景区外,需要步行过桥,不如地铁方便。地铁2号线直达景区内,是最优选择。"
    }
  ],
  "预约与入园": [
    {
      "q": "去橘子洲头需要预约吗?",
      "a": "必须预约。橘子洲头实行实名预约制度,所有游客(含老人、儿童)均需提前预约,无免预约绿色通道。未预约无法入园。"
    },
    {
      "q": "怎么预约橘子洲头门票?",
      "a": "通过官方唯一渠道岳麓山橘子洲旅游区微信公众号预约。也可通过趣长沙小程序预约。预约成功后凭二维码或身份证原件刷证入园。"
    },
    {
      "q": "可以提前多少天预约?",
      "a": "可提前3天预约,每日0点更新门票。节假日建议放票即抢,高峰时段当日票基本秒空。"
    },
    {
      "q": "预约分时段吗?每个时段有多少名额?",
      "a": "分三个时段预约:早上07:00-12:00:4万人;中午12:00-17:00:3万人;晚上17:00-22:00:1万人。需在所选时间段内入园,超时需重新预约。"
    },
    {
      "q": "入园需要带什么证件?",
      "a": "携带预约时登记的身份证原件,刷证或刷脸通行。一人一证,不可转借。安检禁止携带易燃易爆品、三脚架、自拍杆及大件行李。"
    }
  ],
  "景点与游玩": [
    {
      "q": "橘子洲头有哪些必打卡景点?",
      "a": "核心景点包括:青年毛泽东艺术雕塑(高32米,世界最大毛泽东雕像)、问天台(沁园春·长沙出处)、沁园春·长沙诗词碑、望江亭(唐代古风,270°江景)、百亩橘园、谁主沉浮雕塑群、长沙市非物质文化遗产展示馆、朱张古渡、唐生智公馆。"
    },
    {
      "q": "橘子洲头推荐游览时间多长?",
      "a": "推荐游览时间3-5小时。橘子洲全长约5公里,纯步行往返超10公里。90%的游客选择乘坐观光车。"
    },
    {
      "q": "毛泽东青年艺术雕塑有多大?",
      "a": "雕塑高32米,长83米,宽41米,是目前世界上最大的毛泽东雕像。由8000多块花岗岩拼接而成,以1925年青年时期的毛泽东形象为原型。"
    },
    {
      "q": "橘子洲头有烟花表演吗?",
      "a": "特定节日有烟花燃放活动。烟花燃放时间通常为20:30-20:50。烟花燃放当天下午16:00禁止入园,具体以景区实际公示为准。杜甫江阁是最佳观赏点之一。"
    },
    {
      "q": "橘子洲头有什么特色体验?",
      "a": "特色体验包括:瞻仰32米伟人雕塑、乘坐观光小火车吹江风看长沙城景、文创店免费集章(最多138枚)、夜游欣赏19:30江岸灯光秀、秋季可在橘洲文化园体验摘橘子。"
    }
  ],
  "观光车与游船": [
    {
      "q": "观光小火车值得坐吗?",
      "a": "非常值得。橘子洲全长约5公里,步行往返超10公里。观光车全程往返40元/人,可在各站点随上随下。90%的游客选择乘坐观光车。务必购买往返票,单程走断腿。"
    },
    {
      "q": "观光小火车怎么购票?",
      "a": "通过长沙城发橘子洲微信小程序线上购票,或现场自助售票机、人工售票窗口购票。购票后凭电子检票码扫码乘车,无需兑换纸质票。"
    },
    {
      "q": "观光小火车的站点有哪些?",
      "a": "主要站点包括:橘子洲站(地铁入口始发站)、神职人员寓所站、新诗词碑站、问天台站(青年毛泽东雕像)、橘洲客栈站、沙滩公园站、江神庙站。车票当日有效,同一站点只可乘车一次。"
    },
    {
      "q": "观光小火车的优待票怎么买?",
      "a": "18周岁以下未成年人、60周岁以上老年人可凭身份证在自助机上购买优待票。其他优待票(在校学生、退役军人等)需在人工售票窗口购买。"
    },
    {
      "q": "水陆两栖车是什么?值得体验吗?",
      "a": "水陆两栖车是橘子洲近年推出的网红交通工具,既能在陆地上跑,又能直接开到水里。运营时间需以景区当日公告为准(夏季通常17:00停止营运)。建议提前确认运营时间,避免白跑一趟。"
    }
  ],
  "错峰与贴士": [
    {
      "q": "什么时间去橘子洲头人最少?",
      "a": "推荐两个黄金时段:清晨7:30-9:00(游客稀少、光线柔和,适合拍照)和傍晚17:00-22:00(人流最少、可欣赏日落+夜景)。中午12:00-14:00是全天客流低谷,但阳光强烈、遮阴少。"
    },
    {
      "q": "橘子洲头游玩有什么注意事项?",
      "a": "必须提前预约,未预约无法入园;穿舒适的鞋,景区超长,暴走会崩溃;不要相信黄牛,官方预约免费;携带身份证原件入园;禁止携带三脚架、自拍杆及大件行李。"
    },
    {
      "q": "橘子洲头附近有什么好吃的?",
      "a": "长沙特色美食包括:黑色经典臭豆腐、糖油粑粑、剁椒鱼头、辣椒炒肉、口味虾、嗦螺、酱板鸭、湘莲。景区内有江天暮雪风物市集,涵盖31家商铺。"
    },
    {
      "q": "橘子洲头适合什么季节去?",
      "a": "橘子洲四时之景各异:春季(2月中旬至3月樱花盛开,梅园梅花绽放)、秋季(橘黄橘绿之秋景最佳,可体验摘橘子)。全年四季皆宜,各有特色。"
    },
    {
      "q": "带老人小孩去橘子洲头有什么建议?",
      "a": "务必乘坐观光小火车(老人、儿童享半价优惠);提前3天预约,选早上或傍晚时段避开人流高峰;1.2米以下儿童观光车免票;景区内各站点均可上下车,不必一次性走完全程;注意末班车时间,避免滞留。"
    }
  ]
}

四、后端代码:app.py


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
橘子洲头景区AI客服 - Flask后端

功能:
    1. 提供Web聊天界面
    2. 接收用户问题,调用Ollama API生成回答
    3. 支持基于本地知识库的精准问答
    4. 包含景区客服专属Prompt

作者:Javy21 (https://blog.csdn.net/javy21)
环境:WSL2 Ubuntu 22.04 / Python 3.10 / Ollama + Qwen2.5:7B
"""

import os
import json
import requests
from flask import Flask, render_template, request, jsonify

# ---------- 配置 ----------
app = Flask(__name__)

# Ollama服务地址(根据实际环境修改)
OLLAMA_URL = "http://192.168.0.106:11434/api/generate"
MODEL_NAME = "qwen2.5:7b"

# 加载本地知识库
KNOWLEDGE_BASE = []
try:
    with open('./knowledge_base/qa_pairs.json', 'r', encoding='utf-8') as f:
        data = json.load(f)
        for category, pairs in data.items():
            for pair in pairs:
                KNOWLEDGE_BASE.append({
                    'question': pair['q'],
                    'answer': pair['a'],
                    'category': category
                })
    print(f"✅ 加载 {len(KNOWLEDGE_BASE)} 条知识库问答")
except Exception as e:
    print(f"⚠️ 知识库加载失败: {e}")
    KNOWLEDGE_BASE = []

# ---------- 系统Prompt ----------
SYSTEM_PROMPT = """你是橘子洲头景区的AI客服助手,名字叫"橘子小助手"。

【你的角色】
你是橘子洲头景区的官方智能客服,为游客提供准确、友好、高效的咨询服务。

【回答规则】
1. 优先使用知识库中的标准答案回答
2. 如果知识库中没有,基于你的通用知识进行合理回答
3. 回答必须与橘子洲头景区相关,不回答无关问题
4. 语气热情友好,带长沙特色(可以偶尔用"霸得蛮"、"韵味"等长沙方言点缀)

【景区基本信息】
- 名称:橘子洲头景区
- 开放时间:07:00-22:00
- 门票:免费(需预约)
- 地址:湖南省长沙市岳麓区橘子洲头2号
- 地铁:2号线橘子洲·青莲站

【核心服务】
- 预约入园:微信公众号"岳麓山橘子洲旅游区"
- 观光车:环全洲线往返40元/人
- 游船:日游120元起,夜游188元起

【回答要求】
- 简洁明了,关键信息加粗
- 涉及价格、时间等数字信息必须准确
- 不确定的信息要明确告知"建议以景区官方公告为准"
- 回答控制在200字以内
"""


# ---------- 本地检索函数 ----------
def search_knowledge(query):
    """
    在本地知识库中检索匹配的问答对
    简单关键词匹配,返回最相关的一条
    """
    query_lower = query.lower()
    best_match = None
    best_score = 0

    for item in KNOWLEDGE_BASE:
        q = item['question'].lower()
        # 简单的关键词匹配评分
        score = 0
        keywords = query_lower.split()
        for kw in keywords:
            if kw in q:
                score += 1
        # 完整匹配加分
        if query_lower in q:
            score += 5
        if score > best_score:
            best_score = score
            best_match = item

    # 只有匹配度 > 0 才返回,否则返回None
    if best_score > 0:
        return best_match
    return None


# ---------- 路由 ----------
@app.route('/')
def index():
    """渲染主页面"""
    return render_template('index.html')


@app.route('/api/chat', methods=['POST'])
def chat():
    """
    聊天API
    请求体:{"message": "用户问题"}
    返回:{"reply": "AI回答"}
    """
    data = request.get_json()
    user_message = data.get('message', '').strip()

    if not user_message:
        return jsonify({'error': '请输入您的问题'}), 400

    # 1. 先查本地知识库
    matched = search_knowledge(user_message)

    if matched:
        # 直接使用知识库答案
        reply = f"{matched['answer']}\n\n(以上信息来自橘子洲头景区官方指南)"
        print(f"📚 命中知识库: {matched['question'][:30]}...")
    else:
        # 2. 调用大模型
        print(f"🤖 调用大模型: {user_message[:30]}...")

        # 构造Prompt
        prompt = f"""{SYSTEM_PROMPT}

游客提问:{user_message}

请以橘子洲头景区AI客服的身份,回答游客的问题。如果不知道答案,请礼貌告知并建议查看官方渠道。"""

        payload = {
            'model': MODEL_NAME,
            'prompt': prompt,
            'stream': False,
            'options': {
                'temperature': 0.2,
                'num_predict': 512
            }
        }

        try:
            response = requests.post(OLLAMA_URL, json=payload, timeout=60)
            response.raise_for_status()
            result = response.json()
            reply = result.get('response', '抱歉,我暂时无法回答这个问题。')
        except requests.exceptions.Timeout:
            reply = '抱歉,AI思考时间过长,请稍后再试。'
        except Exception as e:
            print(f"❌ Ollama调用失败: {e}")
            reply = '抱歉,AI服务暂时不可用,请稍后再试。'

    return jsonify({'reply': reply})


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5003, 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, 'Segoe UI', 'Microsoft YaHei', sans-serif;
            background: #f0f4f8;
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        /* ---------- 主容器 ---------- */
        .chat-container {
            width: 100%;
            max-width: 420px;
            height: 92vh;
            max-height: 780px;
            background: #ffffff;
            border-radius: 24px;
            box-shadow: 0 20px 60px rgba(0,0,0,0.15);
            display: flex;
            flex-direction: column;
            overflow: hidden;
            position: relative;
        }

        /* ---------- 头部 ---------- */
        .header {
            background: linear-gradient(135deg, #2d7d46 0%, #1a5c33 100%);
            padding: 20px 24px 16px;
            color: #fff;
            flex-shrink: 0;
        }
        .header-top {
            display: flex;
            align-items: center;
            gap: 12px;
        }
        .header .avatar {
            width: 44px;
            height: 44px;
            background: rgba(255,255,255,0.2);
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 24px;
            flex-shrink: 0;
        }
        .header .title {
            font-size: 18px;
            font-weight: 700;
        }
        .header .subtitle {
            font-size: 12px;
            opacity: 0.85;
            margin-top: 2px;
        }
        .header .status {
            font-size: 11px;
            opacity: 0.8;
            margin-top: 6px;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        .header .status .dot {
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background: #7bed9f;
            display: inline-block;
            animation: pulse 2s infinite;
        }
        @keyframes pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.4; }
        }

        /* ---------- 快捷入口 ---------- */
        .quick-actions {
            padding: 12px 16px 8px;
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
            flex-shrink: 0;
            border-bottom: 1px solid #f0f0f0;
        }
        .quick-actions .tag {
            padding: 6px 14px;
            background: #f0f7f3;
            color: #2d7d46;
            border-radius: 20px;
            font-size: 12px;
            cursor: pointer;
            transition: all 0.2s;
            border: 1px solid transparent;
            white-space: nowrap;
        }
        .quick-actions .tag:hover {
            background: #2d7d46;
            color: #fff;
        }

        /* ---------- 消息区域 ---------- */
        .messages {
            flex: 1;
            padding: 16px 20px;
            overflow-y: auto;
            background: #fafcfe;
        }
        .messages::-webkit-scrollbar {
            width: 4px;
        }
        .messages::-webkit-scrollbar-thumb {
            background: #ccc;
            border-radius: 4px;
        }

        .message {
            margin-bottom: 16px;
            display: flex;
            flex-direction: column;
            animation: fadeIn 0.3s ease;
        }
        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(8px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .message .bubble {
            max-width: 88%;
            padding: 12px 16px;
            border-radius: 16px;
            font-size: 15px;
            line-height: 1.7;
            word-wrap: break-word;
        }
        .message.user {
            align-items: flex-end;
        }
        .message.user .bubble {
            background: #2d7d46;
            color: #fff;
            border-bottom-right-radius: 4px;
        }
        .message.assistant {
            align-items: flex-start;
        }
        .message.assistant .bubble {
            background: #f0f0f0;
            color: #1a2332;
            border-bottom-left-radius: 4px;
        }
        .message .time {
            font-size: 11px;
            color: #999;
            margin-top: 4px;
            padding: 0 4px;
        }

        /* ---------- 欢迎消息 ---------- */
        .welcome {
            text-align: center;
            padding: 20px 0 8px;
        }
        .welcome .icon { font-size: 48px; }
        .welcome h3 {
            font-size: 18px;
            color: #1a2332;
            margin: 8px 0 4px;
        }
        .welcome p {
            font-size: 14px;
            color: #6b7a8f;
        }

        /* ---------- 输入区域 ---------- */
        .input-area {
            display: flex;
            padding: 12px 16px 16px;
            border-top: 1px solid #eee;
            background: #fff;
            flex-shrink: 0;
            gap: 10px;
        }
        .input-area input {
            flex: 1;
            padding: 12px 16px;
            border: 1.5px solid #e0e6ed;
            border-radius: 24px;
            outline: none;
            font-size: 15px;
            transition: border-color 0.2s;
        }
        .input-area input:focus {
            border-color: #2d7d46;
        }
        .input-area input:disabled {
            background: #f5f7fa;
        }
        .input-area button {
            padding: 12px 24px;
            background: #2d7d46;
            color: #fff;
            border: none;
            border-radius: 24px;
            font-size: 15px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.2s;
            flex-shrink: 0;
        }
        .input-area button:hover {
            background: #1f5c33;
        }
        .input-area button:disabled {
            background: #aab3c5;
            cursor: not-allowed;
        }

        /* ---------- 底部安全区 ---------- */
        .footer {
            text-align: center;
            font-size: 11px;
            color: #bbb;
            padding: 6px 0 10px;
            flex-shrink: 0;
            border-top: 1px solid #f5f5f5;
        }
        .footer a {
            color: #2d7d46;
            text-decoration: none;
        }

        /* ---------- 响应式 ---------- */
        @media (max-width: 460px) {
            .chat-container {
                max-width: 100%;
                height: 100vh;
                max-height: 100vh;
                border-radius: 0;
                box-shadow: none;
            }
            body { padding: 0; }
        }
    </style>
</head>
<body>

<div class="chat-container">
    <!-- 头部 -->
    <div class="header">
        <div class="header-top">
            <div class="avatar">🍊</div>
            <div>
                <div class="title">橘子洲头·智能客服</div>
                <div class="subtitle">橘子小助手 · 7×24小时在线</div>
            </div>
        </div>
        <div class="status">
            <span class="dot"></span>
            <span>已接入「橘子洲头景区」知识库</span>
        </div>
    </div>

    <!-- 快捷入口 -->
    <div class="quick-actions" id="quickTags">
        <span class="tag" data-msg="橘子洲头开放时间是什么?">🕐 开放时间</span>
        <span class="tag" data-msg="观光小火车票价多少?">🚂 小火车票价</span>
        <span class="tag" data-msg="怎么去橘子洲头?">🚇 交通路线</span>
        <span class="tag" data-msg="需要预约吗?怎么预约?">📱 预约入园</span>
        <span class="tag" data-msg="毛泽东雕塑有多大?">🗿 伟人雕塑</span>
        <span class="tag" data-msg="橘子洲头有什么好吃的?">🍜 美食推荐</span>
    </div>

    <!-- 消息区域 -->
    <div class="messages" id="messages">
        <div class="welcome" id="welcome">
            <div class="icon">🍊</div>
            <h3>欢迎来到橘子洲头!</h3>
            <p>我是橘子小助手,可以帮你查询<br>开放时间、门票、交通、景点等一切信息~</p>
        </div>
    </div>

    <!-- 输入区域 -->
    <div class="input-area">
        <input type="text" id="userInput" placeholder="输入你的问题..." />
        <button id="sendBtn">发送</button>
    </div>

    <div class="footer">
        <a href="https://blog.csdn.net/javy21" target="_blank">老攻城狮的AI编程实践之路</a> · 橘子洲头景区AI客服
    </div>
</div>

<script>
    // ============================================================
    // 橘子洲头AI客服 - 前端交互
    // 作者:Javy21 (https://blog.csdn.net/javy21)
    // ============================================================

    const messages = document.getElementById('messages');
    const input = document.getElementById('userInput');
    const sendBtn = document.getElementById('sendBtn');
    const welcome = document.getElementById('welcome');

    /**
     * 添加一条消息
     */
    function addMessage(role, content) {
        // 隐藏欢迎语
        if (welcome) welcome.style.display = 'none';

        const div = document.createElement('div');
        div.className = `message ${role}`;

        const bubble = document.createElement('div');
        bubble.className = 'bubble';
        bubble.innerHTML = content.replace(/\n/g, '<br>');

        div.appendChild(bubble);
        messages.appendChild(div);
        messages.scrollTop = messages.scrollHeight;
    }

    /**
     * 设置加载状态
     */
    function setLoading(loading) {
        sendBtn.disabled = loading;
        input.disabled = loading;
        sendBtn.textContent = loading ? '思考中...' : '发送';
        if (loading) {
            const loadingDiv = document.createElement('div');
            loadingDiv.className = 'message assistant';
            loadingDiv.id = 'loadingMsg';
            loadingDiv.innerHTML = `<div class="bubble" style="background:#f0f0f0;color:#888;">⏳ 正在思考...</div>`;
            messages.appendChild(loadingDiv);
            messages.scrollTop = messages.scrollHeight;
        } else {
            const loadingMsg = document.getElementById('loadingMsg');
            if (loadingMsg) loadingMsg.remove();
        }
    }

    /**
     * 发送消息
     */
    async function sendMessage() {
        const text = input.value.trim();
        if (!text) return;

        // 显示用户消息
        addMessage('user', text);
        input.value = '';
        setLoading(true);

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

            if (response.ok) {
                addMessage('assistant', data.reply);
            } else {
                addMessage('assistant', `❌ 错误:${data.error || '服务异常'}`);
            }
        } catch (err) {
            addMessage('assistant', `❌ 网络错误:${err.message}`);
        } finally {
            setLoading(false);
            input.focus();
        }
    }

    // ---------- 事件绑定 ----------
    sendBtn.addEventListener('click', sendMessage);
    input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') sendMessage();
    });

    // 快捷标签点击
    document.querySelectorAll('.tag').forEach(tag => {
        tag.addEventListener('click', function() {
            input.value = this.dataset.msg;
            sendMessage();
        });
    });

    // 聚焦
    input.focus();
</script>
</body>
</html>

六、运行与验证

6.1 启动服务

cd ~/ai_project/orange_island_ai
source ../venv/bin/activate
python app.py
看到以下输出表示成功:
✅ 加载 30 条知识库问答
 * Running on http://0.0.0.0:5003

6.2 访问测试

浏览器打开 http://localhost:5003

测试问题

  • “橘子洲头开放时间是什么?” → 应命中知识库,返回标准答案

  • “观光小火车票价多少?” → 应命中知识库

  • “橘子洲头有什么好吃的?” → 应命中知识库

  • “长沙还有什么好玩的?” → 知识库无匹配,调用大模型

6.3 验证结果

测试场景 预期结果
知识库命中 秒回标准答案,带“来自橘子洲头景区官方指南”
知识库未命中 调用大模型,以客服身份回答
快捷标签点击 自动填充并发送

【写在最后】橘子洲头的AI客服应用参考示例,从构思、设计、编码、部署、测试一步步落地实施,我已经完整跑通了,并记录了下来整个过程。代码全部开源,考虑版权和数据安全问题,项目的数据全部来自公开信息(如有请涉嫌版权,请联系博主进行删除),您拿到就能用、用了就能跑。

接下来我想听听您的声音:

  • 您所在的城市,有没有适合做成AI客服的景区或公共场景?

  • 您觉得以上的这个实例里面,最值得扩展的功能是什么?

  • 如果把这套方案落地到真实生产环境,您最担心的技术或运维问题是什么?

欢迎在评论区留言,我会认真对待每一位读者,认真看每一条回复,挑有代表性的问题进行专题解答。

如果这篇内容对您有帮助,麻烦您动动发财的手,帮忙点个“赞”和“收藏”,让更多正在探索AI落地的同行看到。有任何疑问,或者想看其他场景的AI应用实践,也欢迎告诉我——下一篇文章的主题,可能就来自您的建议。灰常感谢。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐