本文完整记录了一个基于 HTML5 + CSS3 + JavaScript 的 AI 对话项目「歌词猜歌名 AI 对话」从构思、设计、编码、联调到发布的全过程,包含大模型接口调用、SSE 流式解析、系统提示词工程、JSON 数据契约设计、前端界面实现与端到端测试等核心环节,希望为同样想动手做 AI 应用的朋友提供一份可复用的实战参考。

仓库地址:https://gitcode.com/IHYAMamg/sfjklsds.git

 码道项目生成:

<!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;
        }

        @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');

        body {
            font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 50%, #0d1b2a 100%);
            background-attachment: fixed;
            overflow-x: hidden;
            padding: 20px;
        }

        /* ========== 游戏容器样式 ========== */
        #app {
            width: 100%;
            max-width: 600px;
        }

        .game-container {
            background: rgba(255, 255, 255, 0.08);
            backdrop-filter: blur(20px);
            -webkit-backdrop-filter: blur(20px);
            border-radius: 24px;
            border: 1px solid rgba(255, 255, 255, 0.15);
            box-shadow: 
                0 25px 50px rgba(0, 0, 0, 0.4),
                0 0 0 1px rgba(255, 255, 255, 0.05),
                inset 0 1px 0 rgba(255, 255, 255, 0.1);
            padding: 40px 30px;
        }

        /* ========== 游戏信息区样式 ========== */
        .game-info {
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap;
            gap: 12px;
            margin-bottom: 30px;
            padding-bottom: 20px;
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
        }

        .game-info span {
            display: inline-flex;
            align-items: center;
            padding: 8px 16px;
            border-radius: 12px;
            background: rgba(255, 255, 255, 0.08);
            font-size: 14px;
            color: rgba(255, 255, 255, 0.9);
            letter-spacing: 0.5px;
        }

        #difficulty-display {
            background: rgba(99, 102, 241, 0.25);
            border: 1px solid rgba(99, 102, 241, 0.4);
        }

        #score-display {
            background: rgba(34, 197, 94, 0.25);
            border: 1px solid rgba(34, 197, 94, 0.4);
        }

        #high-score-display {
            background: rgba(251, 191, 36, 0.25);
            border: 1px solid rgba(251, 191, 36, 0.4);
        }

        #streak-display {
            background: rgba(244, 63, 94, 0.25);
            border: 1px solid rgba(244, 63, 94, 0.4);
        }

        /* ========== 歌词展示区样式 ========== */
        #lyrics-display {
            min-height: 120px;
            display: flex;
            justify-content: center;
            align-items: center;
            text-align: center;
            margin-bottom: 30px;
            padding: 20px;
            background: rgba(0, 0, 0, 0.3);
            border-radius: 16px;
            border: 1px solid rgba(255, 255, 255, 0.08);
        }

        #lyrics-display p {
            font-size: 26px;
            font-weight: 500;
            font-style: italic;
            color: #ffd700;
            text-shadow: 
                0 0 20px rgba(255, 215, 0, 0.5),
                0 0 40px rgba(255, 215, 0, 0.3);
            line-height: 1.6;
            letter-spacing: 1px;
        }

        /* ========== 输入区样式 ========== */
        .input-area {
            display: flex;
            gap: 12px;
            margin-bottom: 25px;
        }

        #guess-input {
            flex: 1;
            padding: 15px 20px;
            font-size: 16px;
            border: 2px solid rgba(255, 255, 255, 0.15);
            border-radius: 14px;
            background: rgba(255, 255, 255, 0.08);
            color: #fff;
            outline: none;
            transition: all 0.3s ease;
        }

        #guess-input::placeholder {
            color: rgba(255, 255, 255, 0.4);
        }

        #guess-input:focus {
            border-color: #6366f1;
            background: rgba(255, 255, 255, 0.12);
            box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
        }

        /* ========== 按钮样式 ========== */
        button {
            padding: 15px 24px;
            font-size: 15px;
            font-weight: 600;
            border: none;
            border-radius: 14px;
            cursor: pointer;
            transition: all 0.3s ease;
            color: #fff;
            letter-spacing: 0.5px;
        }

        #submit-btn {
            background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
            box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
        }

        #submit-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
        }

        #submit-btn:active {
            transform: translateY(0);
        }

        .action-area {
            display: flex;
            gap: 12px;
            flex-wrap: wrap;
        }

        #next-btn {
            flex: 1;
            background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%);
            box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
            min-width: 120px;
        }

        #next-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
        }

        #hint-btn {
            background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
            box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
        }

        #hint-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(245, 158, 11, 0.5);
        }

        #change-difficulty-btn {
            background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%);
            box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
        }

        #change-difficulty-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(16, 185, 129, 0.5);
        }

        /* ========== 反馈区样式 ========== */
        #feedback {
            margin-top: 25px;
            padding: 18px;
            border-radius: 14px;
            text-align: center;
            font-size: 18px;
            font-weight: 500;
            opacity: 0;
            transform: scale(0.9);
            transition: all 0.3s ease;
            pointer-events: none;
        }

        #feedback.show {
            opacity: 1;
            transform: scale(1);
        }

        #feedback.correct {
            background: rgba(34, 197, 94, 0.2);
            border: 1px solid rgba(34, 197, 94, 0.5);
            color: #4ade80;
            animation: bounce 0.6s ease;
        }

        #feedback.incorrect {
            background: rgba(239, 68, 68, 0.2);
            border: 1px solid rgba(239, 68, 68, 0.5);
            color: #f87171;
            animation: shake 0.5s ease;
        }

        /* ========== 动画效果 ========== */
        @keyframes bounce {
            0%, 100% { transform: scale(1); }
            25% { transform: scale(1.08); }
            50% { transform: scale(0.98); }
            75% { transform: scale(1.03); }
        }

        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            10% { transform: translateX(-10px); }
            20% { transform: translateX(10px); }
            30% { transform: translateX(-8px); }
            40% { transform: translateX(8px); }
            50% { transform: translateX(-6px); }
            60% { transform: translateX(6px); }
            70% { transform: translateX(-4px); }
            80% { transform: translateX(4px); }
            90% { transform: translateX(-2px); }
        }

        /* ========== 响应式样式 ========== */
        @media (max-width: 640px) {
            .game-container {
                padding: 30px 20px;
                border-radius: 20px;
            }

            .game-info {
                flex-direction: column;
                align-items: stretch;
            }

            .game-info span {
                justify-content: center;
                text-align: center;
            }

            #lyrics-display p {
                font-size: 20px;
            }

            .input-area {
                flex-direction: column;
            }

            #guess-input {
                font-size: 18px;
                padding: 14px 18px;
            }

            #submit-btn {
                width: 100%;
                padding: 16px;
            }

            .action-area {
                flex-direction: column;
            }

            #next-btn,
            #hint-btn,
            #change-difficulty-btn {
                width: 100%;
            }

            button {
                padding: 16px;
            }
        }

        @media (max-width: 380px) {
            #lyrics-display p {
                font-size: 18px;
            }

            .game-container {
                padding: 20px 15px;
            }
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="game-container">
            <!-- 游戏信息区 -->
            <div class="game-info">
                <span id="difficulty-display">难度:简单</span>
                <span id="score-display">得分:0</span>
                <span id="high-score-display">最高分:0</span>
                <span id="streak-display">连击:0</span>
            </div>

            <!-- 歌词展示区 -->
            <div id="lyrics-display">
                <p>歌词将在这里显示...</p>
            </div>

            <!-- 输入区 -->
            <div class="input-area">
                <input type="text" id="guess-input" placeholder="请输入歌名...">
                <button id="submit-btn">提交</button>
            </div>

            <!-- 反馈区 -->
            <div id="feedback">
                <p>反馈信息将在这里显示...</p>
            </div>

            <!-- 操作区 -->
            <div class="action-area">
                <button id="next-btn">下一题</button>
                <button id="hint-btn">提示</button>
                <button id="change-difficulty-btn">切换难度</button>
            </div>
        </div>
    </div>

    <script>
        // ========== 歌曲库数据 ==========
        const songLibrary = [
            {
                id: 1,
                title: "晴天",
                artist: "周杰伦",
                lyrics: {
                    easy: "从前从前,有个人爱你很久",
                    medium: "故事的小黄花,从出生那年就飘着",
                    hard: "刮风这天,我试过握着你手,但偏偏,雨渐渐,大到我看你不见"
                }
            },
            {
                id: 2,
                title: "七里香",
                artist: "周杰伦",
                lyrics: {
                    easy: "雨下整夜,我的爱溢出就像雨水",
                    medium: "窗外的麻雀,在电线杆上多嘴",
                    hard: "你突然对我说,七里香的名字很美"
                }
            },
            {
                id: 3,
                title: "青花瓷",
                artist: "周杰伦",
                lyrics: {
                    easy: "天青色等烟雨,而我在等你",
                    medium: "素胚勾勒出青花,笔锋浓转淡",
                    hard: "冉冉檀香透过窗,心事我了然,宣纸上走笔至此搁一半"
                }
            },
            {
                id: 4,
                title: "夜曲",
                artist: "周杰伦",
                lyrics: {
                    easy: "为你弹奏萧邦的夜曲,纪念我死去的爱情",
                    medium: "一群嗜血的蚂蚁,被腐肉所吸引",
                    hard: "我用漂亮的押韵,形容被掠夺一空爱情"
                }
            },
            {
                id: 5,
                title: "稻香",
                artist: "周杰伦",
                lyrics: {
                    easy: "还记得你说家是唯一的城堡,随着稻香河流继续奔跑",
                    medium: "对这个世界如果你有太多的抱怨,跌倒了就不敢继续往前走",
                    hard: "不要哭,让萤火虫带着你逃跑,乡间的歌谣,永远的依靠"
                }
            },
            {
                id: 6,
                title: "告白气球",
                artist: "周杰伦",
                lyrics: {
                    easy: "亲爱的,爱上你,从那天起,甜蜜的很轻易",
                    medium: "塞纳河畔,左岸的咖啡,我手一杯,品尝你的美",
                    hard: "你说你有点难追,想让我知难而进,礼物不需挑最贵,只要香榭的落叶"
                }
            },
            {
                id: 7,
                title: "江南",
                artist: "林俊杰",
                lyrics: {
                    easy: "风到这里就是黏,黏住过客的思念",
                    medium: "圈圈圆圆圈圈,天天年年天天的我,深深看你的脸",
                    hard: "不懂怎么表现温柔的我们,还以为殉情只是古老的传言"
                }
            },
            {
                id: 8,
                title: "曹操",
                artist: "林俊杰",
                lyrics: {
                    easy: "不是英雄,不读三国,若是英雄,怎么能不懂寂寞",
                    medium: "独自走下长坂坡,月光太温柔,曹操不啰嗦,一心要那荆州",
                    hard: "用阴谋阳谋,明说暗夺淡泊,东汉末年分三国,烽火连天不休"
                }
            },
            {
                id: 9,
                title: "修炼爱情",
                artist: "林俊杰",
                lyrics: {
                    easy: "修炼爱情的悲欢,我们这些努力不简单",
                    medium: "凭什么要失望,藏眼泪到心脏,几年前的幻想,几年后的原谅",
                    hard: "几年前的歌,听了几百遍,心还是会疼,想你的时候,就唱这首歌"
                }
            },
            {
                id: 10,
                title: "突然好想你",
                artist: "五月天",
                lyrics: {
                    easy: "突然好想你,你会在哪里,过得快乐或委屈",
                    medium: "最怕空气突然安静,最怕朋友突然的关心",
                    hard: "突然锋利的回忆,突然模糊的眼睛,我们像一首最美丽的歌曲"
                }
            },
            {
                id: 11,
                title: "倔强",
                artist: "五月天",
                lyrics: {
                    easy: "我和我最后的倔强,握紧双手绝对不放",
                    medium: "逆风的方向,更适合飞翔,我不怕千万人阻挡,只怕自己投降",
                    hard: "我的手越肮脏,眼神越是发光,你不在乎我的过往,看到了我的翅膀"
                }
            },
            {
                id: 12,
                title: "知足",
                artist: "五月天",
                lyrics: {
                    easy: "如果我爱上你的笑容,要怎么收藏怎么拥有",
                    medium: "当一阵风吹来,风筝飞上天空,为了你,而祈祷,而祝福,而感动",
                    hard: "怎么去拥有一道彩虹,怎么去拥抱一夏天的风,天上的星星笑地上的人"
                }
            },
            {
                id: 13,
                title: "海阔天空",
                artist: "Beyond",
                lyrics: {
                    easy: "原谅我这一生不羁放纵爱自由,也会怕有一天会跌倒",
                    medium: "今天我,寒夜里看雪飘过,怀着冷却了的心窝飘远方",
                    hard: "多少次,迎着冷眼与嘲笑,从没有放弃过心中的理想"
                }
            },
            {
                id: 14,
                title: "光辉岁月",
                artist: "Beyond",
                lyrics: {
                    easy: "钟声响起归家的讯号,在他生命里,仿佛带点唏嘘",
                    medium: "年月把拥有变做失去,疲倦的双眼带着期望",
                    hard: "今天只有残留的躯壳,迎接光辉岁月,风雨中抱紧自由"
                }
            },
            {
                id: 15,
                title: "后来",
                artist: "刘若英",
                lyrics: {
                    easy: "后来,我总算学会了,如何去爱,可惜你,早已远去,消失在人海",
                    medium: "栀子花,白花瓣,落在我蓝色百褶裙上,爱你,你轻声说",
                    hard: "那时候的爱情,为什么就能那样简单,而又是为什么,人年深日久"
                }
            },
            {
                id: 16,
                title: "小幸运",
                artist: "田馥甄",
                lyrics: {
                    easy: "原来你是我最想留住的幸运,原来我们和爱情曾经靠得那么近",
                    medium: "我听见雨滴落在青青草地,我听见远方下课钟声响起",
                    hard: "那为我对抗世界的决定,那为我淋的雨,一幕幕都是你,一尘不染的真心"
                }
            },
            {
                id: 17,
                title: "演员",
                artist: "薛之谦",
                lyrics: {
                    easy: "简单点,说话的方式简单点,递进的情绪请省略,你又不是个演员",
                    medium: "其实说分开也不难,我也没那么难堪,反正感情总是这样",
                    hard: "该配合你演出的我演视而不见,在逼一个最爱你的人即兴表演"
                }
            },
            {
                id: 18,
                title: "丑八怪",
                artist: "薛之谦",
                lyrics: {
                    easy: "如果像你一样总有人赞美,围绕着我的卑微也许能消退",
                    medium: "丑八怪,能否别把灯打开,我要的爱,出没在漆黑的舞台",
                    hard: "其实我根本没人说,其实我没你不能活,其实我给你的爱比你想的多"
                }
            },
            {
                id: 19,
                title: "泡沫",
                artist: "邓紫棋",
                lyrics: {
                    easy: "全都是泡沫,只一刹的花火,你所有承诺,全部都太脆弱",
                    medium: "阳光下的泡沫,是彩色的,就像被骗的,我是幸福的",
                    hard: "美丽的泡沫,虽然一刹花火,你所有承诺,虽然都太脆弱"
                }
            },
            {
                id: 20,
                title: "光年之外",
                artist: "邓紫棋",
                lyrics: {
                    easy: "缘分让我们相遇乱世以外,命运却要我们危难中相爱",
                    medium: "感受停在我发端的指尖,如何瞬间冻结时间",
                    hard: "我没想到为了你我能疯狂到,山崩海啸没有你根本不想逃"
                }
            },
            {
                id: 21,
                title: "十年",
                artist: "陈奕迅",
                lyrics: {
                    easy: "十年之前,我不认识你,你不属于我,我们还是一样,陪在一个陌生人左右",
                    medium: "怀抱既然不能逗留,何不在离开的时候,一边享受,一边泪流",
                    hard: "直到和你做了多年朋友,才明白我的眼泪,不是为你而流,也为别人而流"
                }
            },
            {
                id: 22,
                title: "因为爱情",
                artist: "陈奕迅",
                lyrics: {
                    easy: "因为爱情,不会轻易悲伤,所以一切都是幸福的模样",
                    medium: "给你一张过去的CD,听听那时我们的爱情,有时会突然忘了,我还在爱着你",
                    hard: "再唱不出那样的歌曲,听到都会红着脸躲避,虽然会经常忘了我依然爱着你"
                }
            },
            {
                id: 23,
                title: "红玫瑰",
                artist: "陈奕迅",
                lyrics: {
                    easy: "得不到的永远在骚动,被偏爱的都有恃无恐",
                    medium: "梦里梦到醒不来的梦,红线里被软禁的红",
                    hard: "从背后抱你的时候,期待的却是她的面容,说来实在嘲讽,我不太懂"
                }
            },
            {
                id: 24,
                title: "单车",
                artist: "陈奕迅",
                lyrics: {
                    easy: "难离难舍想抱紧些,茫茫人生好像荒野",
                    medium: "如孩童能伏于爸爸的肩膊,谁要下车,难离难舍总有一些",
                    hard: "不要不要假设我知道,一切一切也都是为我而做"
                }
            },
            {
                id: 25,
                title: "小情歌",
                artist: "苏打绿",
                lyrics: {
                    easy: "就算大雨让整座城市颠倒,我会给你怀抱",
                    medium: "这是一首简单的小情歌,唱着人们心肠的曲折",
                    hard: "你知道,就算大雨让这座城市颠倒,我会给你怀抱,受不了,看见你背影来到"
                }
            },
            {
                id: 26,
                title: "我好想你",
                artist: "苏打绿",
                lyrics: {
                    easy: "我好想你,好想你,却不露痕迹",
                    medium: "开了灯,眼前的模样,偌大的房,寂寞的床",
                    hard: "我还踮着脚思念,我还任记忆盘旋,我还闭着眼流泪,我还装作无所谓"
                }
            },
            {
                id: 27,
                title: "遇见",
                artist: "孙燕姿",
                lyrics: {
                    easy: "我遇见谁,会有怎样的对白,我等的人,他在多远的未来",
                    medium: "听见,冬天的离开,我在某年某月,醒过来",
                    hard: "我往前,飞过一片时间海,我们也常在爱情里受伤害"
                }
            },
            {
                id: 28,
                title: "开始懂了",
                artist: "孙燕姿",
                lyrics: {
                    easy: "开始懂了,快乐是选择",
                    medium: "相信你只是怕伤害我,不是骗我,很爱过谁会舍得",
                    hard: "我的眼泪你的战利品,我的爱情你的纪念品"
                }
            },
            {
                id: 29,
                title: "红豆",
                artist: "王菲",
                lyrics: {
                    easy: "有时候,有时候,我会相信一切有尽头,相聚离开,都有时候",
                    medium: "还没好好的感受,雪花绽放的气候,我们一起颤抖,会更明白,什么是温柔",
                    hard: "可能在我左右,你才追求,孤独的自由,陪你把想念的酸拥抱成温暖"
                }
            },
            {
                id: 30,
                title: "听海",
                artist: "张惠妹",
                lyrics: {
                    easy: "听海哭的声音,叹惜着谁又被伤了心,却还不清醒",
                    medium: "写信告诉我,今天海是什么颜色,灰色的,还是蓝色的",
                    hard: "一定不是我,至少我很冷静,可是泪水,就连泪水,也都不相信"
                }
            }
        ];

        // ========== 游戏逻辑变量 ==========
        let currentSongIndex = 0;
        let currentDifficulty = 'easy';
        let score = 0;
        let highScore = localStorage.getItem('lyrics_guess_high_score') || 0;
        let streak = 0;
        let usedSongs = [];

        // ========== DOM 元素 ==========
        const lyricsDisplay = document.getElementById('lyrics-display');
        const guessInput = document.getElementById('guess-input');
        const submitBtn = document.getElementById('submit-btn');
        const nextBtn = document.getElementById('next-btn');
        const hintBtn = document.getElementById('hint-btn');
        const changeDifficultyBtn = document.getElementById('change-difficulty-btn');
        const feedback = document.getElementById('feedback');
        const difficultyDisplay = document.getElementById('difficulty-display');
        const scoreDisplay = document.getElementById('score-display');
        const highScoreDisplay = document.getElementById('high-score-display');
        const streakDisplay = document.getElementById('streak-display');

        // ========== 难度配置 ==========
        const difficultyConfig = {
            easy: { text: '简单', next: 'medium', points: 10 },
            medium: { text: '中等', next: 'hard', points: 20 },
            hard: { text: '困难', next: 'easy', points: 30 }
        };

        // ========== 初始化游戏 ==========
        function initGame() {
            highScoreDisplay.textContent = '最高分:' + highScore;
            startNewRound();
        }

        // ========== 开始新的一轮 ==========
        function startNewRound() {
            // 如果所有歌曲都用过了,重新随机
            if (usedSongs.length >= songLibrary.length) {
                usedSongs = [];
            }

            // 随机选择一首没用过的歌曲
            let randomIndex;
            do {
                randomIndex = Math.floor(Math.random() * songLibrary.length);
            } while (usedSongs.includes(randomIndex) && usedSongs.length < songLibrary.length);

            currentSongIndex = randomIndex;
            usedSongs.push(randomIndex);

            // 显示歌词
            const song = songLibrary[currentSongIndex];
            lyricsDisplay.innerHTML = '<p>' + song.lyrics[currentDifficulty] + '</p>';

            // 重置输入和反馈
            guessInput.value = '';
            feedback.className = '';
            feedback.textContent = '';

            // 更新显示
            difficultyDisplay.textContent = '难度:' + difficultyConfig[currentDifficulty].text;
            scoreDisplay.textContent = '得分:' + score;
            streakDisplay.textContent = '连击:' + streak;
        }

        // ========== 提交答案 ==========
        function submitAnswer() {
            const userGuess = guessInput.value.trim();
            if (!userGuess) return;

            const song = songLibrary[currentSongIndex];
            const isCorrect = userGuess.toLowerCase() === song.title.toLowerCase() ||
                              userGuess.includes(song.title) ||
                              song.title.includes(userGuess);

            if (isCorrect) {
                handleCorrectAnswer();
            } else {
                handleIncorrectAnswer();
            }
        }

        // ========== 处理正确答案 ==========
        function handleCorrectAnswer() {
            const points = difficultyConfig[currentDifficulty].points;
            score += points;
            streak++;

            // 更新最高分
            if (score > highScore) {
                highScore = score;
                localStorage.setItem('lyrics_guess_high_score', highScore);
                highScoreDisplay.textContent = '最高分:' + highScore;
            }

            // 显示正确反馈
            feedback.className = 'show correct';
            feedback.innerHTML = '<p>回答正确!</p><p>歌曲:' + songLibrary[currentSongIndex].title + ' - ' + songLibrary[currentSongIndex].artist + '</p><p>+' + points + ' 分</p>';

            scoreDisplay.textContent = '得分:' + score;
            streakDisplay.textContent = '连击:' + streak;
        }

        // ========== 处理错误答案 ==========
        function handleIncorrectAnswer() {
            streak = 0;
            feedback.className = 'show incorrect';
            feedback.innerHTML = '<p>回答错误!</p><p>正确答案是:' + songLibrary[currentSongIndex].title + '</p>';
            streakDisplay.textContent = '连击:' + streak;
        }

        // ========== 切换难度 ==========
        function changeDifficulty() {
            currentDifficulty = difficultyConfig[currentDifficulty].next;
            difficultyDisplay.textContent = '难度:' + difficultyConfig[currentDifficulty].text;
            startNewRound();
        }

        // ========== 提示功能 ==========
        function showHint() {
            const song = songLibrary[currentSongIndex];
            let hintText = '歌手:' + song.artist;
            
            // 显示歌名首字
            if (song.title.length > 0) {
                hintText += ' | 歌名首字:' + song.title.charAt(0);
            }
            
            feedback.className = 'show correct';
            feedback.innerHTML = '<p>提示信息</p><p>' + hintText + '</p>';
        }

        // ========== 事件监听 ==========
        submitBtn.addEventListener('click', submitAnswer);
        guessInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                submitAnswer();
            }
        });
        nextBtn.addEventListener('click', startNewRound);
        hintBtn.addEventListener('click', showHint);
        changeDifficultyBtn.addEventListener('click', changeDifficulty);

        // ========== 启动游戏 ==========
        initGame();
    </script>
</body>
</html>

 一、缘起:为什么做了这个项目

 1.1 一个偶然的想法

听歌猜歌名是很多人休闲时喜欢的小游戏。聚会的时候,有人哼一段歌词,其他人抢答歌名;短视频里也经常有歌词猜歌挑战。这类游戏门槛很低,不需要专业乐理知识,只要听过这首歌就能参与。猜歌游戏不只是简单的问答,它承载着听歌时的回忆,考验听歌积累与反应速度。

如今大语言模型(LLM)理解文本的能力很强,可以识别歌词、介绍歌曲信息,那让 AI 来当猜歌游戏的出题人兼裁判,会是什么体验?某天这个想法冒了出来:不再只是让 AI 单纯介绍歌曲,而是构建一套带回合机制、有答题对错判定的互动猜歌小游戏。于是「歌词猜歌名 AI 对话」项目就此诞生。

 1.2 项目定位与目标

项目启动之前,我为它定下了几个明确的目标:
玩法闭环:AI 给出一段歌词 → 用户猜测歌名 → AI 校验答案 → AI 出下一题,形成完整回合游戏循环;
规则可解释:AI 不仅要判断对错,还要清晰告诉玩家猜对 / 猜错的原因,猜错时给出提示;
结果结构化:AI 回复不能是杂乱自由文本,输出结构化数据,方便前端渲染;
纯前端落地:不搭建后端服务,HTML + CSS + JavaScript 三个核心文件,零依赖、无需构建打包;
体验流畅:采用 SSE 流式输出,实现逐字生成效果,给用户实时反馈。

 1.3 技术选型:为什么是纯前端三件套

在各类前端框架遍地开花的今天,我依旧选择最朴素的 HTML5 + CSS3 + JavaScript 组合,理由有三:
第一,零门槛交付。项目不依赖 Node.js 环境、不需要 npm 安装包,无需打包工具,任何设备只要能打开浏览器,就能直接运行,交付成本极低。
第二,教学价值最大化。剥离框架封装,SSE 流式解析、DOM 操作、fetch 请求底层原理全部直观展现。对于初学者,原生 JS 完整实现一遍,远比直接套用框架更容易理解大模型对话应用底层运行逻辑。
第三,部署极简。静态页面托管在任意静态站点服务(GitHub Pages、Gitee Pages、Nginx 等),几乎零运维成本。

选型不是炫技,核心初衷就是:更多人可以运行、看懂、二次修改这个项目。

 二、需求拆解与方案设计

 2.1 游戏规则的定义

做游戏项目,第一步就是定义清晰规则,规则模糊,后续开发都会根基不稳。我为歌词猜歌名设计了下面这套游戏规则:
出题规则:AI 每次抽取一段歌曲原文歌词,隐藏歌名,作为题目;歌词片段不宜过长也不宜太短,优先选择传唱度高的流行歌曲;
答案判定:玩家输入歌名,AI 判断是否匹配;支持别名,允许少量错别字容错;
提示规则:玩家答不出时,可以输入「提示」,AI 给出线索,例如歌手名、歌曲发行年份;
去重约束:本局游戏内,AI 不能重复出同一首歌曲的题目;
指令支持:玩家输入 “开始游戏”“重新开局”“下一题”“提示”,触发对应游戏逻辑。

这几条规则看着简单,却是系统提示词和前端逻辑判断的基础。规则描述越细致,AI 运行稳定性越强。

 2.2 JSON 数据契约设计

项目核心设计点之一,就是定义大模型与前端之间的数据契约。猜歌游戏的一次 AI 回复同时携带多种信息:校验结果、提示文案、本轮歌词题目、正确歌名、歌手信息、额外提示。如果让 AI 自由输出自然文本,前端再去解析提取内容,代码脆弱,极易出错。

解决方案:强制 AI 输出合法 JSON 对象,契约定义如下:

```
{
  "valid": true,
  "message": "回答正确!太棒啦",
  "lyric": "还记得你说家是唯一的城堡,随着稻香河流继续奔跑",
  "song_name": "稻香",
  "singer": "周杰伦",
  "hint": "",
  "isNewQuestion": true
}
```

字段设计遵循职责单一原则:
valid:布尔值,前端根据这个值渲染成功 / 失败样式;
message:展示给玩家的提示文本,答对祝贺,答错说明理由;
lyric:本轮展示给玩家的歌词片段,猜题核心内容;
song_name:歌曲正确名称;
singer:歌曲歌手;
hint:提示内容,玩家请求提示时填入,无提示为空字符串;
isNewQuestion:布尔标记,代表是否开启一道全新题目。

当 valid 为 false(答错)时,`isNewQuestion` 默认为 false,不会刷新歌词题目;玩家请求提示时 valid 保持原有状态,只填充 hint 字段。前端依靠这些字段,决定是否刷新题目卡片。契约先行,是提升开发效率最关键的一步。

 2.3 系统提示词工程

数据契约确定之后,核心难点就是让大模型稳定按照契约格式输出。这就是系统提示词(System Prompt)工程发挥作用的地方。

我将系统提示词拆成三层结构:
角色与职责:开篇设定:你是歌词猜歌名小游戏的出题人兼裁判,使用简体中文,严格执行猜歌游戏规则;
规则枚举:逐条列出 2.1 中的全部游戏规则,重点标注:不能重复出题、歌词片段选择要求、答案容错、提示规则;
输出约束:最重要部分。强制**只能输出一段合法 JSON,禁止额外文字、注释、markdown 代码块标记**,逐字段解释含义,附带成功示例、失败示例。

实践发现:提示词内带上完整示例,模型输出稳定性提升非常明显。写入成功 JSON 样例和失败 JSON 样例之后,模型绝大多数情况都可以产出前端可直接解析的 JSON。

踩坑记录:早期版本我要求模型输出 ```json 包裹的代码块,流式解析时会连带 markdown 标记一起返回,JSON 解析直接报错。修改提示词,明确禁止 markdown 标记后,问题彻底解决。

 三、Python 参考实现与 JavaScript 改写

 3.1 原始参考代码分析

项目参考 Python 调用大模型接口示例代码,核心逻辑:通过 requests.post 携带 Authorization 请求头发起流式请求,逐行解析 SSE(Server-Sent Events)返回数据。

```
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
    if not line.startswith(b"data:"):
        continue
    if line.strip() == b"data:[DONE]" or line.strip() == b"data: [DONE]":
        return
    yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))
```

这段代码体现 SSE 协议核心特点:
数据使用 `data:` 前缀逐段推送;
每一行是独立 JSON 数据块(chunk);
流结束标识为 `data: [DONE]`;
每个 chunk 结构:`{"choices":[{"delta":{"content":"...","reasoning_content":"..."}}]}`。

理解 SSE 协议原理,才能够把代码迁移到其他编程语言。

3.2 JavaScript 改写的关键差异

不能直接把 Python 代码逐行翻译成浏览器 JS,浏览器环境和后端 Python 环境存在三处本质差异:
区别一:网络 API 不同。Python 依靠 requests 库做同步请求;浏览器端使用异步 fetch API。`requests.iter_lines()` 对应 `response.body.getReader()`;Python yield 生成器,在 JS 中依靠 while 循环 + await reader.read () 实现异步迭代读取流。

区别二:CORS 跨域限制。浏览器同源策略,跨域请求会触发 OPTIONS 预检请求。需要确认大模型 API 配置了正确的 Access-Control-Allow-Origin,并且请求头白名单包含 Authorization、Content-Type,否则浏览器直接拦截请求,联调阶段优先验证跨域。

区别三:字节流与字符串处理。Python 中处理字节流,使用`decode("utf-8")`;浏览器端使用`TextDecoder("utf-8")`解码。流式读取会遇到**粘包问题**:单次读取拿到的数据可能只有半行,需要维护缓冲区,按换行符分割完整行,剩余字符缓存,留给下一轮解析。

改写后的核心循环示意代码:

```
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  // 按换行分割完整行,过滤非data:开头数据
}
```

处理完粘包、半行、空行、[DONE] 结束标记等边界场景,前端打字机流式效果就实现了。

四、前端界面设计与实现

4.1 视觉风格的确定

猜歌项目自带音乐氛围感,视觉风格放弃冰冷科技感,选择柔和音乐主题:
主色:#3A4F70,静谧蓝,呼应音乐安静沉浸的氛围;
强调色:#E68A4E,暖橙色,突出歌词卡片、提示信息;
背景:柔和渐变 #f7f9fc → #eef2f8,阅读舒适;
消息气泡区分:AI 卡片浅蓝底色,用户消息暖灰底色,一眼区分人机。

整体采用「卡片 + 圆角 + 轻阴影」现代简约风格,把歌词作为核心视觉重点,营造听歌猜谜的轻松氛围。

4.2 布局与交互

页面采用经典的三段式纵向布局:
头部:标题「歌词猜歌名 AI 对话」+ 副标题 “根据歌词,猜猜是什么歌”;
消息区:可滚动对话流,AI 消息居左、用户消息居右,头像使用文字(“AI”/“我”),规避不同设备 emoji 渲染乱码;
输入区:文本框 + 发送按钮,支持回车发送,支持输入关键词:提示、下一题、重新开始。

核心设计细节是歌词卡片:AI 返回的歌词单独渲染卡片,左侧橙色装饰线条,依次展示歌词片段、提示信息,答对后自动展开歌曲名称与歌手信息。信息层级清晰,玩家第一眼聚焦歌词内容。

4.3 思考状态的呈现

接口返回的数据流中,delta 同时携带 reasoning_content(模型思考过程)和 content(最终回答)两类内容。如果都展示给用户,体验会很拖沓。我的处理策略是:
收到 reasoning_content 时,展示 "AI 正在构思歌词..." 动态提示(带闪烁光标动画);
一旦开始收到 content,立即切换为逐字渲染最终回答,思考提示自动消失。

这样既保留了流式输出的实时感,又不让思考过程干扰阅读。细节虽小,但对体验的提升非常明显。

五、端到端测试与踩坑记录

5.1 用 Playwright 做真实浏览器测试

项目完成后,不能只停留在 "代码能跑" 的层面。我引入了 Playwright 做端到端验证,脚本在本地起一个静态服务器,用无头 Chromium 真实模拟用户操作:
打开页面,等待页面加载完成;
输入 “开始游戏”,点击发送;
等待 AI 的歌词卡片渲染出来,截图存档;
输入歌曲名字进行作答,验证答对 / 答错两条分支;
抓取页面控制台的所有错误日志,作为回归依据。

测试脚本断言两条核心路径:歌词卡片正常渲染、答题对错逻辑正常,同时监控 console 错误。真机验证的价值在于,它能暴露纯静态分析看不见的问题 —— 比如某个元素没渲染、某个异步回调没触发、某个网络请求被 CORS 拦截。

5.2 踩过的四个坑

整个开发过程中踩坑不少,挑四个最有代表性的记录下来:
坑一:流式数据粘包。第一次实现时按行解析,发现偶发的 JSON 解析失败。排查后发现浏览器分块返回数据时,一个 chunk 里可能包含多行甚至半行,必须维护缓冲区按 \n 切分。这属于 SSE 解析的经典问题。
坑二:markdown 代码块污染。提示词早期要求 "输出 JSON 代码块",结果模型真的在开头结尾加了 `json 和 `,流式拼接后 JSON.parse 必挂。解决方案是提示词里明确 "禁止任何 markdown 标记",双管齐下:前端解析失败时也做了兜底提示,不让用户面对裸奔的报错。
坑三:favicon 404。测试时控制台报了一个 404,排查发现是浏览器自动请求 favicon.ico 导致的。用内联 SVG data URL 声明了图标后彻底解决。虽然不起眼,但既然是 "零报错交付",这些细节也应该处理掉。
坑四:答案匹配问题。早期模型判定答案过于严格,用户输入简称、少量错别字直接判定错误。优化提示词,增加 “别名容错” 规则,解决这个问题。

5.3 测试结果

最终端到端测试全部通过:答题、获取提示、下一题、重新开局链路正常、无控制台错误、无视觉布局问题。截图复核显示:文字清晰无乱码、卡片排版美观、信息层级分明。测试不只是 "验收",更是项目质量的一道保险。

六、部署、README 与仓库管理

6.1 部署方式

由于是纯静态项目,部署异常简单:python3 -m http.server 8000 即可本地运行,线上则可直接托管到任意静态站点服务。唯一需要注意的是,API Key 直接写在前端代码中属于公开信息,生产环境如需保密,应当增加一层后端代理转发请求,由代理持有 Key 并转发到上游大模型接口。

6.2 代码仓库规范化

项目代码写完后,我没有急着丢进仓库,而是做了三步规范化:
编写 .gitignore,排除工具目录与日志文件,保持仓库干净;
编写 README.md,包含项目简介、功能特性、技术栈、目录结构、快速开始、API 配置、JSON 契约说明、游戏规则共八个板块,让任何首次接触项目的人都能在五分钟内跑起来;
规范的提交信息,例如 初始化:歌词猜歌名 AI 对话页面(HTML5+CSS3+JS,SSE 流式输出 JSON),让历史可追溯。

README 的价值常常被低估:它既是给用户的说明书,也是给未来的自己的备忘录。写 README 的时候,正是重新审视整个项目的绝佳时机 —— 能不能让别人不看代码就理解你的设计?这一问,往往会倒逼出更清晰的项目表达。

七、项目成果展示

经过完整的开发、联调与测试流程,最终交付的项目包含四个文件:

```
├── index.html   # 页面结构
├── style.css    # 样式(音乐柔和蓝橙主题)
├── main.js      # 逻辑:SSE 流式请求 + JSON 渲染
└── README.md    # 项目说明文档
```

项目已推送到 AtomGit 平台,main 分支包含完整的可运行代码。整个项目的核心价值可以总结为三点:
一个可直接游玩的猜歌互动游戏:AI 给出歌词片段,玩家猜歌名,支持提示、下一题;
一份可复用的 SSE 流式解析范式:任何大模型对话应用都可以移植这段解析逻辑;
一套系统提示词工程的实践范本:如何通过 "角色 + 规则 + 输出约束 + 示例" 四件套稳定产出结构化 JSON。

七点五、常见问题 FAQ

在开发与分享的过程中,不少朋友问过类似的问题,这里集中整理回答:
Q1:为什么不用 WebSocket 而用 SSE?
大模型对话是典型的 "单向流" 场景 —— 客户端发一次请求,服务端持续推送。SSE(Server-Sent Events)基于 HTTP,天然支持这种模式,且实现简单、天然兼容断线重连。WebSocket 是双向全双工协议,功能更强,但对这个场景是 "杀鸡用牛刀",还要多维护一套连接状态。选型的原则永远是 "够用就好"。

Q2:流式输出和一次性输出有什么区别?
一次性输出要等模型生成完毕才返回,长回答往往要等十几秒才能看到第一个字,体验很差。流式输出边生成边推送,用户几乎零等待就能看到文字逐字蹦出来。这里的 "打字机效果" 不仅是视觉呈现问题,更是对注意力经济的尊重 —— 人在等待超过两秒就会产生焦躁情绪。

Q3:系统提示词里的 JSON 要求会不会被模型无视?
有可能,但不是无限度。增强约束效力的手段有:角色锚定、规则编号、显式示例、禁止性措辞(“禁止输出任何其他文字”),以及前端解析失败时的兜底提示。多重保险叠加后,模型名义上的 "自由度" 已经被压缩到了极小空间。实测几十轮游戏中,JSON 解析失败率趋近于零。

Q4:答案容错会不会导致 AI 误判?
容错是做了限制,只支持常见别名、轻微错别字,不支持完全无关的歌名。如果规则设置成完全严格匹配,用户输入 “七里香” 写成 “七里乡” 就判错,游玩体验会很差。在提示词中限定容错边界,平衡趣味性和准确性。

Q5:接下来最想加什么功能?
我最想做的是难度分级:简单模式歌词片段辨识度高;困难模式选取冷门歌曲歌词。另外增加计分系统,统计答对题目数量。如果接入后端,可以做游戏记录、排行榜,保存玩家的闯关记录。

八、复盘与展望

8.1 项目总结

复盘整个项目,我认为最值得借鉴的三条经验是:
第一,契约先行。 先定义清楚前端与大模型之间的数据格式,再动手写代码,开发效率至少提升一倍。AI 应用的难点之一就是 "模型输出不可控",而一份明确的输出契约加严谨的提示词,能把不可控性降到最低。
第二,规则即提示词。 游戏规则写得越明确,AI 的表现越稳定。把 "重复出题、答案容错、提示机制" 这些特例全部显式写进系统提示词,比让模型自己去 "领悟" 要可靠得多。
第三,验证靠真机。 静态检查替代不了真实浏览器测试。流式粘包、CORS、DOM 渲染这类问题,只有在真实环境里才会现形。

8.2 后续演进方向

项目目前是 MVP 版本,后续至少有三个明确的演进方向:
难度分级:区分简单 / 困难模式,简单使用热门歌曲,困难模式选用小众歌曲;
积分统计:记录答对题数、剩余答题机会,增加游戏竞技感;
歌曲拓展:答对歌曲之后,展示歌曲简介、创作背景,增加知识拓展内容。

如果未来接入后端,还可以做用户战绩持久化、猜歌排行榜等社区化功能。

8.3 一点感言

写代码的乐趣,在于把一个模糊的想法变成可以运行的现实。从 "让 AI 和我玩歌词猜歌名" 这个念头,到四个人人都能看懂的静态文件,再到浏览器里流畅的逐字猜歌对话 —— 整个过程有设计的兴奋,有踩坑的抓狂,更有跑通那一刻的成就感。

猜歌游戏是文字和旋律的碰撞,做项目其实也是。好的需求是前奏,扎实的实现是副歌,不断迭代打磨细节,才会有完整作品。这也是我把这篇文章放在「码道」下记录的原因 —— 技术之路,本就是这样一环扣一环地走下去。

回看整个开发过程,我越发觉得所谓的 "AI 应用开发",本质上是人机协作的接口设计:你要像对待一个聪明但需要明确指令的伙伴一样,把规则讲清楚、把边界划明白、把输出约束好,剩下的就交给它发挥。而作为开发者,我们要把注意力放在最容易出彩也最容易被忽视的地方 —— 体验的细节:流式的节奏感、思考状态的提示、卡片的层级、失败时的兜底文案。这些细节叠加在一起,才构成一个 "好用" 的产品的全部。

最后想对读到这里的读者说:如果你也有一个 "让 AI 做点什么有趣的事" 的念头,不要犹豫,把它变成一个小项目吧。不必追求宏大,一个歌词猜歌名、一个成语接龙、一个读书笔记助手,都足够让你跑通 "从想法到产品" 的完整链路。这条路上一旦启程,你会发现自己停不下来 —— 因为每完成一题,下一段歌词已经准备就绪。

愿每一个读到这里的开发者,都能在自己的项目里,找到属于自己的那首歌。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐