周六下午打开 polly.wang,首页聊天区就一个光秃秃的输入框,连句问候都没有。对面如果是个新访客,大概率直接划走——"这啥?一个空白对话框?"
到晚上 11 点,同样的首页已经变成了这样:时段问候语、5 个快捷入口、图片能跨刷新保留、移动端底部再也没有那块奇怪的留白。
这篇记录一天之内的 8 项改动。不是什么大工程,但密度够高,过程也够有趣——特别是小龙虾做 Code Review 的那段。
系列回顾:Polly Chat 2.0 重构 → Vision 多模态 → D1 持久化 (小龙虾主笔) → 本篇 v2.5
版本线
先放个全景图,方便定位今天的改动在整条线上的位置:
| 版本 | 日期 | 标志改动 | 博文 |
|---|---|---|---|
| 2.0 | 2/13 | 去后端重构,SSE 直连 CopilotX | Polly Chat 2.0 |
| 2.1 | 2/13 | Vision 多模态 | 看图说话 |
| 2.2 | 2/14 | Cloudflare D1 云端持久化 | 小龙虾情人节实录 |
| 2.3 | 2/22 | 多图粘贴 + Blob URL 内存泄漏修复 | — |
| 2.4 | 2/22 | prompt v4 大改版(小龙虾 4 轮 Review) | — |
| 2.5 | 2/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 条明确的"不要这样说":
- ❌ 客服腔:"非常感谢您的提问!这是一个很好的问题。"
- ❌ 面试腔:"我在这个领域有丰富的经验..."
- ❌ 共情模板:"我完全理解你的感受。"
- ❌ 报菜名:"我目前正在做 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 个项目 + 反问 |
| 🔬 聊聊 AI | A | 从个人经历切入而非泛泛而谈 |
| 💬 随便聊聊 | 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.js | 751 | IndexedDB + 欢迎屏 + chip 事件 |
| style_new.css | 1593 | 欢迎屏样式 + 移动端修复 |
| build_prompt.py | 689 | Layer 3 博文动态 + milestone 去重 |
| index.html | 54 | 欢迎屏 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.0 2/13 Polly Chat 2.0: From Clunky Backend to Zero-Backend 2.1 2/13 给博客聊天加上「看图说话」功能 2.2 2/14 给 Polly 装上记忆:一只虾的情人节开发实录 2.5 2/22 本篇

