Polly Chat v2.5:从欢迎屏到 IndexedDB

Polly Chat v2.5:从欢迎屏到 IndexedDB

周六下午打开 polly.wang,首页聊天区就一个光秃秃的输入框,连句问候都没有。对面如果是个新访客,大概率直接划走——"这啥?一个空白对话框?"

到晚上 11 点,同样的首页已经变成了这样:时段问候语、5 个快捷入口、图片能跨刷新保留、移动端底部再也没有那块奇怪的留白。

这篇记录一天之内的 8 项改动。不是什么大工程,但密度够高,过程也够有趣——特别是小龙虾做 Code Review 的那段。

系列回顾:Polly Chat 2.0 重构 → Vision 多模态 → D1 持久化 (小龙虾主笔) → 本篇 v2.5

版本线

先放个全景图,方便定位今天的改动在整条线上的位置:

版本日期标志改动博文
2.02/13去后端重构,SSE 直连 CopilotXPolly Chat 2.0
2.12/13Vision 多模态看图说话
2.22/14Cloudflare D1 云端持久化小龙虾情人节实录
2.32/22多图粘贴 + Blob URL 内存泄漏修复—
2.42/22prompt v4 大改版(小龙虾 4 轮 Review)—
2.52/22欢迎屏 + IndexedDB + 移动端修复本篇

v2.3 → v2.5 的三个版本全挤在同一天,两次 commit,+468 行 / -111 行,涉及 8 个文件。下面按改动类型逐个聊。


一、欢迎屏:5 个 Chip,3 种类型

光秃秃的输入框最大的问题不是丑,是没给用户一个开口的理由。

解决方案:在输入框上方加一个欢迎屏幕(welcome screen),包含问候语和 5 个快捷入口(chips)。当用户点击任意一个 chip 或直接打字发消息后,欢迎屏自动消失,聊天界面展开。

模板层

<div id="welcome-screen" class="welcome-screen">
    <h2>和 Polly 聊聊</h2>
    <p class="welcome-subtitle" id="welcome-subtitle">博客主人的数字分身,随时在线 ☕</p>
    <div class="welcome-chips" id="welcome-chips">
        <button class="chip" data-msg="你是谁?">👋 你是谁</button>
        <button class="chip" data-msg="最近在忙什么?">🔥 最近在忙什么</button>
        <button class="chip chip-image" data-action="upload-image">📷 发张图试试</button>
        <button class="chip chip-dynamic" data-msg="聊聊 AI">🔬 聊聊 AI</button>
        <button class="chip" data-msg="最近怎么样?随便聊聊">💬 随便聊聊</button>
    </div>
</div>

5 个 chip 分三种类型:

类型示例行为
静态👋 你是谁 / 🔥 最近在忙什么 / 💬 随便聊聊点击直接发送 data-msg
功能型📷 发张图试试弹出文件选择器(data-action="upload-image")
动态🔬 聊聊 AI初始文案,运行时从 prompt 提取 🟢 项目名替换

JS 事件绑定

三种类型统一绑定,用 data-action 和 data-msg 区分行为:

document.querySelectorAll('.welcome-chips .chip').forEach(chip => {
    chip.addEventListener('click', () => {
        // 功能型:弹文件选择器
        if (chip.dataset.action === 'upload-image') {
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = 'image/*';
            fileInput.onchange = (e) => {
                const file = e.target.files[0];
                if (file) { this.processImage(file); this.input.focus(); }
            };
            fileInput.click();
            return;
        }
        // 静态/动态型:发送消息
        const msg = chip.dataset.msg;
        if (msg) { this.input.value = msg; this.send(); }
    });
});

动态 Chip:从 prompt 里挖项目名

setupWelcome() 在初始化时根据当前小时数设置副标题("早起的程序员,难得 ☀️" / "夜猫子模式,正在线 🌙"),然后调用 updateDynamicChips() 从 system prompt 中正则匹配第一个 🟢 在研项目名称:

updateDynamicChips() {
    if (!this.systemPrompt) return;
    const dynamicChip = document.querySelector('.chip-dynamic');
    if (!dynamicChip) return;
    const projectMatch = this.systemPrompt.match(/\*\*(.+?)\*\*\s*\(🟢\)/);
    if (projectMatch) {
        const name = projectMatch[1];
        dynamicChip.textContent = `🔬 ${name.slice(0, 10)}`;
        dynamicChip.dataset.msg = `${name} 是什么?跟我讲讲`;
    }
}

这让 chip 内容随 prompt 自动更新——每次 build_prompt.py 重新生成 prompt 后,首页的 chip 也会跟着变。

显隐逻辑

纯 CSS 控制,零 JS:

.polly-chat.expanded .welcome-screen { display: none; }

.expanded 由 JS 在发送消息时添加到 .polly-chat 容器上,欢迎屏就自动消失了。点 New Chat 时移除 .expanded,欢迎屏又回来。


二、小龙虾 Prompt Code Review:4 轮迭代

这是今天最有趣的部分。

prompt v3 是我自己写的,像一份产品说明书:规则 1、规则 2、规则 3……十几条硬性约束。能用,但总觉得对话偏"客服味"。

于是把 prompt 丢给小龙虾做 Code Review。4 轮迭代下来,核心改动:

规则约束 → 人格示范

Before(v3):

"回复时先共情,再回答问题,最后用反问句结尾。"

After(v4):

"我说话的方式:直接但不生硬,偶尔带点俏皮,技术聊深了会兴奋地越说越快。"

从"你应该怎么做"变成"我是什么样的人"。LLM 模仿人格描述比遵守规则列表自然得多。

反面示范(Anti-patterns)

小龙虾加了 4 条明确的"不要这样说":

  1. ❌ 客服腔:"非常感谢您的提问!这是一个很好的问题。"
  2. ❌ 面试腔:"我在这个领域有丰富的经验..."
  3. ❌ 共情模板:"我完全理解你的感受。"
  4. ❌ 报菜名:"我目前正在做 A、B、C、D、E 五个项目。"

这组反面示范效果极好。之前数字 Polly 最爱说"这是个很好的问题",加了 anti-pattern 之后再也没出现过。

其他改动

  • 语言镜像:从"建议"升级为"铁律"——用户说中文就回中文,说英文就回英文,绝不切换
  • 时间感知:JS 端 getTimeContext() 注入时段(凌晨/早晨/上午/下午/晚上),prompt 里用时段做场景化回应
  • 硬性规则精简:7 → 6 条,去掉"每 3 句至少一个反问"这种无法稳定执行的约束
  • milestone 去重:build_prompt.py 做 year-prefix 匹配,避免里程碑和近期动态重复

效果测试

用 test_prompt.py CLI 工具跑了 4 个 chip 的 preset:

Chip评分亮点
👋 你是谁A简洁自然,带反问
🔥 最近在忙什么A列了 3 个项目 + 反问
🔬 聊聊 AIA从个人经历切入而非泛泛而谈
💬 随便聊聊A日常语气,还提到了小宝(我家猫)

4 轮 Review 后的 prompt v4,每个 chip 都能产生自然、有个性的回复,没有客服腔。小龙虾的 Code Review 价值超出预期。


三、CSS 根因分析:移动端底部的神秘留白

这个 bug 表现很简单:移动端展开聊天后,底部有一大块空白,输入框悬在半空中。

排查过程

第一反应是 margin 或 padding 多了,改了几轮数值都不对。直到查看 #chat-box 的 computed styles,发现了真凶:

/* 移动端媒体查询里的旧代码 */
#chat-box {
    max-height: calc(100vh - 200px);
}

max-height 把聊天区域硬性截断了。即使 flex: 1 想让 #chat-box 填满剩余空间,max-height 的优先级更高——flex 算出来的高度如果超过 max-height,就会被截掉,于是底部出现空白。

修复

@media (max-width: 768px) {
    #chat-box {
        max-height: calc(100vh - 120px); /* 降低非展开态限制 */
    }
    #chat-box.expanded {
        max-height: none; /* 展开后由 flex:1 控制 */
    }
    .polly-chat.expanded .input-container {
        margin-bottom: 24px; /* 给底部一点呼吸空间 */
    }
}

关键一行:max-height: none。展开后取消高度限制,让 flexbox 自由布局。非展开态保留 calc(100vh - 120px) 作为安全网(从 200px 缩减到 120px,进一步减少空白)。

教训

max-height 和 flex: 1 一起用时要极度小心。 flex 计算的是"理想高度",但 max-height 会强制截断。两者冲突时,max-height 赢。


四、IndexedDB 迁移:告别 localStorage 的 5MB 天花板

v2.1 加了 Vision 功能后,用户可以发图片聊天。图片以 base64 存在 messages 数组里,一张图就几百 KB。localStorage 最多 5MB(还得跟其他数据共享),存几张图就爆了。

之前的权宜之计是存 localStorage 时剥掉图片——但这意味着刷新页面后图片消失。

方案选择

方案优点缺点
localStorage + 压缩改动小治标不治本,5MB 还是会爆
IndexedDB容量几乎无限,原生异步API 比 localStorage 复杂
Cache API适合缓存请求语义不匹配

小龙虾的建议是方案 B——IndexedDB。虽然 API 啰嗦了点,但封装一下就好。

实现

4 个底层方法,总共 30 行:

// DB: polly_chat, Store: history
_openDB() {
    return new Promise((resolve, reject) => {
        const req = indexedDB.open(this.DB_NAME, 1);
        req.onupgradeneeded = () => req.result.createObjectStore(this.STORE_NAME);
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });
}

async _idbGet(key) { /* readonly transaction → get(key) */ }
async _idbPut(key, value) { /* readwrite transaction → put(value, key) */ }
async _idbDelete(key) { /* readwrite transaction → delete(key) */ }

上层 saveHistory() 和 restoreHistory() 改成 async,内部把 localStorage.setItem / getItem 替换成 _idbPut / _idbGet。

自动迁移

考虑到老用户浏览器里可能还存着 localStorage 数据,加了迁移逻辑:

async restoreHistory() {
    // 检测旧 localStorage 数据
    const legacy = localStorage.getItem(this.STORAGE_KEY);
    if (legacy) {
        const legacyData = JSON.parse(legacy);
        if (legacyData.messages?.length > 0) {
            await this._idbPut('current', legacyData);
        }
        localStorage.removeItem(this.STORAGE_KEY); // 迁移后清除
        console.log('📦 Migrated localStorage → IndexedDB');
    }
    // 从 IndexedDB 读取
    const data = await this._idbGet('current');
    // ...恢复消息
}

迁移是一次性的:读 localStorage → 写 IndexedDB → 删 localStorage。下次刷新就直接走 IndexedDB 了。

效果

  • ✅ 图片聊天跨刷新不丢失(之前丢)
  • ✅ 存储容量从 5MB → 几乎无限
  • ✅ 老用户无感迁移
  • ✅ newChat() 同时清除 IndexedDB 和 legacy localStorage

五、三个小修复

1. thinking fadeOut 并行化

旧逻辑:thinking 动画播完 → 删除 DOM → 渲染回复文本。问题是 fadeOut 动画有 300ms,这段时间用户盯着空白等。

新逻辑:fadeOut 和文本渲染并行。渲染第一个 token 的同时 thinking 在 fade,视觉上无缝衔接。

2. DOMPurify XSS 防护

聊天回复用 marked.js 渲染 Markdown,理论上有 XSS 风险。加了 DOMPurify v3 做消毒:

<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>

渲染链变成:markdown → marked.parse() → DOMPurify.sanitize() → innerHTML。

3. Blob URL 内存泄漏修复

v2.3 支持多图粘贴后,每次粘贴会 URL.createObjectURL() 生成预览 URL。如果用户连续粘贴多次,旧的 Blob URL 没有释放。修复:在创建新 URL 前 revokeObjectURL() 旧的。


六、test_prompt.py:终端里的 QA 环境

改完 prompt 之后,之前的测试方式是:开浏览器 → 打开 polly.wang → 手动输入 → 看回复。来回切换很低效。

于是写了个 CLI 测试工具 test_prompt.py,不提交(gitignore),纯本地使用:

# 单条测试
python scripts/test_prompt.py "你是谁"

# 交互模式
python scripts/test_prompt.py
> 最近在忙什么
> /new              # 重置对话
> /quit             # 退出

# 5 个 preset
python scripts/test_prompt.py --preset 1  # 你是谁?
python scripts/test_prompt.py --preset 2  # 最近在忙什么?
python scripts/test_prompt.py --preset 3  # 聊聊 AI
python scripts/test_prompt.py --preset 4  # 随便聊聊
python scripts/test_prompt.py --preset 5  # English test

工具直接读 polly-prompt.json,复刻 getTimeContext() 的时段注入逻辑,调用 api.polly.wang/v1/messages(和线上完全一致的 API)。每次改完 prompt 后终端里跑一轮 preset,30 秒就能验证效果。


成果汇总

代码量

文件行数主要改动
polly-chat.js751IndexedDB + 欢迎屏 + chip 事件
style_new.css1593欢迎屏样式 + 移动端修复
build_prompt.py689Layer 3 博文动态 + milestone 去重
index.html54欢迎屏 HTML + DOMPurify
polly-prompt.json—v2026.02.22
合计3087—

提交记录

3b49044  feat(chat): 小龙虾 v4 prompt 大改版 + 多图聊天支持
         3 files, +137 / -31

3a74c17  feat(chat): 欢迎屏 + IndexedDB 持久化 + 移动端修复 + prompt 更新
         5 files, +331 / -80

反思

小龙虾的 Code Review 比我预想的有用得多。 我自己写 prompt 容易陷入"规则思维"——觉得加一条规则就能解决一个问题。小龙虾的方式完全不同:给人格描述,给反面示范,让 LLM 自己推断该怎么说。效果好很多。

IndexedDB 早就该迁移了。 从 v2.1 加 Vision 的那天起就知道 localStorage 不够用,但一直用"剥掉图片"的 workaround 凑合。拖了 9 天才做正确的事。好在迁移本身不复杂,30 行封装 + 自动迁移逻辑就搞定了。

CSS 的 max-height 坑值得记住。 不是第一次在 flex 布局里被 max-height 坑了。写个备忘录贴在显示器上:flex + max-height = 小心。

下一步大概是给欢迎屏加个头像,然后把动态 chip 做得更智能——不只是从 prompt 里正则匹配项目名,而是根据最近博文主题动态推荐话题。


版本时间线

版本日期文章
2.02/13Polly Chat 2.0: From Clunky Backend to Zero-Backend
2.12/13给博客聊天加上「看图说话」功能
2.22/14给 Polly 装上记忆:一只虾的情人节开发实录
2.52/22本篇

Comments