索菲亚的车友聚会,几乎都是从同一个场景开始的:一个群聊。
“周日?”“10点前不行。”“我开E92来。”“还像上次那样停Ring Mall?”“先喝咖啡还是直接停车聊?”四十条消息刷过去,聚会理论上算是定下来了,但没人能说清楚到底几点、在哪、谁会来——除非你往上翻,把整段聊天从头读一遍。
开发者GeorgievAlex就住在索菲亚。他上一个DEV挑战赛的作品,是给朋友的宝马E90做的维修助手,拿了DigitalOcean的最佳应用奖。而这一次,他想解决的是车修好之后的下一步:真的把车开出去,和别人一起跑。
把群聊粘进去,出来一场聚会
于是他做了RevSofia,一个给索菲亚车友用的聚会看板。
看板上的聚会分两类:一类是面向所有人的综合聚会,不管你开什么车;另一类是品牌车友会,覆盖宝马、奔驰,以及奥迪/大众集团——大众、斯柯达、西雅特、Cupra和保时捷都算在内。
每场聚会都带一个“加入日历”按钮,点一下计划就进了手机,人也就该上路了。顶部导航里藏着一个整月日历,每场聚会在对应日期上留一个点,颜色对应所属车友会。
站内没有做聊天功能。聊天发生在他们的Discord里,每个车友会页面都直接链过去,而不是假装要取代它。一场聚会通过审核后,会自动发到Discord,时间按每个读者自己的时区显示。
真正让这套东西跑起来的,是那个规划器。你把群聊粘进去,英文或保加利亚文都行,一个开放权重模型(Gemma 4)会把它变成一份聚会草稿:
- 日期和时间,“周日”会对照索菲亚的今天来解析
- 集合点和目的地
- 谁会来、开什么车
- 它属于哪个车友会,以及判断理由
- 一份“聊天里始终没定下来”的清单
- 一段可以直接粘回Discord的公告
模型弄错的地方你手动改,改完点“提交审核”,它就变成一条GitHub issue。在人工批准之前,什么都不会发布出去。
5.4秒,从一团乱麻到一份草稿
这就是“出门走走”的那部分。屏幕时间大概三十秒,剩下的时间都留给维托沙山。
他放出了一段测试记录。测试用的群聊是他用保加利亚文写的,因为很多这类讨论串实际上就长这样:
Ники问:周日早上谁去维托沙?
Мария回:我!开高尔夫来。
Иво说:我也去,开335。
Ники定:9:30,西梅奥诺夫斯科公路上的加油站。
Мария补:好,之后去兹拉特尼桥喝咖啡。
Стоян说:这周日不行,下次。
在线上站点,这段对话大约5.4秒后返回了结果:
- 开始时间:周日10月11日,09:30(索菲亚时间)
- 集合点:西梅奥诺夫斯科公路上的加油站
- 目的地:兹拉特尼桥
- 参加者:Ники、Мария(高尔夫)、Иво(宝马335i)。Стоян说了来不了,所以他没被列进去
- 车友会:综合,因为是混合品牌
- 仍未确定:几点结束
- 公告:保加利亚文,可直接粘贴
看板上目前挂着的四场聚会都标着“示例聚会”。它们是样本数据,免得站点空着。他不想让一个刚上线的站点假装真有聚会存在。
故意无聊的技术选型
和上一个项目一样,他给自己定了条“故意无聊”的规矩:不用框架,不用数据库,不装npm依赖。
整个链路是这样的:浏览器端是GitHub Pages上的静态站点,配Cloudflare Turnstile;粘贴的聊天内容加一个一次性的“我是人类”令牌,POST到DigitalOcean Function;这个函数持有模型密钥,先拿令牌去Cloudflare核验;核验通过后,请求转发到DigitalOcean Serverless Inference上的gemma-4-31B-it。
聚会数据就是仓库里的JSON文件。一个很小的Node构建脚本负责校验它们,输出站点数据,外加每场聚会一个.ics日历文件。批准一场聚会,字面意义上就是合并一个文件。
那个DigitalOcean Function只干AI这一件事。它存在的意义是让模型密钥永远不碰浏览器。它把聊天内容截在8000字符,从不把上游错误转发给客户端,并且让模型返回的JSON走一遍和构建脚本相同的校验模块。所以模型就算幻觉出一个叫“porsche”的车友会,也会被夹回“综合”,而不是把什么东西弄坏。
Gemma 4跑在DigitalOcean Serverless Inference上。那是个OpenAI兼容的端点,所以没有GPU要伺候。模型只是一行配置。
Logo是手绘SVG。一个转速表指针扫向红线,下面压着维托沙山脊,因为索菲亚的每场聚会最后都会朝那座山开。它用几行三角函数生成,让刻度和弧线落在精确的角度上。
审核通过的聚会会自己发公告。一个GitHub Action盯着data/events/目录。当新的聚会文件落到main分支上,它就通过存在Actions secrets里的webhook,往车友会的Discord发一条embed。提及被关掉了,所以藏在别人聊天文本里的@everyone没法把整个服务器都ping一遍。示例聚会和已经发过的聚会会被跳过。
