普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月2日首页

微软 AI 诊断准确率超人类医生4倍,以后看病前先问问它?

作者 张子豪
2025年7月2日 19:42

四倍,AI 医生的诊断准确率远超过人类医生。

这可能有点难以置信,但微软人工智能团队日前发布的一项 AI 诊断协调系统 MAI-DxO(MAI Diagnostic Orchestrator)真的做到了。

它在《新英格兰医学杂志》每周发布共计 304 个真实复杂病例上进行了基准测试。测试结果显示,准确率达到了85.5%。

这个基准测试不再是之前光凭借记忆,就可以做到的试卷答题,而是微软创建的全新的评测标准,「顺序诊断基准」(SD Bench)。它高度还原了真实诊疗过程的互动挑战:

  1. 从患者的初步症状描述入手。
  2. 通过多轮提问,选择各种检验检查,逐步手机病情信息。
  3. 每开一项检查,同时记录检查项目的费用;评估必要性和成本。
  4. 给出最终诊断。

同样面对这个 304 个复杂病例,微软选择了另外 21 位来自美国和英国,具有 5 年至 20 年临床经验的执业医生,测试结果显示,真实医生的平均准确率仅为 20%,这与 「AI 医生」的差距足足有四倍之大。

同时,与人类医生相比,这个「AI 医生」还少开了很多不必要的检查,减少了 20%-70% 的诊断成本。

顺序诊断基准测试介绍图,「守门人」回应来自诊断代理的信息请求,评估模型则评估诊断代理的最终诊断与病例报告准确度。

▲顺序诊断基准测试介绍图,「守门人」回应来自诊断代理的信息请求,评估模型则评估诊断代理的最终诊断与病例报告准确度。

MAI-DxO 究竟是如何做到人类医生的准确率四倍之高呢,它不是一个新出现的大语言模型,它也不依赖某个单一的模型。

MAI-DxO 是一个模拟现实中多名医生合作诊断过程的系统。得益于当前大语言模型的持续发展,在 MAI-DxO 系统中,有不同的语言模型去扮演五种不同的医疗角色。

这些医疗角色包括推测各种结果的假设医生、选择医生、质疑当前诊断假设的挑战医生、避免不必要检查的成本管理医生、以及确保诊断步骤和选择逻辑一致的检查表医生。

这些「医生」协作工作,充分地模拟了人类医生团队的工作流程,还弥补了单一 AI 模型在复杂诊断中可能出现的缺陷。

MAI-DxO 系统概览图

▲MAI-DxO 系统概览图

如上图描述的系统概览图所示,MAI-DxO 完全模拟了我们去医院看病的流程。

  1. 首先从问诊开始,MAIN-DxO 会得到一个简短的临床小故事,通常为 2-3 句话,包含病例的基本情况。
  2. 接着,MAI-DxO 会开始总结患者的主要诉求,选择下一步操作,是继续向患者提问,还是申请开检查。
  3. 每开一项检查会计算检查费用,同时持续进行多轮互动,直到给出最后诊断结果。

在测试过程中,MAI-DxO 利用 o4-mini 和专业医生设置了一个「守门人」,确保系统给 AI 的信息是与正常医生在问诊和临床上能够得到的信息一样。

MAI-DxO 的出现,为大语言模型在医疗诊断上取得明显的性能提升。微软测试了来自 OpenAI、Gemini、Claude、Grok、DeepSeek 以及 Llama 系列的不同模型,表现均优于仅使用单一的 AI 模型,而表现最好的组合是 MAI-DxO 与 OpenAI 的 o3 配对。

由于不受大语言模型的限制,MAI-DxO 还能够在将来有更好的模型出现时,同步适配。

不同人工智能模型的准确性和每例平均诊断测试成本对比

▲不同人工智能模型的准确性和每例平均诊断测试成本对比

尽管看起来 「AI 医生」已经有模有样,不过 AI 要真正做一个好医生可不是那么容易的。

微软在该项目论文最后提到,这次的研究存在显著局限性,包括像参与对比实验的 21 位医生并没有获得同行的讨论协助、参考书籍以及生成式 AI 等资源。此外,微软这次实验也仅仅只讨论了最具挑战性的病例难题,而对我们一般的日常性疾病诊断没有做进一步的测试。

微软强调 AI 不会取代医生,它将成为医生与患者共同的助手。

但就是这个医生和患者共同的助手,也持续地吸引着全世界范围的关注;早在今年 3 月,微软就发布了医疗界首个用于临床工作流程的 AI 助手 Microsoft Dragon Copilot,它能帮助医生更好的整理病例的临床文件。

IBM 推出 IBM Watson Health 医疗人工智能平台、谷歌的 DeepMind、以及英伟达的 NVIDIA Clara 等,都正从导诊、问诊、病理等医疗场景中带来新的变革。

前段时间,阿里达摩院也发布了全球首个胃癌影像筛查 AI 模型 DAMO GRAPE,首次利用平扫 CT 影像结合深度学习识别早期胃癌病灶。

华为今年才组建组建医疗卫生军团,上周也联合瑞金医院,宣布开源 RuiPath 病理模型,具备临床验证能力,覆盖肺癌等 7 个常见癌种。

医学需要极高的精准度,0.01% 的失误也有可能造成严重的后果,它完全不同于程序员写代码时出现的 bug。

MAI-DxO 模拟真实问诊的过程,看起来这条 AI 医疗之路越来越清晰。

从百度问诊,到 ChatGPT 问诊,我想未来除了拿着普通医院的检查结果,查医院排行榜,付费问在线医生,还可以先看看这个「AI 医生」。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


与 AI 共舞:我的 Claude Code 一月谈

作者 Fatbobman
2025年7月2日 22:12

转眼间,我使用 Claude Code 已经整整一个月了。这段时间里,它迅速成为了开发者们的新宠,关于 Claude Code 的讨论充斥着我的社交媒体时间线。恰好有网友在 Discord 上让我聊聊 Claude Code,借此机会,我想梳理一下这段时间的使用感受,以及过去两年中 AI 为我的开发工作带来的便利与思考。

美国现在最贵的,是中国 AI 人才:清北中科大学霸正在「统治」硅谷 AI 圈

作者 莫崇宇
2025年7月2日 19:18

过去两周,AI 行业最出圈的不是哪个产品,而是人。经常一觉醒来,社交媒体的时间线都在刷新换汤不换药的新闻:又双叒叕有哪位 AI 大牛被挖走了。

顶级 AI 人才,正成为 AI 赛道上最稀缺、也最具品牌效应的资产。

在这轮人才流动的风暴中心中,我们发现一个格外显眼的细节:这群主导过 ChatGPT、Gemini、Claude 等大模型研发的核心成员中,华人科学家的比例出奇地高。

这个这个变化并不是突然出现的,这几年兴起的 AI 浪潮中,美国的顶级 AI 人才中华人占比不断升高。 根据 MacroPolo 发布的《全球人工智能人才追踪调查报告 2.0》,来自中国的顶尖 AI 研究人员占比在 2019 年到 2022 年间,从 29% 提升到了 47%。

而在智谱研究发布的《ChatGPT 团队背景研究报告》,更是发现在 ChatGPT 核心的 87人团队中,有 9 人都是华人,占比超过 10%。因此,我们也重新梳理了近期在硅谷头部公司中广受关注的华人 AI 研究员画像,并试图从中总结出一些特征:

1️⃣ 顶尖名校出身,学术能力极强
他们大多本科就读于清华、北大、中科大、浙大等顶尖高校,计算机或数学背景居多;研究生阶段普遍进入 MIT、斯坦福、伯克利、普林斯顿、UIUC 等名校深造,几乎每人都有顶会高引论文傍身(NeurIPS、ICLR、SIGGRAPH 等),

2️⃣ 年轻高产,爆发周期集中于 2020 年之后
年龄多在 30~35 岁;硕博阶段恰逢深度学习的全球爆发期,学术基础扎实,熟悉工程体系和团队协作。不少人职业的第一站就是接触大厂或服务大规模人群的 AI 产品或平台,起点更高、节奏更快。

3️⃣ 强多模态背景,攻坚模型后训练
他们的研究方向普遍着重于跨模态(文本、语音、图像、视频、动作)的统一推理系统,包括 RLHF、蒸馏、对齐、人类偏好建模、语音语调评估等具体细节。

4️⃣ 即便频繁流动,但基本不会脱离生态
Google、Meta、微软、英伟达,Anthropic、OpenAI……他们的流动范围横跨 AI 初创与巨头,但研究主题、技术积累往往保持连贯性,基本不换赛道。

OpenAI→Meta

Shuchao Bi

Shuchao Bi 本科毕业于浙江大学数学系,后赴加州大学伯克利分校深造,先后获得统计学硕士学位,并攻读数学博士。

2013 – 2019 年,他在 Google 担任技术负责人,主要贡献包括构建多阶段深度学习推荐系统,显著提升 Google 广告收益(数十亿美元级别)。

2019 – 2024 年,他担任 Shorts 探索负责人,期间,联合创建并主导 Shorts 视频推荐与发现系统,并 组建并扩展大规模机器学习团队,覆盖推荐系统、评分模型、互动发现、信任与安全等方向。

2024 年加入 OpenAI 后,他主要领导多模态后训练组织,是 GPT-4o 语音模式与o4-mini的联合创造者

期间,他主要推进 RLHF、图像/语音/视频/文本推理、多模态智能体、多模态语音到语音(VS2S)、视觉-语言-行动基础模型(VLA)、跨模态评估系统等,也涉及多模态链式推理、语音语调/自然度评分、多模态蒸馏与自监督优化,其核心目标是通过后训练构建更通用的多模态 AI Agent。

Huiwen Chang

2013 年,Huiwen Chang 本科毕业于清华大学计算机系(姚班),后赴美国普林斯顿大学攻读计算机科学博士,研究方向聚焦于图像风格迁移、生成模型和图像处理,曾获微软研究院奖学金。

在加入 OpenAI 之前,她在 Google 担任高级研究科学家,累计工作超过六年,长期从事生成模型与计算机视觉研究,曾在 Google Research 发明 MaskGIT 和 Muse 文本生成图像架构。

早期的文本生成图像主要依赖扩散模型(如 DALL·E 2、Imagen),这些模型虽然生成质量高,但推理速度慢、训练开销大。而 MaskGIT 和 Muse 则采用了「离散化 + 并行生成」 的方式,大幅提升了效率。

MaskGIT 是非自回归图像生成的新起点,Muse 则是将这一方法推向文本图像生成的代表作。它们不像 Stable Diffusion 那样广为人知,但在学术与工程体系中,是非常重要的技术基石。

此外,她也是扩散模型顶级论文《Palette: Image-to-image diffusion models》的联合作者之一。

这篇论文发表于 SIGGRAPH 2022,提出了一种统一的图像到图像翻译框架,并在图像修复、着色、补全等多个任务上超过 GAN 和回归基线,至今已被引用超过 1700 次,成为该领域的代表性成果之一。

2023 年 6 月起,她加入 OpenAI 多模态团队,联合开发了 GPT-4o 图像生成功能,继续推动图像生成、多模态建模等前沿方向的研究与落地。

Ji Lin

Ji Lin 主要从事多模态学习、推理系统与合成数据方向的研究。他是多个核心模型的贡献者,包括 GPT-4o、GPT-4.1、GPT-4.5、o3/o4-mini、Operator、以及 4o 图像生成模型等。

他本科毕业于清华大学电子工程专业(2014–2018),从麻省理工学院获得电子工程与计算机科学博士学位,导师为知名学者 Prof. Song Han。

博士阶段,他的研究方向聚焦于模型压缩、量化、视觉语言模型、稀疏推理等关键方向。

在 2023 年加入 OpenAI 之前,他曾在英伟达、Adobe 和 Google 担任实习研究员,并在 MIT 长期从事神经网络压缩与推理加速相关研究,积累了深厚的理论基础与工程实践经验。

学术方面,他在模型压缩、量化和多模态预训练等方向有多篇高影响力论文,Google 学术总引用数超过 17800,代表成果包括视频理解模型 TSM、硬件感知量化方法 AWQ、SmoothQuant 以及视觉语言模型 VILA。

他也是 GPT-4o 系统技术文档的核心作者之一(比如 GPT-4o 系统卡),并凭借 AWQ 论文获得 MLSys 2024 最佳论文奖。

Hongyu Ren

Hongyu Ren 本科在北京大学获得计算机科学与技术学士(2014–2018)学位,随后在斯坦福大学获得计算机科学博士(2018–2023)学位。

他曾获得苹果、百度以及软银 Masason 基金会 PhD Fellowship 等多项奖学金,研究方向聚焦于大语言模型、知识图谱推理、多模态智能与基础模型评估。

在加入 OpenAI 之前,他曾在 Google、微软以及英伟达有过多段实习经历,比如 2021 年在苹果担任实习研究员期间,参与 Siri 问答系统的搭建。

2023 年 7 月加入 OpenAI 后,Hongyu Ren 参与构建了 GPT-4o、4o-mini、o1-mini、o3-mini、o3 和 o4-mini 等多个核心模型,并领导后训练团队。

用他的话来说:「I teach models to think faster, harder and sharper.(我教模型更快、更努力、更敏锐地思考。)」

学术领域,他的 Google 学术总引用数超过 17742 次,高被引论文包括:《On the Opportunities and Risks of Foundation Models》(引用 6127 次);《Open Graph Benchmark》(OGB)数据集(引用 3524 次)等。

Jiahui Yu

Jiahui Yu 本科毕业于中国科学技术大学少年班,获得计算机科学学士学位,随后在伊利诺伊大学香槟分校(UIUC)获得计算机科学博士学位。

他的研究重点包括深度学习、图像生成、大模型架构、多模态推理和高性能计算。

在 OpenAI 任职期间,Jiahui Yu 担任感知团队负责人,主导开发 GPT-4o 图像生成模块、GPT-4.1、o3/o4-mini 等重要项目,提出并落地了「Thinking with Images」感知体系。

在此之前,他曾在 Google DeepMind 工作近四年,期间是 PaLM-2 架构与建模的核心贡献者之一,并共同领导了 Gemini 多模态模型的开发,是 Google 多模态战略中最重要的技术骨干之一。

他还拥有在英伟达、Adobe、百度、Snap、旷视和微软亚洲研究院等多家机构的实习经历,研究内容涵盖 GAN、目标检测、自动驾驶、模型压缩、图像修复与大规模深度学习训练系统等多个方向。

Jiahui 在 Google 学术上总引用次数超过 34500 次,h 指数达 49,代表性研究成果包括图文对齐基础模型 CoCa、文本生成图像模型 Parti、神经网络可伸缩设计 BigNAS,以及广泛应用于 Adobe Photoshop 的图像修复技术 DeepFill v1 和 v2 等。

Shengjia Zhao

Shengjia Zhao 本科毕业于清华大学计算机系,曾在美国莱斯大学交换,后于斯坦福大学获得计算机科学博士学位,专注于大模型架构、多模态推理和对齐方向的研究。

2022 年,他加入 OpenAI,担任核心研发成员,深度参与 GPT-4 和 GPT-4o 的系统设计工作。曾主导 ChatGPT、GPT-4、所有 mini 模型、4.1 和 o3 的研发工作,还曾领导 OpenAI 合成数据团队。

他是《GPT-4 Technical Report》(被引超过 1.5 万次)和《GPT-4o System Card》(被引超过 1300 次)的联合作者,并参与了多个系统卡(如 OpenAI o1)的撰写,是推动 OpenAI 基础模型标准化与公开化的重要贡献者之一。

在学术表现上,他 Google 学术总引用数超过 21,000 次,h 指数为 25,曾获得过 ICLR 2022 Outstanding Paper Award、JP Morgan PhD Fellow、Qualcomm 创新奖学金(QinF)与 Google Excellence Scholarship 等多项奖项。

Google→Meta

Pei Sun

2009 年,Pei Sun在清华大学获得了学士学位,随后前往卡内基梅隆大学攻读硕士和博士学位,顺利完成硕士阶段学习,并在博士阶段选择退学。

他曾在 Google DeepMind 担任首席研究员,期间主攻 Gemini 模型的后训练、编程和推理工作,是 Gemini 系列模型(包括 Gemini 1、1.5、2 和 2.5)后训练、思维机制构建与代码实现的核心贡献者之一。

在加入 DeepMind 之前,Pei 曾在 Waymo 任职近七年,担任高级研究科学家,主导了 Waymo 两代核心感知模型的研发,是自动驾驶感知系统演进的中坚力量。

更早些时候,他曾在 Google 担任软件工程师五年多,后又加入分布式存储公司 Alluxio 任职工程师超过一年,参与系统架构研发。

Nexusflow→英伟达

Banghua Zhu

Banghua Zhu 本科毕业于清华大学电子工程系,后赴美国加州大学伯克利分校攻读电气工程与计算机科学博士,师从著名学者 Michael I. Jordan 和 Jiantao Jiao。

他的研究聚焦于提高基础模型的效率与安全性,融合统计方法与机器学习理论,致力于构建开源数据集和可公开访问的工具。他的兴趣方向还包括博弈论、强化学习、人机交互以及机器学习系统设计。

他代表性论文《Chatbot Arena》提出了人类偏好驱动的大模型评测平台,成为 LLM 领域的重要基准之一。

此外,他还在 RLHF、人类反馈对齐、开源对齐模型等方向有所贡献。其 Google 学术显示引用总数超过 3100,h 指数为 23,也是大模型竞技场「Chatbot Arena」、「Benchbuilder」、「Starling」等多个热门开源项目的核心作者之一。

他曾在 Microsoft 担任研究实习生,在 Google 担任学生研究员,曾联合创立 AI 初创公司 Nexusflow,今年 6 月,他宣布加入英伟达 Star Nemotron 团队担任首席研究科学家,此外将于今年秋季入职华盛顿大学的助理教授。

根据其发布内容,他将在英伟达参与模型后训练、评估、AI 基础设施和智能代理构建等项目,强调与开发者及学术界的深度协作,并计划将相关成果开源。

Jiantao Jiao

Jiantao Jiao 是加州大学伯克利分校电气工程与计算机科学系以及统计系的助理教授。

他于 2018 年获得斯坦福大学电气工程博士学位,目前是多个研究中心的联合负责人或成员,包括伯克利理论学习中心(CLIMB)、人工智能研究中心(BAIR Lab)、信息与系统科学实验室(BLISS)以及去中心化智能研究中心(RDI)。

他的研究集中于生成式 AI 与基础模型,对统计机器学习、优化理论、强化学习系统的隐私与安全、经济机制设计以及自然语言处理、代码生成、计算机视觉、自动驾驶与机器人等方向也颇有兴趣。

和 Banghua Zhu 一样,他也是 Nexusflow 联合创始人之一,目前已经正式加入英伟达,担任研究总监兼杰出科学家。

Jiao 的总引用次数达 7259,h 指数为 34,代表性论文包括《Theoretically principled trade-off between robustness and accuracy》,以及与 Banghua Zhu 等人合作的《Bridging Offline Reinforcement Learning and Imitation Learning: A Tale of Pessimism》,均发表在 NeurIPS 等顶会。

Claude→Cursor

Catherine Wu

Catherine Wu 曾在 Anthropic 担任 Claude Code 的产品经理,专注于构建可靠、可解释、可操控的 AI 系统。据 The Information 报道,Catherine Wu 已被 AI 编程初创公司 Cursor 挖角,出任产品负责人一职。

在加入 Anthropic 之前,她曾是知名风投公司 Index Ventures 的合伙人,任职近三年,期间深度参与多家顶尖创业公司的早期投资与战略支持。

她的职业起点并不在投资圈,而是扎根于一线技术岗位。

她曾在 Dagster Labs 担任工程经理,主导公司首个商业化产品的研发,也曾在 Scale AI 担任早期产品工程师,参与多个关键产品的构建与运营扩张。

更早之前,她在摩根大通实习,并于普林斯顿大学获得计算机科学学士学位,在校期间还曾赴苏黎世联邦理工学院进行交换学习。

特斯拉 | Phil Duan

段鹏飞(Phil Duan)是特斯拉 AI 的首席软件工程师,现负责 Autopilot 下的 Fleet Learning 团队,致力于推动特斯拉自动驾驶系统(FSD)中「数据 + 感知」核心模块的建设。

他带领特斯拉团队开发高吞吐、快迭代的数据引擎,从数百万辆汽车中采集、处理并自动标注驾驶数据,强调数据质量、数量与多样性的协同优化。在感知方向,他主导构建多项关键神经网络,包括视觉基础模型、目标检测、行为预测、占据网络、交通控制和高精度泊车辅助系统等,是 Autopilot 感知系统的核心构建者之一。

他本科毕业于武汉理工大学,主修光信息科学与技术,随后攻读俄亥俄大学电气工程博士与硕士学位,研究方向为航空电子,并以博士论文荣获 2019 年 RTCA William E. Jackson Award,该奖项是美国航空电子与电信领域授予研究生的最高荣誉之一。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Figma 上市:AI 时代的生产力企业新样本

作者 周芊彤
2025年7月2日 17:45

2025 年 7 月 1 日,以「云端协同设计」为核心理念的 SaaS 设计公司 Figma 向美国证交会递交招股书,并计划以股票代码 「FIG」 登陆纽交所,目标募资最高 15 亿美元(约合人民币 108.8 亿元)。

二级市场显示 Figma 的 pre-IPO 估值为 125 亿美元,市销率达到 17.9 倍。若本次 IPO 成功,虽然和 Zoom、Snowflake 等 SaaS 大热门不能相提并论,金融业还是对其赋予厚望,认为 Figma 有可能超过年初的云计算公司 CoreWeave,成为 2025 年最大科技 IPO 黑马。

▲ Figma 营收数据. 图片来自:Figma S-1 文件

自 2016 年 Figma 首次亮相以来,就一直在打破大家对 「协作工具难赚钱」的刻板印象,交出了一份教科书级别的财务答卷:

  • 2024 年营收 7.49 亿美元,同比增长 48%;
  • 2025 年 Q1 营收 2.28 亿美元,同比增长 46%;
  • 滚动 12 个月营收 8.21 亿美元,毛利率高达 91%。

在高毛利的加持下,公司在 2024 年四季度与 2025 年一季度已重新转正,实现经营盈利。与上一财年相比,Figma 基本甩掉了「高增长=高亏损」的包袱。在主营业务的现金创造能力上,Figma 作为成熟 SaaS 企业已经「当之无愧」。

▲ Figma Mirror 实时更新. 图片来自:Figma

如果你也是互联网软件设计开发业务线中的一员,那么一定绕不开 Figma 这个以界面设计起家的在线设计协作平台——

在 Figma 之前,支持「云端协作」的概念在设计行业并不新鲜,Sketch 和 Adobe XD 都有过类似操作。但区别于其它传统设计软件,Figma 基于浏览器工作:你只需用浏览器打开链接便可开始设计工作,用起来相当轻量,和动辄十几秒才启动的 Adobe 垃圾桶是天壤之别。这也让 Figma 成为许多设计团队的首选。

▲ Figma 多人协作演示. 图片来自:Figma

就如创始人 Dylan Field 表示:

我们希望让设计变得像 Google Docs 一样简单且适于协作。

▲ 开发者视角. 图片来自:Figma

从用户数据来看,Figma 确实将传统设计软件行业撬开了一个大口:截止至 2025 年,Figma 的月活跃用户数已经超过 1300 万,拥趸中不乏微软、Slack、GitHub 等知名企业。活跃的用户群体,成为了 Figma 的核心竞争力。

秉持「赋能创作者」的理念,Figma 在定价与服务上也打出了长期牌:保留个人免费版,团队企业版则按人头计费。适用于不同用户组织的灵活定价政策,也让设计师可以在不迁移数据的前提下一路升级到企业版,在无形中增强了用户粘性。

▲ Figma 新增白板协作和幻灯片功能. 图片来自:Figma

除此之外,Figma 也不拘于只做一个在线协作设计平台——今年 Figma 进一步将 AI 融入设计工作流:

从 「Make design」 一键生成高保真图片,到 「Figma Sites」无代码网站上线,都在致力于让设计和开发更高度整合,让设计师用户们享受到 AI 时代的红利,实现更快、更「vibe」、更协同的工作中,实现真正意义上的设计开放共享。

▲ 用「Make design」做的界面设计. 图片来自:xxx

当然,生成式 AI 的后端重度依赖算力,十分昂贵,Figma 在这方面的大量投入也导致营收承压。管理团队在招股书中表示:为客户开发生成人工智能工具(特别是用于人工智能推理和模型训练)的相关成本,可能会损害其长期利润率。

但对于这家试图在 AI 时代有所作为的设计软件公司而言,AI 带来的生产力革命将是千载难逢的机会。

▲从左到右分别是自动布局,矢量绘制和模块组件. 图片来自:Figma

回顾 Figma 的成长轨迹,自 2022 年 Adobe 斥资 200 亿美元的收购案失败后,Figma 的战略心态也出现了明显变化,不再追求被收购,而是独立最大做强。

▲ Figma 创始人 Dylan Field. 图片来自:TechCrunch

从「成为更好的设计工具」到如今则试图「成为 AI 时代的设计操作系统」,诞生于移动互联网时代的 Figma,瞄准的不仅是设计师群体,更是 AI 时代所有的生产者。

至于「AI + 设计」的故事能否被市场认可,Figma 还有很长一段路要走,但这已经是今年最大的软件 IPO 之一,对任何还在观望的创业者和投资人来说,这或许比 Figma 推出的任何新功能都来得更刺激一些。

本文作者:周芊彤、肖钦鹏

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


OpenAI 内部信曝光!奥特曼怒斥小扎 7 亿挖人:唯利是图的雇佣兵,将被使命打败

作者 莫崇宇
2025年7月2日 11:13

还有人没看过关于 Meta 挖人的段子吗?

▲ 建议以后顶级 AI 人才的流动,参考俱乐部的转会制度。

在昨日 Meta 高调官宣超级智能团队实验室之后,大批挖走OpenAI核心研究员之后,一向沉得住气的 Sam Altman 现在也坐不住了,向全体员工发出内部信:

有使命感的人将胜过唯利是图的雇佣兵。

据连线杂志报道,Altman 还在信中强调,留在 OpenAI 才是那些希望构建通用人工智能(AGI)研究者的正确选择,并暗示公司正在重新评估整个研究团队的薪酬结构。

对 Meta 的挖人行为,Altman 显得相当不屑,认为这种「开价挖人」的模式未来将带来严重的文化副作用。

我们已经从角落里的极客,成长为科技行业里最受关注的人(至少是这样)……AI 圈现在乌烟瘴气;Meta 的做法让人感觉不太体面;我觉得事情将来只会更加疯狂。我被解雇又回归时曾说,那不会是 OpenAI 历史上最疯狂的事;显然现在这事也还不是

在评价那些被 Meta 挖走的前同事时,Altman 的态度也没太客气:

「Meta 确实招到了一些优秀的人,但整体来看,他们并没有挖到那些顶尖人才,还得一路向下寻找;他们已经尝试招募很久了,我都记不清他们试图从我们这里挖走多少人去当他们的首席科学家。」Altman 写道,「我为整个行业的使命感感到骄傲,当然总会有一些唯利是图的人。」

他还放话称,OpenAI 股票的潜力远远超过 Meta。但巨大的回报应该建立在巨大成功之后,OpenAI将很快公布更多薪酬方面的举措,但会「确保公平性」,而不是只针对那些「被 Meta 盯上」的个别员工。

Altman还呼吁大家继续留在 OpenAI:

我对我们的研究路线从未如此有信心,我们在计算资源上做了前所未有的投入,我喜欢我们敢于下注,并相信我们会好好利用它。最重要的是,我认为我们拥有全世界最特别的团队和文化。我们确实还需要努力改进我们的文化;过去经历了疯狂的爆炸式增长。但我们的核心是正确的,我认为没有任何其他组织能做到这一点,我有信心我们能解决现有问题。

更重要的是,我们真的在乎如何以正确的方式构建AGI,其他公司更把它当作实现其他目标的手段。而这对我们来说始终是最重要的事,也将永远如此。等到 Meta 转向下一个流行项目,或忙于守护他们的社交护城河时,我们仍会在这里,一天又一天、一年又一年,努力比任何人都更好地完成我们的使命。其他许多项目将起起落落。

话虽如此,其实也真不怪研究人员转投 Meta。

无他,实在是扎克伯格给的太多的了。小扎不语,只是一味群发高薪合同。顶级 AI 研究员横在中间,像极了拿 offer 的你我他,嘴上说着不在乎钱,但手已经开始敲键盘回复小扎发来的邮件。

根据连线杂志获取的信息,扎克伯格为顶尖研究人员开出的薪酬高达 4 年 3 亿美元,首年总薪酬超过 1 亿美元,而目前,财大气粗的 Meta 已向 OpenAI 的员工至少发出了 10 份如此高额的报价,并承诺最先进的 GPU 资源「随便用」。

并且报道还提到,Meta 曾试图招募一位 OpenAI 的高级研究员担任首席科学家一职,但对方最终拒绝了邀请。据称,这些薪资方案虽然以股票为主,但第一年股票直接兑现,诱惑力拉满。

做个横向对比,微软 CEO Satya Nadella 在 2024 年获得的总薪酬为 7910 万美元,主要是股票形式;Uber CEO Dara Khosrowshahi 同期则大约为 3940 万美元,同样以股票为主。一个顶级 AI 研究员的年薪,现在轻松干掉硅谷大厂 CEO。

当然,在上周 Meta 全员大会上,CTO Andrew Bosworth 也回应了 OpenAI CEO Sam Altman 所称的「Meta 用 1 亿美元签约金挖角」一事,直指其夸大其词。

所谓高额待遇仅适用于极少数高级岗位。「我非常清楚他为什么这么说:因为我们确实成功吸引了一些 OpenAI 的人才,而他对此显然并不高兴。」他强调,所谓的「1 亿美元报价」不是一次性奖金,而是包含股票激励、签约奖励等多个组成部分。

这也应了那句话,算力可以堆,数据可以靠爬虫,但对想赢下 AGI 终局的公司来说,人才始终是最贵的资源。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


昨天以前首页

从SSE到打字机——AI场景下前端的实现逻辑与实践

2025年7月1日 19:34

随着Deepseek的横空出世,让每个人都有了构建自己AI知识库的机会,作为一个前端开发者,完全可以通过大模型构建自己的日常学习知识库,然后自己写一个AI的交互页面构建自己的 ChatGPT ,当然说到这,肯定有人说现在有一键构建的开源项目为什么不用呢,说白了技术还是要自己实现才能更加深入地理解,并且更加灵活地运用到日常学习或者实际业务场景中去。

本篇文章只从前端的角度出发,分析实现一个AI的交互页面能用到哪些技术,最后再去实现一个AI场景页面。

当然,你也可以点击这里直接查看本篇文章实现的页面。

如果打不开,这里还有贴心国内服务器的备用链接

PS:上面两个演示链接都是用 vuepress 实现的个人博客,感觉用这套框架实现自定义组件里面的坑还挺多了,有机会可以再写一篇关于 vuepress 的开发避坑文章。

当然,关于IM的交互逻辑在我之前的文章 【从零开始实现一个腾讯IM即时通讯组件(无UI设计方案)~】中已经详细描述了实现过程,所以,这篇文章就从已经实现了IM交互的页面基础上开始实现AI场景下的IM。

技术选型

涉及到AI场景必然会联想到打字机效果的流式输出文本,那么前端实现这种效果有哪些方式呢?

协议对比

首先最简单的,通过轮询接口不断获取数据,其次通过websocket不断获取监听到的数据,最后通过服务端消息推送获取数据。这三种思路对应着三种通讯协议:HTTP、WebSocket、SSE。

先对比一下这三种协议:

基本概念与通信模式

特性 HTTP SSE (Server-Sent Events) WebSocket
协议类型 无状态的请求 - 响应协议 基于 HTTP 的单向事件流协议 基于 TCP 的全双工实时协议
通信方向 客户端→服务器(单向) 服务器→客户端(单向) 双向(全双工)
连接特性 短连接(每次请求新建连接) 长连接(单次请求,持续响应) 长连接(一次握手,持续通信)
发起方式 客户端主动请求 客户端主动请求,服务器持续推送 客户端发起握手,后续双向通信
典型场景 静态资源请求、API 调用 实时通知、股票行情、新闻推送 实时聊天、在线游戏、协作工具

技术细节对比

特性 HTTP SSE WebSocket
协议基础 HTTP/1.1 或 HTTP/2 HTTP/1.1 或 HTTP/2 WebSocket 协议 (RFC 6455)
端口 80 (HTTP) / 443 (HTTPS) 80/443 80 (ws) / 443 (wss)
数据格式 文本、JSON、二进制等 纯文本(text/event-stream) 文本或二进制(帧格式)
二进制支持 支持,但需额外处理 不支持(需编码为文本) 原生支持
自动重连 否(需客户端实现) 是(内置机制) 否(需手动实现)
心跳机制 否(需轮询) 否(需自定义) 是(Ping/Pong 帧)
浏览器兼容性 全兼容 现代浏览器(IE 不支持) 现代浏览器(IE 10+)

性能与效率

特性 HTTP SSE WebSocket
连接开销 高(每次请求需重新建立连接) 中(一次连接,长期保持) 低(一次握手,持续通信)
协议 overhead 高(HTTP 头信息冗余) 低(仅初始头) 中(帧头开销较小)
实时性 低(依赖客户端轮询) 高(服务器主动推送) 极高(双向实时)
带宽利用率 低(轮询导致无效请求) 中(单向持续传输) 高(按需双向传输)
延迟 高(请求响应周期) 中(推送延迟) 低(长连接直接通信)

API选择

再来回看一下我们的需求,AI场景说白了一问一答的方式,那么我们希望发送一次请求后,能够持续获取数据,本次请求后端也只需要知道我的问题即可,不需要和前端进行其他交互,所以 SSE 在这种场景下的优势就显而易见了。

前端要在浏览器中实现 SSE 的方式有两种:

  • EventSource API
  • fetch API

EventSourcefetch 都是现代 Web 开发中用于与服务器通信的 API。

特性 EventSource (SSE) Fetch API
通信模式 单向(服务器→客户端) 双向(请求→响应)
连接特性 长连接(持续接收服务器推送) 短连接(每次请求新建连接)
数据流类型 事件流(持续不断) 一次性响应(请求完成即结束)
数据格式 文本(事件流格式) 任意(JSON、Blob、文本等)
自动重连 内置支持(自动重连机制) 需手动实现

EventSource API实现了 SSE 。换句话说 EventSource API是 Web 内容与服务器发送事件通信的接口。一个EventSource 实例会对HTTP服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

但是它有一些限制:

  • 无法传递请求体 request body ,必须将执行请求所需的所有信息编码到 URL 中,而大多数浏览器对 URL 的长度限制为 2000 个字符。
  • 无法传递自定义请求头。
  • 只能进行 GET 请求,无法指定其他方法。
  • 如果连接中断,无法控制重试策略,浏览器会自动进行几次尝试然后停止。

而AI场景常常会有一些其他需求,如上文记忆、接口 token 验证等等,于是 fetch 成了我们的最佳选择。

fetch API可以通过设置 headers 支持流式数据的接收,然后通过 ReadableStreamDefaultReader 对象,逐块读取响应的数据。

大模型选择

作为前端开发我们更注重于模型的定制化配置和页面的展示效果与交互,通过第三方模型可以快速满足我们的需求,这里我选用的是阿里云百炼

它直接提供了支持流式输出的接口,只需要在请求头加上 X-DashScope-SSE:true 。比较坑的是阿里云文档里面只提供了 node 的写法,实际浏览器中 axios 并不支持流式传输。

image-20250621145616487

API解析

AbortController

前面我们说到 SSE 的数据传输是单向的,有时候我们会想中断推送信息的接收,实际需求就是中断AI当前回答,所以我们需要一个控制器来更加精细地控制我们的请求。

AbortController 对象的作用是对一个或多个 Web 请求进行中止操作,像 fetch 请求、ReadableStream 以及第三方库的操作都可以取消。

核心机制:借助 AbortSignal 来实现操作的中止。AbortController 会生成一个信号对象,该对象可被传递给请求,当调用 abort() 方法时,就会触发请求的取消操作。

有了这个API我们就可以实现中断回答按钮的实现。

const controller = new AbortController()
const response = await fetch(
  url: 'url',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer token`,
      'X-DashScope-SSE': 'enable', // 允许实时推送
    },
    signal: controller.signal, // 信号对象绑定请求
    body: "{...}",
  }
)

setTimeout(()=> controller.abort(), 1000) // 一秒钟后自动中断请求

Reader

在请求发出后,我们需要一个能持续获取推送信息的入口,fetchresponse.body.getReaderJavaScript 中用于处理 fetch 请求响应流的方法,它允许你以可控的方式逐块读取响应数据,而非一次性处理整个响应。这在处理大文件下载、实时数据流(如视频、SSE)或需要渐进式解析数据的场景中特别有用。

// 获取一个 ReadableStreamDefaultReader 对象,用于逐块读取响应的二进制数据(Uint8Array)。
const reader = response.body.getReader()

while (true) {
  // 读取数据块 流是一次性的,读取完成后无法再次读取
const {done, value} = await reader.read();
  if (done) {
    console.log('流读取完成');
    break;
  }
}

循环调用 read() 以达到获取完整数据的需求,根据 done 判断是否已经读取完毕。

TextDecoder

TextDecoderJavaScript 中用于将二进制数据(如 ArrayBufferUint8Array)解码为人类可读的文字字符串的内置对象。它支持多种字符编码(如 UTF-8ISO-8859-1GBK 等),是处理网络响应、文件读取等二进制数据转换的标准工具。

// 任意二进制数据
const value = ...

// 流式解码:支持分块处理二进制数据(通过多次调用 decode 方法)。
const decoder = new TextDecoder('UTF-8')
// 解码二进制数据为文本
const chunk = decoder.decode(value, { stream: true })

值得注意的是 decodestream 参数设置为 true ,这是为了防止乱码的情况,因为我们知道 UTF-8 是一种变长编码,ASCII 字符(0-127)用 1 个字节表示,而其他字符(如中文、 emoji)可能用 2-4 个字节表示。例如:

  • 的 UTF-8 编码是 [228, 184, 150](3 个字节)。
  • 😊 的 UTF-8 编码是 [240, 159, 152, 138](4 个字节)。

当数据分块传输时,一个字符可能被截断在不同的块中。例如:

块1: [228, 184]    // "中" 的前两个字节(不完整)
块2: [150]         // "中" 的最后一个字节

stream 选项决定了解码器如何处理可能不完整的多字节字符:

stream 行为描述
false 默认值。假设输入是完整的,直接解码所有字节。若遇到不完整字符,会用 替换。
true 假设输入是数据流的一部分,保留未完成的多字节字符,等待后续数据。

实际情况可以参考下段代码:

// 错误情况
const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1)); // 输出: "�"(错误:截断的字符被替换为乱码)
console.log(decoder.decode(chunk2)); // 输出: "�"(错误:单独的第三个字节无法组成有效字符)

// 正确情况

const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1, { stream: true })); // 输出: ""(无输出,保留未完成字符)
console.log(decoder.decode(chunk2));                   // 输出: "中"(合并后正确解码)

处理流式输出

结合上述API的分析,fetch 实现处理流式数据的代码如下:

const controller = new AbortController()

const response = await fetch(
  url,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer sk-dd0e8892eb0445149fd21fd9b1d6176c`,
      'X-DashScope-SSE': 'enable',
    },
    signal: controller.signal,
    body: JSON.stringify({
      input: {
        prompt: text
      },
      parameters: {
        'incremental_output' : 'true' // 增量输出
      },
    }),
  }
)
if (!response.ok) {
  message.error('AI返回错误')
  loadingSend.value = false
  return
}

const decoder = new TextDecoder('UTF-8')
const reader = response.body.getReader()

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    console.log('流读取完成');
    // 中断fetch请求
    controller.abort()
    // 资源释放:释放读取器锁
    reader.releaseLock()
    break;
  }

  // 解码二进制数据为文本
  const chunk = decoder.decode(value, { stream: true })
  console.log('chunk:===>', chunk)
}

处理流式数据

通过 reader 读取到的数据经过 decoder 处理后格式如下:

id:1
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"我是","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":1,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

id:2
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"你的","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":2,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

当然这个是阿里云的返回格式,但流式数据格式都大差不差,接下来我们来分析这段文本。

首先,reader 获取的数据可能会有多段,如上文中的就是 id:1id:2 两段数据。

其中关键字段为:data.output.text ,所以我们需要根据返回数据的结构特点通过正则把有效信息给过滤出来。

// 全局贪婪匹配 "text":" 到 ","reject_status": 之间的内容,确保多段数据也能准确提取所有的有效信息
const regex = /"text":"(.*?)","reject_status":/gs;

这里使用正则而不是 JSON 化的原因是流式数据的处理讲究高效与准确JSON 化更加地消耗性能,而且存在异常报错的可能,为了最大可能保证主流程的持续输出,用正则是更优的选择。当然具体业务场景具体处理,这里仅作个人见解。

根据上述正则,实现一个数据处理函数:

const extractText = (jsonString) => {
  try {
    const regex = /"text":"(.*?)","reject_status":/gs;
    let match;
    let result = '';
    // 利用regex.exec()在字符串里循环查找所有匹配结果,把每次匹配得到的捕获组内容(也就是text字段的值)添加到result字符串中。
    while ((match = regex.exec(jsonString)) !== null) {
      // 将字符串里的\n转义序列转换为真正的换行符,把\"转义序列还原为普通的双引号。
      result += match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
    }
    return result
  } catch (error) {
    console.log('error', error)
    return ''
  }
}

最后把数据处理函数加到流式输出代码中,通过缓存持续获取有用的信息:

...
// 用于累计接收到的数据
let accumulatedText = ''

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    ...
    break;
  }

  const chunk = decoder.decode(value, { stream: true })
  // 累加并渲染数据
  const newText = extractText(chunk)
  if (newText) {
  accumulatedText += newText
  }
}

转换MD文本

这里用到几个库来实现:

  • markdown-it 一个快速、功能丰富的 Markdown 解析器,基于 JavaScript 实现。它的主要作用是把 Markdown 文本转换成 HTML。
  • @vscode/markdown-it-katex VS Code 团队开发的插件,用于在 Markdown 中渲染 LaTeX 数学公式,它集成了 KaTeX 这个快速的数学公式渲染引擎。
  • markdown-it-link-attributes 为 Markdown 中的链接添加自定义属性,比如为外部链接添加target="_blank"rel="noopener noreferrer"属性。
  • mermaid-it-markdown 用于在 Markdown 中集成 Mermaid 图表,Mermaid 是一种用文本语法描述图表的工具。

三方库使用

结合上述各种库结合,处理接口返回的信息流:

import MarkdownIt from 'markdown-it'
import MdKatex from '@vscode/markdown-it-katex'
import MdLinkAttributes from 'markdown-it-link-attributes'
import MdMermaid from 'mermaid-it-markdown'
import hljs from 'highlight.js'

const mdi = new MarkdownIt({
  html: false,
  linkify: true,
  highlight(code, language) {
    const validLang = !!(language && hljs.getLanguage(language))
    if (validLang) {
      const lang = language ?? ''
      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
    }
    return highlightBlock(hljs.highlightAuto(code).value, '')
  },
})
mdi.use(MdLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }).use(MdKatex).use(MdMermaid)

// 实现代码块快速复制
function highlightBlock(str, lang) {
  return `<pre class="code-block-wrapper">
            <div class="code-block-header">
                <span class="code-block-header__lang">${lang}</span>
                <span class="code-block-header__copy">复制代码</span>
            </div>
            <code class="hljs code-block-body ${lang}"><br>${str}</code>
          </pre>`
}

const renderToAI = (text) => {
  // 对数学公式进行处理,自动添加 $$ 符号
  const escapedText = escapeBrackets(escapeDollarNumber(text))
  return mdi.render(escapedText)
}

const escapeBrackets = (text) => {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock)
      return codeBlock
    else if (squareBracket)
      return `$$${squareBracket}$$`
    else if (roundBracket)
      return `$${roundBracket}$`
    return match
  })
}

const escapeDollarNumber = (text) => {
  let escapedText = ''

  for (let i = 0; i < text.length; i += 1) {
    let char = text[i]
    const nextChar = text[i + 1] || ' '

    if (char === '$' && nextChar >= '0' && nextChar <= '9')
      char = '\\$'

    escapedText += char
  }

  return escapedText
}

复制代码块

快速复制代码实现:

// 聊天列表主体元素
const textRef = ref()

// 构建textarea,将内容复制到剪切板
const copyToClip = (text) => {
  return new Promise((resolve, reject) => {
    try {
      const input = document.createElement('textarea')
      input.setAttribute('readonly', 'readonly')
      input.value = text
      document.body.appendChild(input)
      input.select()
      if (document.execCommand('copy'))
        document.execCommand('copy')
      document.body.removeChild(input)
      resolve(text)
    }
    catch (error) {
      reject(error)
    }
  })
}

// 为所有的复制代码按钮添加复制事件
const addCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.addEventListener('click', () => {
        const code = btn.parentElement?.nextElementSibling?.textContent
        if (code) {
          copyToClip(code).then(() => {
            btn.textContent = '复制成功'
            setTimeout(() => {
              btn.textContent = '复制代码'
            }, 1000)
          })
        }
      })
    })
  }
}

// 移除页面中所有的复制事件
const removeCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.removeEventListener('click', () => { })
    })
  }
}

// 在合适的生命周期里注册或卸载重新事件

// 可以在流式输出完成,页面渲染完成的时候手动调用,避免性能浪费,更加合理
onUpdated(() => {
  addCopyEvents()
})

onUnmounted(() => {
  removeCopyEvents()
})

自定义MD样式

MD样式:

.ai-message {
    background-color: transparent;
    font-size: 14px;
}
.ai-message p {
    white-space: pre-wrap;
}
.ai-message ol {
    list-style-type: decimal;
}
.ai-message ul {
    list-style-type: disc;
}
.ai-message pre code,
.ai-message pre tt {
    line-height: 1.65;
}
.ai-message .highlight pre,
.ai-message pre {
    background-color: #fff;
}
.ai-message code.hljs {
    padding: 0;
}
.ai-message .code-block-wrapper {
    position: relative;
    padding: 0 12px;
    border-radius: 8px;
}
.ai-message .code-block-header {
    position: absolute;
    top: 5px;
    right: 0;
    width: 100%;
    padding: 0 1rem;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    color: #b3b3b3;
}
.ai-message .code-block-header__copy {
    cursor: pointer;
    margin-left: 0.5rem;
    user-select: none;
}
.ai-message .code-block-header__copy:hover {
    color: #65a665;
}
.ai-message div[id^='mermaid-container'] {
    padding: 4px;
    border-radius: 4px;
    overflow-x: auto !important;
    background-color: #fff;
    border: 1px solid #e5e5e5;
}
.ai-message li {
    margin-left: 16px;
    box-sizing: border-box;
}

最后,把处理函数追加到处理流式数据后面:

let mdHtml = ''

...
const chunk = decoder.decode(value, { stream: true })
const newText = extractText(chunk)
if (newText) {
  accumulatedText += newText
  mdHtml += renderToAI(accumulatedText)
}

打字机

到目前为止我们已经流式地拿到了接口返回的数据并且转换成了页面可以展示的MD风格HTML字符串。

打字机的基本思路就是按照一定频率把内容添加到页面上,并且在内容最前面加个打字的光标。

直接上代码:

<template>
  <div v-html="displayText + `${ showCursor || adding ? `<span class='cursors'>_</span>`:'' }`"></div>
</template>

<script setup>
import { ref, watch, onUnmounted } from 'vue';

const props = defineProps({
  // 要显示的完整文本
  text: {
    type: String,
    required: true
  },
  // 打字速度(毫秒/字符)
  speed: {
    type: Number,
    default: 10
  },
  showCursor: {
    type: Boolean,
    default: false
  },
  break: {
    type: Boolean,
    default: false
  },
});
const emits = defineEmits(['update', 'ok'])

const displayText = ref('');
const adding = ref(false);
let timer = null;

// 更新显示的文本
const updateDisplayText = () => {
  if (displayText.value.length < props.text.length) {
    adding.value = true;
    displayText.value = props.text.substring(0, displayText.value.length + 1);
    emits('update')
    timer = setTimeout(updateDisplayText, props.speed);
  } else {
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
};

// 增量更新
watch(() => props.text, (newText) => {
  // 如果新文本比当前显示的文本长,则继续打字
  if (newText.length > displayText.value.length) {
    clearTimeout(timer);
    updateDisplayText();
  }
});

// 停止回答
watch(() => props.break, (val) => {
  if (val) {
    displayText.value = props.text + ''
    clearTimeout(timer);
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
});

// 初始化
updateDisplayText();

// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer);
});
</script>

<style>

.cursors {
  font-weight: 700;
  vertical-align: baseline;
  animation: blink 1s infinite;
  color: #3a5ccc;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
</style>  

我们只需要把上述转换的MD文本传入这个组件就能实现打字机效果。

<temlate>
<div class="ai-message">
    <TypingEffect :text="text" :showCursor="!ready" :break="break" @update="updateAIText" @ok="textAllShow" />
  </div>
</temlate>

需要注意的是,打字机打印的速度是按照恒定速度执行的,流式数据是不规则时间返回的,有可能返回很快,也有可能返回很慢,所以两边就会有时间差。

这就造成了一种现象,有时候我们点了停止回答的按钮,页面上还在不断输出内容,好像没有打断这次回答,这里我们只需要在点击停止回答的时候终止打字机的轮询,直接展示完整数据即可。

最后优化显示,需要自动滚动到底部:

const scrollToBottom = () => {
  try {
    const { height } = textRef.value.getBoundingClientRect()
    textRef.value.scrollTo({
      top: textRef.value.scrollHeight - height,
      behavior: 'smooth',
    })
  } catch (e) {}
}

总结

前端AI场景下总结来说就两个平时不常见的技术点:

  • 流式输出
  • 请求中断

当然本篇文章只是实现了基本的AI场景,像上下文记忆、多对话框以及更大模型的微调等等并未涉及到,这些更加深入地功能,可以后面慢慢研究,那么,这次就到这里~

疯狂挖人之后,小扎刚刚官宣超级智能 AI 梦之队,华人占大半 | 附 11 人核心名单

作者 莫崇宇
2025年7月1日 08:50

在长达数周的高强度「挖角」之后,Meta 今天凌晨宣布正式成立超级智能实验室(Meta Superintelligence Labs,简称 MSL)。

Meta CEO 马克·扎克伯格在周一发布的一封内部信中透露,MSL 将整合公司现有的基础 AI 研究(FAIR)、大语言模型开发以及 AI 产品团队,并组建一个专门研发下一代 AI 模型的新实验室。

根据彭博社等多家外媒报道,该实验室未来将成为 Meta 人工智能战略的核心。

此次组织架构调整的最大看点,是引入多位行业重量级人物共同执掌新部门。被扎克伯格砸下 143 亿美元投资的 Scale AI 的前 CEO Alexandr Wang 将出任 Meta 首席 AI 官(Chief AI Officer),全面领导 MSL。

与此同时,前 GitHub CEO Nat Friedman 也确认加入,将负责 Meta 在AI产品和应用研究领域的推进。

据扎克伯格介绍,Wang 是这一代最令人印象深刻的创业者,在 Scale AI 期间参与了多款头部 AI 模型的数据体系搭建;Friedman 则被称为连接投资界与 AI 前沿技术的中流砥柱,曾参与创办多个 AI 风险投资基金,并担任 Meta 顾问。

随着 MSL 的正式亮相,Meta 也首次公布其最近一轮密集「挖人』的完整名单。

在过去几周里,Meta 从 OpenAI、Anthropic 和 Google 等竞争对手手中共招募了 11 位 AI 顶尖人才,几乎覆盖了当前主流大模型的全部研发脉络:

  • 多位 GPT-4o 和 GPT-4.1 的核心成员:如Shengjia Zhao、Jiahui Yu、Shuchao Bi、Hongyu Ren;
  • 来自 Anthropic 的高级工程师 Joel Pobar,此前曾在 Meta 任职11年;
  • DeepMind 的 Jack Rae 和 Pei Sun,曾负责 Gemini 模型和多模态推理系统;
  • OpenAI 语音与图像模型的重要推动者 Huiwen Chang、Ji Lin 等。

这些人才曾是 OpenAI 和 Anthropic 等机构的核心技术骨干,主导过 GPT 系列、Gemini 系列等主流模型的关键技术领域。

Meta 方面未透露具体签约金额,但传闻部分顶尖研究人员获得了价值数千万美元的股票激励。

此前,OpenAI CEO Sam Altman 也在公开播客中透露,Meta正以高达 1 亿美元的签约奖金挖人。Meta CTO Andrew Bosworth 上个月接受外媒采访时指出:「现在 AI 人才的市场价格已经达到了一个非常惊人的水平,这是我 20 年科技职业生涯中前所未见的。」

面对 Llama 4 系列模型的受挫,小扎高度重视 AI 人才,也不断通过查询论文排兵布阵,甚至亲自出马,把候选人请到太浩湖和帕洛阿尔托的家中,亲自面试、亲自拉人,拼的就是反应速度和出手诚意。

据悉,Meta 计划在未来几年投入数千亿美元于 AI 基础设施、模型训练、可穿戴终端与人才储备上。新团队未来还将启动 Llama 系列之后的下一代模型研发,目标是在一年内实现行业领先。

Meta 发言人对此次重组未作官方评论,但未来几周仍将有更多顶尖人才加入 MSL 团队。

附上扎克伯格内部信原文:

随着人工智能发展速度的加快,超级智能的实现正逐渐变得触手可及。我相信,这将开启人类的一个全新时代,我也会全力以赴,确保 Meta 在这一进程中走在最前沿。今天,我想分享我们是如何组织AI工作的,目标是实现我们的愿景:为每个人打造「个人超级智能」。

我们将这个整体 AI 组织命名为 Meta 超级智能实验室(Meta Superintelligence Labs,简称 MSL)。它将涵盖我们所有基础模型、产品和 FAIR 团队(Fundamental AI Research),并新增一个专门负责开发下一代模型的实验室。

Alexandr Wang 已正式加入 Meta,担任首席 AI 官(Chief AI Officer),并领导 MSL的整体工作。我与 Alex 合作已有数年时间,我认为他是他这一代中最令人印象深刻的创业者。他对超级智能的历史意义有着清晰的理解,作为 Scale AI 的联合创始人兼 CEO,他将公司打造成了一个高速成长的企业,几乎参与了业内所有领先模型的开发工作。

Nat Friedman 也加入了 Meta,将与 Alex 共同领导 MSL,负责我们的 AI 产品与应用研究工作。他将与 Connor 一起明确他未来在团队中的职责分工。Nat 曾在微软领导 GitHub,最近则负责一家领先的 AI 投资公司。过去一年里,他一直担任 Meta 顾问委员会成员,对我们的发展路线图和所需工作已有深刻了解。

今天以及过去几周,还有多位非常优秀的新成员加入 MSL,我也非常高兴能正式介绍他们:

  • Trapit Bansal —— 在「思维链」上的强化学习领域具有开创性成果,OpenAI 的 O 系列模型联合创造者。
  • Shuchao Bi —— GPT-4o 语音模式与o4-mini的联合创造者,曾在 OpenAI 负责多模态模型的后训练工作。
  • Huiwen Chang —— GPT-4o 图像生成功能联合创造者,曾在 Google Research 发明 MaskGIT 和 Muse 文本生成图像架构。
  • Ji Lin —— 参与开发 o3/o4-mini、GPT-4o、GPT-4.1、GPT-4.5、4o 图像生成和Operator推理系统。
  • Joel Pobar —— 曾在Anthropic从事模型推理工作,此前在 Meta 工作 11 年,参与 HHVM、Hack、Flow、Redex、性能工具和机器学习等项目。
  • Jack Rae —— 负责 Gemini 预训练技术以及 Gemini 2.5 的推理部分,曾主导DeepMind 早期的 Gopher 和 Chinchilla 大语言模型。
  • Hongyu Ren —— GPT-4o、4o-mini、o1-mini、o3-mini、o3 和 o4-mini 的联合创造者,曾在 OpenAI 领导后训练团队。
  • Johan Schalkwyk —— 前 Google Fellow,Sesame 早期贡献者,Maya 项目技术负责人。
  • Pei Sun —— 曾在 Google DeepMind 从事 Gemini 模型的后训练、编程和推理工作,还打造了 Waymo 过去两代感知模型。
  • Jiahui Yu —— o3、o4-mini、GPT-4.1 和 GPT-4o 的联合创造者,曾领导 OpenAI 感知团队,并共同领导 Gemini 的多模态开发。
  • Shengjia Zhao —— ChatGPT、GPT-4、所有 mini 模型、4.1 和 o3 的联合创造者,曾领导 OpenAI 合成数据团队。

我对我们在 Llama 4.1 和 4.2 模型上的规划进展感到非常兴奋。这些模型支持 Meta AI 的核心功能,目前已在我们多个应用中拥有超过 10 亿月活用户,并被越来越多 Meta 内部的 AI 助手所采用,用于提升我们的产品与技术。我们将继续深入开发这些模型。

与此同时,我们也将着手启动下一代模型的研究,希望在未来一年左右走到行业最前沿。过去几个月,我会见了 Meta 内部顶尖人才、其他 AI 实验室以及一些有前景的初创公司,以组建这个小而精的核心团队。我们仍在完善这个团队,并会邀请AI部门的更多成员加入这个实验室。

Meta 在将超级智能推向世界方面具备独特优势。我们有强大的业务基础,能够建设远超小型实验室的计算能力;我们在打造覆盖数十亿用户的产品方面经验丰富;我们也正引领并开拓增长迅速的 AI 眼镜与可穿戴设备市场。

此外,我们的公司结构也赋予了我们更大的决心和行动力。我相信,这波人才引入和模型并行研发的方式,将使我们有望真正实现「人人拥有个人超级智能」的承诺。

接下来几周,还会有更多出色的人才加入这个团队,敬请期待。我迫不及待地想全身心投入这项工作了。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


智能眼镜的重点,并不在智能|硬哲学

作者 马扶摇
2025年6月30日 18:31

爱范儿关注「明日产品」,硬哲学栏目试图剥离技术和参数的外衣,探求产品设计中人性的本源。

作为上周四小米「人车家全生态发布会」上唯二由雷总亲自发布的产品,起售价 1999 的小米 AI 眼镜可谓赚足了眼球。原因也很简单:当小米决定入场一个产品领域时,往往就是这个领域大众化的开端。

虽然名字里带着一个 AI ,小米也宣布过小米 AI 眼镜可以通过手机接入满血版的超级小爱模型,但是绝大多数人(包括我身边的朋友)对于小米眼镜的关注点其实都不在人工智能上,而是一个我们习以为常十多年的功能:拍照。

图|微博 @小米公司

我们不需要的人工智能

「AI」仿佛是二十一世纪二十年代一个避不开的话题,早已经脱离了它曾经技术名词的概念,反而变成了一种营销词汇。然而在 AI 产品浪潮已经席卷了五年多之后,我们对于电子产品的日常使用方式却并没有发生什么根本性的转变。

原因也很简单:现阶段的 AI 并没有一个非常具体的入口,值得人们在已经形成的日常使用习惯中插入它。哪怕用户引导能力强如苹果,也整出过 Visual Intelligence 这种不明所以的烂活:

2024 年 iPhone 16 发布会上这则舍近求远的 Apple Intelligence 演示片最终成为了集体吐槽对象

不幸的是,从现阶段的硬件产品来看,各类 AI 智能眼镜也不会成为那个「具体的入口」。

更何况考虑到硬件规模,真正的端侧 AI 想要做进手机都十分困难,遑论眼镜这种限制极大的硬件了——从产品分类上讲,叫现在这些智能眼镜是「能够快速调用手机智能助手的蓝牙耳机」都更贴切一些。

因此在现阶段,虽然我们仍然将这一品类的产品称为「智能眼镜」,但它的核心竞争力永远都不会取决于是否智能,而是在于它为一些我们日常的活动带来了全新的视角。

所以,当我们放下 Meta AI、小爱同学,还有那个不知道能不能等来的 Apple Intelligence,再回过头来重新看看智能眼镜,我们才能看清它的脉络和存在意义。

我们很需要的 POV 相机

长久以来,POV(Point of view,即第一人称)视角一直是日常拍摄视频或者 vlog 时比较难处理的。在传统的电影工业中,最终的解决方案往往就是这样一套巨大的设备:

但是绝大多数普通消费者既不需要这么笨重坚固的设备,也用不上那么极致的画质,因此后面便出现了各种更加轻便的固定方案,比如挂脖和胸带,可以将运动相机——或者干脆把 iPhone ——固定到接近第一人称视角的位置拍摄:

但这些轻量化的方案也并不是完美的,毕竟对于普通消费者来说,额外多带一件装备也是太多了。无论是 GoPro 的胸带还是 Insta360 的帽夹,都是一部单独的相机和需要专门携带的配件,在现实的使用环境中既不能一直佩戴、也不能及时查看素材,更需要时刻照看着。

这个时候,一副能够拍照录像、兼顾开放式耳机,同时还能满足普通眼镜功能的的智能眼镜,就顺理成章的出现了,而这也正是 Meta 在 2023 年联合雷朋所做的。

如果只看拍摄和录像规格,Ray-Ban Meta 能够录出来的东西放在 iPhone 旁边简直没法看,最长三分钟的 1080P 30 帧的竖向视频,或者用 720P 进行最长 30 分钟的直播。毫不夸张的说,把 iPhone 15 咬在嘴里录出来的视频也比 Ray-Ban Meta 高到不知道哪里去了。

然而问题就在这里:人不能一直咬着/挂着 iPhone 录视频,但可以(并且已经)一直佩戴眼镜了。

更重要的是,Ray-Ban Meta 的录制规格对于它所面向的潜在消费者来说其实是完全够用的——在有充足光照的环境下,智能眼镜拍出来的东西发发朋友圈或者 Reels 完全不会显得画质陡降:

Ray-Ban Meta 样张|PetaPixel

此外,智能眼镜拍摄视频所带来的沉浸感也是其他随身拍摄设备难以企及的。毕竟眼镜作为人们身上最靠近眼睛的设备,所拍摄出来的视角自然也是最接近第一人称的。除非将 iPhone 挡在脸前,或者类似电影工业中那样把相机挂在鼻子前面,否则很难实现类似的视角。

这种时候,智能眼镜的另一大属性就体现出来了:它是一种「非侵入式」的拍摄设备。眼镜作为一种日常配饰,并不需要额外在身上挂什么东西,甚至对很多人来说只是换了一副不同的镜架而已,即使佩戴上也不会干扰行动:

2025 年美国曲棍球联盟全明星技巧赛上,体育直播平台 FloSports 就给球员配发了 Meta 眼镜用于直播进球视角

因此,这样一副非侵入式、能够满足最基本的画质要求,同时可以提供无出其右的 POV 视角的智能眼镜,对于普通消费者来说,就意味着既不需要复杂的设备,也不需要硕大的肌肉,更不需要拷卡导素材之类的繁琐流程,直接用手点点眼镜就能录出一段身临其境的视频,同时还能顺便听着歌:

眼镜 + 手机的录像组合在将来一定会越来越常见

这差不多就是在 2025 年,各家手机厂商都在疯狂卷影像的大背景下,最让人意想不到、最具未来感的使用方式了。

智能眼镜的路线之争

在小米 AI 眼镜的发布会后,其实还有一部分人表示了一定程度的失望,因为在很多先期的传言和消费者的期盼中,小米要推出的是一款带显示功能的智能眼镜。

这实际上是一个非常好玩的问题,因为在「智能眼镜究竟应不应该包含显示功能」这个问题之前,还有一个先决条件:我们应该怎样定义智能眼镜?

这何尝不是一种智能的眼镜?

目前在电商平台上可以直接买到的,「以眼镜的形态」存在的智能产品其实非常多——毕竟如果硬要说的话,苹果 Vision Pro 也可以是以加厚滑雪护目镜的形式存在的。

在这个赛道里面,从小米到华为,再到雷鸟、ROKID、Xreal 等等,都会给自家的多功能眼镜产品打上一个「智能眼镜」的标签。然而这些眼镜的功能和使用方式却天差地别,唯一的共性就是能够戴在脸上而已。

因此,在前文的语境下,对于类似小米和 Meta 这种形态与功能的智能眼镜来说,我们可以提出这样一个定义:

只有在不通电的情况下,仍然可以正常佩戴、作为传统眼镜(屈光或墨镜)使用,不会对身体活动产生影响的眼镜类智能产品,才可以被叫做智能眼镜。

这样定义的目的,主要是为了去除类似魅族 StarV View、雷鸟 Air 3s、ROKID Max2 之类的「智能 AR 眼镜」。因为无论从具体功能还是能否断电使用上看,这一类设备都属于「长得像眼镜的轻便型 AR 头戴显示器」,像屏幕一样显示内容原本就是它们的主要功能。

类似雷鸟 Air 3s Pro 这种 AR 头显,由于显示屏会遮挡视线无法作为普通眼镜使用,因此不属于智能眼镜|充电头网

这样的定义是很有必要的,因为虽然 AR 头显和智能眼镜随着技术进步,外观形态正在趋同,却有着截然不同的功能侧重:是为了保证显示效果的沉浸感而牺牲作为普通眼镜的功能,还是努力在普通眼镜的形态上添加智能功能,这直接决定了产品的本质是什么东西。

因此,在确定了「智能眼镜」的定义之后,我们再回来看看智能眼镜是否应该包含显示功能的问题。从目前市面上已知的产品来看,智能眼镜的技术发展道路分成了很清晰的三条:

– 不含任何显示功能,使用纯语音交互的智能眼镜,以 Ray-Ban Meta、小米 AI 眼镜、雷鸟 V3 为代表。

– 具备单色纯文字显示功能,可以用来显示提词器、通知、导航等简单文字化信息的智能眼镜,包括魅族 StarV Air 和 Rokid Glasses 。

– 具备彩色屏幕和图像显示能力,拥有完整图形化界面的智能眼镜,比如雷鸟 X3 Pro,以及谷歌在 Google I/O 上演示的 Android XR 验证机。

虽然这三条技术道路上各自都有已经商品化的产品,价格涵盖从 1999 到 9999,但实际上智能眼镜的技术发展脉络基本上就是沿着这条道路前行的,甚至谷歌在十二年前就已经尝试过全彩显示了:

换句话说,「显示功能」一定会在未来成为智能眼镜的必争之地,在激光全息、高分辨率光波导等等显示技术成熟的背景下,智能眼镜和眼镜形态的 AR 头显在最终会实现融合,变成一套真正的可日常佩戴的显示设备。

不过在现阶段,智能眼镜类产品更多还是作为一种手机的延伸,逐渐将一部分手机的交互和拍摄功能带到更加第一视角的位置上,在最本质的层面上是一种人体的增强设备。

此前,智能眼镜受制于硬件的发展,一直没能跨过「能日用」这道门槛,只是作为一个「刻奇」的智能设备。然而现在依托着手机供应链的逐渐成熟,智能眼镜在实用性上基本上完成了蜕变——比如虽然功能和体积类似,小米的眼镜可以靠着金沙江电池的技术实现 Ray-Ban Meta 大约两倍的续航,实用程度毫无疑问是更上一层楼的。

Ray-Ban Meta 使用的是一块 160mAh 的电池|知乎 @我爱音频网

在这样的基础上,我们可以猜测:2025、2026 和 2027 将会是智能眼镜市场开始蓬勃和竞争最激烈的三年,以语音交互为主的 AI 并不会成为智能眼镜的主要卖点,反而是拍照和音乐功能会继续发展,最终成为消费者购买意愿的主要组成部份。

因此,无论是智能眼镜的高集成度、小型传感器对于拍照和视频算法的依赖,未来在显示技术上的突破,以及最重要的:与硬件生态中的主力——即手机——的联动,这些技术指标最终都在暗示着一个结果:我们一定会看到更多手机厂商加入这个市场。

图|Screen Rant

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Flutter AI 工具包:集成 AI 聊天功能到 Flutter App

作者 Bowen_Jin
2025年6月29日 21:00

image.png

主要特点

  1. 多轮聊天:自动维护聊天历史,保持多轮交互的语义连贯性
  2. 流式响应渲染:实时逐字显示 AI 回复,提升交互体验
  3. 富文本显示:支持 Markdown 解析、代码块高亮、链接识别等
  4. 语音输入:使用语音输入prompt。
  5. 多媒体输入:支持发送图片、文件等附件,AI 可识别处理
  6. 自定义样式:提供自定义样式,以匹配App设计。
  7. 聊天序列化/反序列化:存储和检索App会话之间的对话。
  8. 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  9. 可插拔LLM支持:实现一个简单的界面来插入自定义LLM。
  10. 跨平台支持:兼容Android、iOS、Web和macOS平台。

demo效果

image.png

源代码可在GitHub上找到

安装

依赖项添加到pubspec.yaml文件中

dependencies:
  flutter_ai_toolkit: ^latest_version
  google_generative_ai: ^latest_version # 使用Gemini
  firebase_core: ^latest_version        # 使用Firebase Vertex AI

Gemini AI配置

要使用Google Gemini AI,请从Google Gemini AI Studio获取API密钥

还需要选择一个Gemini model。

import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        body: LlmChatView(
          provider: GeminiProvider( // Gemini 服务提供商
            model: GenerativeModel( // Gemini model
              model: 'gemini-2.0-flash',
              apiKey: 'GEMINI-API-KEY', // Gemini API Key
            ),
          ),
        ),
      );
}

GenerativeModel类来自google_generative_ai软件包。GeminiProvider将Gemini AI插入到LlmChatView,LlmChatView是顶级Widget,与您的用户提供基于LLM的聊天对话。

Vertex AI configuration

另外一个AI服务是Firebase的Vertex AI。不需要API密钥,并用更安全的Firebase取代它。要在项目中使用Vertex AI,请按照 Get started with the Gemini API using the Vertex AI in Firebase SDKs 中描述的步骤进行操作。

完成后,使用flutterfire CLI工具将新的Firebase项目集成到您的Flutter App中,如Add Firebase to your Flutter app文档中所述。

按照这些说明操作后,您就可以在Flutter App中使用Firebase Vertex AI了。

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

// ... other imports

import 'firebase_options.dart'; // from `flutterfire config`

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const App());
}

在Flutter App中正确初始化Firebase后,可以创建Vertex provider的实例了:

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        // create the chat view, passing in the Vertex provider
        body: LlmChatView(
          provider: VertexProvider(
            chatModel: FirebaseVertexAI.instance.generativeModel(
              model: 'gemini-2.0-flash',
            ),
          ),
        ),
      );
}

FirebaseVertexAI类来自firebase_vertex ai软件包。构建VertexProvider类,将Vertex AI暴露给LlmChatView。不需要提供API密钥。这些都作为Firebase项目自动处理了。

LlmChatView

LlmChatView Widget 是AI Toolkit提供的互动聊天组件。支持如下功能

  • 多行文本输入:允许用户在输入prompt时粘贴长文本或插入新行。
  • 语音输入:允许用户使用语音输入prompt
  • 多媒体输入:允许用户拍照和发送图像和其他文件类型prompt。
  • 图像缩放:允许用户放大图像缩略图。
  • 复制到剪贴板:允许用户将消息或LLM响应的文本复制到剪贴板。
  • 消息编辑:允许用户编辑最新的消息以重新提交到LLM。
  • 支持Material 和 Cupertino两种设计样式

多行文本输入

语音输入

多媒体输入

图片缩放

点击能缩放图片

复制到剪贴板

文字编辑

长按文字, 弹出编辑菜单

支持Material and Cupertino两种设计样式

额外的功能

  • 欢迎信息:向用户显示初始问候。
  • prompt建议:向用户提供预定义的提建议prompt,以引导互动。
  • 系统指令:让 AI 系统明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。
  • 管理历史记录:每个LLM Provider都允许管理聊天记录,用于清除、动态更改和在会话之间存储聊天状态。
  • 聊天序列化/反序列化:存储和检索App会话之间的对话。
  • 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  • 自定义样式:定义独特的视觉样式,以将聊天外观与整个App相匹配。
  • 自定义LLM Provider:构建自定义LLM Provider,将聊天与您自己的模型后端集成。
  • 重新路由提示:调试、记录或重新路由消息,旨在让Provider动态跟踪问题或路由提示。

欢迎信息

自定义欢迎消息

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!', //初始化LlmChatView的欢迎消息:
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

prompt建议

没有聊天记录时,提供一组建议的prompt

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         suggestions: [
           'I\'m a Star Wars fan. What should I wear for Halloween?',
           'I\'m allergic to peanuts. What candy should I avoid at Halloween?',
           'What\'s the difference between a pumpkin and a squash?',
         ], /// 建议列表
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

系统指令

让 AI 明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。

例如,食谱示例App使用systemInstructions参数来定制LLM,以专注于根据用户的说明提供食谱:

class _HomePageState extends State<HomePage> {
  ...
  // create a new provider with the given history and the current settings
  LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
      history: history,
        ...,
        model: GenerativeModel(
          model: 'gemini-2.0-flash',
          apiKey: geminiApiKey,
          ...,
          systemInstruction: Content.system('''
You are a helpful assistant that generates recipes based on the ingredients and 
instructions provided as well as my food preferences, which are as follows:
${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences}

You should keep things casual and friendly. You may generate multiple recipes in a single response, but only if asked. ...
''', /// 系统指令
          ),
        ),
      );
  ...
}

历史记录管理

访问history属性查看或设置历史记录:

void _clearHistory() => _provider.history = [];

使用旧的历史来创建新的Provider:

class _HomePageState extends State<HomePage> {
  ...
  void _onSettingsSave() => setState(() {
        // 迁移旧的历史记录到新的供应商
        final history = _provider.history.toList();
        _provider = _createProvider(history);
      });
}

_createProvider方法创建了一个具有上一个Provider历史记录和新用户首选项的新Provider。这对用户来说是无缝的;他们可以继续聊天,但现在LLM会考虑他们的新食物偏好,给他们回复

class _HomePageState extends State<HomePage> {
  ...
  // 根据给定的历史记录和当前设置创建一个新的提供者
  LlmProvider _createProvider([List<ChatMessage>? history]) =>
    GeminiProvider(
      history: history,
      ...
    );
  ...
}

Chat序列化/反序列化

要在App会话之间保存和恢复聊天记录,需要能够对每个用户prompt(包括附件)和每个 LLM 响应进行序列化和反序列化。 两种消息(用户prompt和LLM响应)都暴露在ChatMessage类中。 序列化可以通过使用每个ChatMessage实例的toJson方法来完成。

Future<void> _saveHistory() async {
  // 获取最新的历史
  final history = _provider.history.toList();

  // 保存历史消息
  for (var i = 0; i != history.length; ++i) {
    // 文件存在旧忽略
    final file = await _messageFile(i);
    if (file.existsSync()) continue;

    // 新消息保存到磁盘
    final map = history[i].toJson();
    final json = JsonEncoder.withIndent('  ').convert(map);
    await file.writeAsString(json);
  }
}

同样,要反序列化,使用ChatMessage fromJson方法:

Future<void> _loadHistory() async {
  // 从磁盘读取历史记录
  final history = <ChatMessage>[];
  for (var i = 0;; ++i) {
    final file = await _messageFile(i);
    if (!file.existsSync()) break;

    final map = jsonDecode(await file.readAsString());
    history.add(ChatMessage.fromJson(map));
  }

  /// 设置历史记录
  _provider.history = history;
}

自定义响应Widget

默认聊天视图显示的 LLM 响应格式为 Markdown。可以创建一个自定义Widget来显示您的App风格的样式:

设置LlmChatView的responseBuilder参数:

LlmChatView(
  provider: _provider,
  welcomeMessage: _welcomeMessage,
  responseBuilder: (context, response) => RecipeResponseView(
    response,
  ),
),
class RecipeResponseView extends StatelessWidget {
  const RecipeResponseView(this.response, {super.key});
  final String response;

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[];
    String? finalText;

    // 收到LLM的回复后即时生成内容,因此目前无法得到完整的回复,添加一个按钮以便将食谱添加到列表中
    try {
      final map = jsonDecode(response);
      final recipesWithText = map['recipes'] as List<dynamic>;
      finalText = map['text'] as String?;

      for (final recipeWithText in recipesWithText) {
        // extract the text before the recipe
        final text = recipeWithText['text'] as String?;
        if (text != null && text.isNotEmpty) {
          children.add(MarkdownBody(data: text));
        }

        // 提取食谱
        final json = recipeWithText['recipe'] as Map<String, dynamic>;
        final recipe = Recipe.fromJson(json);
        children.add(const Gap(16));
        children.add(Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(recipe.title, style: Theme.of(context).textTheme.titleLarge),
            Text(recipe.description),
            RecipeContentView(recipe: recipe),
          ],
        ));

        // 添加按钮将食谱添加到列表中。
        children.add(const Gap(16));
        children.add(OutlinedButton(
          onPressed: () => RecipeRepository.addNewRecipe(recipe),
          child: const Text('Add Recipe'),
        ));
        children.add(const Gap(16));
      }
    } catch (e) {
      debugPrint('Error parsing response: $e');
    }

    ...

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: children,
    );
  }
}

自定义样式

使用LlmChatView构造函数的style参数来设置自己的样式,包括背景、文本字段、按钮、图标、建议等默认样式:

LlmChatView(
  provider: GeminiProvider(...),
  style: LlmChatViewStyle(...),
),

万圣节主题演示App

没有UI的聊天

不使用聊天视图也能访问Provider接口。

class _EditRecipePageState extends State<EditRecipePage> {
  ...
  final _provider = GeminiProvider(...);
  ...
  Future<void> _onMagic() async {
    final stream = _provider.sendMessageStream(
      'Generate a modified version of this recipe based on my food preferences: '
      '${_ingredientsController.text}\n\n${_instructionsController.text}',
    ); // 发送用户偏好食谱设置给llm provider
    var response = await stream.join(); // 获取llm推荐的响应
    final json = jsonDecode(response);

    try {
      final modifications = json['modifications'];
      final recipe = Recipe.fromJson(json['recipe']);

      if (!context.mounted) return;
      final accept = await showDialog<bool>( // 只使用了llm服务,没有使用聊天界面
        context: context,
        builder: (context) => AlertDialog(
          title: Text(recipe.title), // 推荐食谱标题
          content: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text('Modifications:'),
              const Gap(16),
              Text(_wrapText(modifications)), /// 修改的内容
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => context.pop(true),
              child: const Text('Accept'),
            ),
            TextButton(
              onPressed: () => context.pop(false),
              child: const Text('Reject'),
            ),
          ],
        ),
      );
      ...
    } catch (ex) {
      ...
      }
    }
  }
}

重新路由Prompt

设置LlmChatView messageSender来调试、记录或操作聊天视图和底层Provider之间的连接

class ChatPage extends StatelessWidget {
  final _provider = GeminiProvider(...);

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: const Text(App.title)),
      body: LlmChatView(
        provider: _provider,
        messageSender: _logMessage,
      ),
    );

  Stream<String> _logMessage(
    String prompt, {
    required Iterable<Attachment> attachments,
  }) async* {
    // log the message and attachments
    debugPrint('# Sending Message');
    debugPrint('## Prompt\n$prompt');
    debugPrint('## Attachments\n${attachments.map((a) => a.toString())}');

    // 发送消息到provider
    final response = _provider.sendMessageStream(
      prompt,
      attachments: attachments,
    );

    // log response信息
    final text = await response.join();
    debugPrint('## Response\n$text');

    yield text;
  }
}

用于一些高级操作,如动态路由到Provider或检索增强生成(RAG)。

定制LLM Provider

abstract class LlmProvider implements Listenable {
  Stream<String> generateStream(String prompt, {Iterable<Attachment> attachments});
  Stream<String> sendMessageStream(String prompt, {Iterable<Attachment> attachments});
  Iterable<ChatMessage> get history;
  set history(Iterable<ChatMessage> history);
}

任何实现LlmProvider接口的都可以插入聊天视图, 可以是云或本地的

  1. 提供配置支持
  2. 处理历史
  3. 将消息和附件翻译成底层LLM
  4. 调用底层LLM

配置支持

class GeminiProvider extends LlmProvider ... {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    ...
  })  : _model = model,
        ...

  final GenerativeModel _model;
  ...
}

处理历史

历史记录是Provider的重要组成部分

Provider不仅需要允许直接操作历史记录,而且必须在更改时通知Listener。 为了支持序列化和更改Provider参数,必须支持保存历史记录作为构建过程的一部分。

class GeminiProvider extends LlmProvider with ChangeNotifier {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    Iterable<ChatMessage>? history,
    ...
  })  : _model = model,
        _history = history?.toList() ?? [],
        ... { ... }

  final GenerativeModel _model;
  final List<ChatMessage> _history;
  ...

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]); /// 添加到历史记录

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  /// 获取当前的对话历史记录
  @override
  Iterable<ChatMessage> get history => _history;

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }

  ...
}
import 'package:google_generative_ai/google_generative_ai.dart';
...

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...
  static Part _partFrom(Attachment attachment) => switch (attachment) {
        (final FileAttachment a) => DataPart(a.mimeType, a.bytes),
        (final LinkAttachment a) => FilePart(a.url),
      };

  static Content _contentFrom(ChatMessage message) => Content(
        message.origin.isUser ? 'user' : 'model',
        [
          TextPart(message.text ?? ''),
          ...message.attachments.map(_partFrom),
        ],
      );
}

调用LLM

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...

  @override
  Stream<String> generateStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) =>
      _generateStream(
        prompt: prompt,
        attachments: attachments,
        contentStreamGenerator: (c) => _model.generateContentStream([c]),
      );

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  Stream<String> _generateStream({
    required String prompt,
    required Iterable<Attachment> attachments,
    required Stream<GenerateContentResponse> Function(Content)
        contentStreamGenerator,
  }) async* {
    final content = Content('user', [
      TextPart(prompt),
      ...attachments.map(_partFrom),
    ]);

    final response = contentStreamGenerator(content);
    yield* response
        .map((chunk) => chunk.text)
        .where((text) => text != null)
        .cast<String>();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }
}

最终的AI 聊天效果

智能前端之图片识别:用React和Moonshot AI打造图片识别应用

作者 FogLetter
2025年6月29日 07:54

前言:当图片遇见AI

大家好!今天我们要探索一个非常酷炫的前端技术——图片识别。想象一下,用户上传一张图片,我们的前端应用不仅能显示预览,还能通过AI识别图片内容并生成详细描述。这听起来像是未来科技,但其实用React和一些现代API就能轻松实现!

作为前端开发者,我们正处在一个令人兴奋的时代。计算机视觉和自然语言处理的进步让我们可以在浏览器中实现以前只能在科幻电影中看到的功能。本文将带你一步步构建这样一个应用,同时分享一些我在开发过程中的心得和最佳实践。

项目概述

我们要构建的应用具有以下功能:

  1. 用户可以选择并上传本地图片
  2. 实时显示图片预览
  3. 将图片发送到AI API进行分析
  4. 显示AI生成的图片描述

听起来很简单?让我们深入细节,看看如何优雅地实现这些功能。

技术栈选择

  • React:我们的前端框架,提供组件化和状态管理
  • FileReader API:处理本地文件读取
  • Moonshot AI:提供图片识别能力
  • Vite:项目构建工具,支持环境变量管理

严格模式:React的安全网

// StrictModel react 默认启动的严格模式
// 执行一次,测试一次 两次

在React 18+中,严格模式(Strict Mode)默认启用。这是一个非常有用的开发工具,它会:

  • 故意双重调用组件函数(仅在开发环境)
  • 检查过时的API使用
  • 检测意外的副作用

这解释了为什么你可能会在控制台看到某些日志出现两次。这不是bug,而是React在帮助我们提前发现潜在问题。

开发建议:始终保留严格模式,它能帮你捕获许多难以追踪的问题,特别是在使用useEffect时。

环境变量:安全地管理API密钥

console.log(import.meta.env.VITE_API_KEY)

在现代前端开发中,我们经常需要使用API密钥等敏感信息。Vite提供了优雅的环境变量解决方案:

  1. 创建.env文件在项目根目录
  2. 变量必须以VITE_前缀开头
  3. 通过import.meta.env访问

安全提示:永远不要将.env文件提交到版本控制!将其添加到.gitignore中。

状态管理:React的核心

const [content, setContent] = useState('')
const [imgBase64Data, setImgBase64Data] = useState('')
const [isValid, setIsValid] = useState(false)

React的useState hook是我们管理组件状态的主要工具。在这个应用中,我们维护三个状态:

  1. content:存储AI返回的图片描述
  2. imgBase64Data:存储图片的Base64编码
  3. isValid:控制提交按钮的禁用状态

设计原则:保持状态最小化,只存储必要的数据。派生数据应该在渲染时计算。

图片预览:即时反馈的重要性

const updateBase64Data = (e) => {
  const file = e.target.files[0];
  if(!file) return;
  
  const reader = new FileReader();
  reader.readAsDataURL(file);
  
  reader.onload = () => {
    setImgBase64Data(reader.result)
    setIsValid(true)
  }
}

图片上传和处理可能很慢,良好的用户体验要求我们提供即时反馈。这里我们使用FileReader API来实现:

  1. 用户选择文件后,触发onChange事件
  2. 通过e.target.files[0]获取文件对象
  3. 创建FileReader实例
  4. 使用readAsDataURL方法将文件转换为Base64字符串
  5. 转换完成后,更新状态

Base64小知识:Base64是一种用64个字符表示二进制数据的编码方案。它可以将图片数据转换为字符串,方便在JSON中传输。

无障碍访问:为所有人构建

<label htmlFor="fileInput">文件:</label>
<input 
  type="file"
  id='fileInput'
  className='input'
  accept='.jpeg,.jpg,.png,.gif'
  onChange={updateBase64Data}
/>

无障碍访问(A11Y)经常被忽视,但它对用户体验至关重要。这里我们:

  1. 使用labelinput通过htmlForid关联
  2. 为输入添加明确的标签
  3. 限制可接受的图片格式

无障碍提示:屏幕阅读器依赖正确的标签关联来向视障用户描述表单控件。不要忽视这些细节!

与AI API交互:异步编程的艺术

const update = async () => {
  if(!imgBase64Data) return;
  
  const endpoint = 'https://api.moonshot.cn/v1/chat/completions';
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${import.meta.env.VITE_API_KEY}`
  }
  
  setContent('正在生成...')
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'moonshot-v1-8k-vision-preview',
      messages: [
        {
          role: 'user',
          content: [
            {
              type: 'image_url',
              image_url: {
                url: imgBase64Data
              }
            },
            {
              type: 'text',
              text: '请详细描述这张图片的内容'
            }
          ]
        }
      ]
    })
  })
  
  const data = await response.json()
  setContent(data.choices[0].message.content)
}

这是与Moonshot AI API交互的核心代码。让我们分解这个异步过程:

  1. 首先检查是否有图片数据
  2. 设置API端点和请求头(包含认证)
  3. 立即更新状态显示"正在生成..."(即时反馈)
  4. 使用fetch发起POST请求
  5. 请求体包含图片数据和提示文本
  6. 解析响应并更新状态

异步编程选择:我们使用async/await而不是.then链,因为它提供了更线性的代码结构,更易于理解和维护。

错误处理:被忽视的重要部分

虽然示例代码中没有展示,但在生产环境中,我们必须添加错误处理:

try {
  const response = await fetch(endpoint, { /* ... */ });
  if (!response.ok) throw new Error('网络响应不正常');
  const data = await response.json();
  setContent(data.choices[0].message.content);
} catch (error) {
  setContent('识别失败: ' + error.message);
  console.error('API调用失败:', error);
}

最佳实践:总是处理网络请求可能失败的情况,并向用户提供友好的错误信息。

性能优化:减少不必要的渲染

React组件在状态变化时会重新渲染。对于我们的应用,可以做一些优化:

  1. 使用useCallback记忆事件处理函数
  2. 对于大型图片,考虑压缩后再上传
  3. 添加防抖或节流(如果适用)
const updateBase64Data = useCallback((e) => {
  // ...原有逻辑
}, []);

性能提示:React DevTools的Profiler工具可以帮助你识别性能瓶颈。

样式与布局:不只是功能

虽然本文主要关注功能实现,但良好的UI同样重要。我们的CSS可能包含:

.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.preview img {
  max-width: 100%;
  height: auto;
  border-radius: 8px;
}

.input {
  margin: 10px 0;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

UI原则:确保应用在不同设备上都能良好显示(响应式设计),并为交互元素提供视觉反馈。

扩展思路:让应用更强大

这个基础应用可以扩展许多有趣的功能:

  1. 多图片分析:允许用户上传多张图片并比较结果
  2. 自定义提示:让用户输入自己的问题而不仅是描述图片
  3. 历史记录:保存之前的识别结果
  4. 图片编辑:添加简单的裁剪或滤镜功能

总结:前端开发的未来

通过这个项目,我们看到了现代前端开发的强大能力。借助React和现代浏览器API,我们能够:

  1. 处理本地文件
  2. 提供实时预览
  3. 与AI服务交互
  4. 创建响应式和无障碍的界面

图片识别只是计算机视觉在前端的冰山一角。随着WebAssembly和WebGPU等技术的发展,前端将能够处理更复杂的AI任务。

最后的思考:作为开发者,我们不仅要关注功能的实现,还要考虑用户体验、性能和可访问性。每一个细节都可能影响用户对我们产品的感受。

希望这篇文章能激发你对智能前端的兴趣!如果你有任何问题或想法,欢迎在评论区讨论。Happy coding! 🚀


附录:完整代码

import { useState, useCallback } from 'react'
import './App.css'

function App() {
  const [content, setContent] = useState('')
  const [imgBase64Data, setImgBase64Data] = useState('')
  const [isValid, setIsValid] = useState(false)

  const updateBase64Data = useCallback((e) => {
    const file = e.target.files[0];
    if(!file) return;
    
    const reader = new FileReader();
    reader.readAsDataURL(file);
    
    reader.onload = () => {
      setImgBase64Data(reader.result)
      setIsValid(true)
    }
  }, [])

  const update = async () => {
    if(!imgBase64Data) return;
    
    try {
      const endpoint = 'https://api.moonshot.cn/v1/chat/completions';
      const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${import.meta.env.VITE_API_KEY}`
      }
      
      setContent('正在生成...')
      
      const response = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          model: 'moonshot-v1-8k-vision-preview',
          messages: [
            {
              role: 'user',
              content: [
                {
                  type: 'image_url',
                  image_url: {
                    url: imgBase64Data
                  }
                },
                {
                  type: 'text',
                  text: '请详细描述这张图片的内容'
                }
              ]
            }
          ]
        })
      })
      
      if (!response.ok) throw new Error('网络响应不正常');
      const data = await response.json()
      setContent(data.choices[0].message.content)
    } catch (error) {
      setContent('识别失败: ' + error.message)
      console.error('API调用失败:', error)
    }
  }

  return (
    <div className='container'>
      <div>
        <label htmlFor="fileInput">文件:</label>
        <input 
          type="file"
          id='fileInput'
          className='input'
          accept='.jpeg,.jpg,.png,.gif'
          onChange={updateBase64Data}
        />
        <button onClick={update} disabled={!isValid}>提交</button>
      </div>
      <div className="output">
        <div className="preview">
          {imgBase64Data && <img src={imgBase64Data} alt="预览" />}
        </div>
        <div>
          {content}
        </div>
      </div>
    </div>
  )
}

export default App

新手必看,AI编程路上不可避免的Node管理

作者 李想AI
2025年6月27日 17:11

大家好,我是李想。

AI编程真的很火,但经常有朋友在问我Node是什么,经常看见运行这个项目需要Node环境,运行这个MCP又需要Node环境,问我怎么下载,下载哪个版本。

就比如新出的Gemini-cli,前置条件就是必须安装Node.js18的版本。

wechat_2025-06-27_151047_685.png

为了让小白更好的上手AI编程,今天我就给大家写一篇Node基础文章,并推荐一个Node的版本管理工具。

1.Node简介

那Node到底是什么?

wechat_2025-06-27_165300_332.png

官方介绍:Node.js 是一个利用高性能 V8 引擎在服务器端运行 JavaScript 的平台,其独特的事件驱动和非阻塞 I/O 设计使它成为构建高性能、可扩展网络应用的理想选择,并拥有极其丰富的 npm 生态系统支持。

其实不重要,你不需要掌握Node.js这门语言,只是因为有Node.js有着庞大的开源库生态系统 npm-它是世界上最大的软件注册中心,提供数百万个可重用的代码包(模块)

所以我们需要通过Node.js的 npm 去下载相关的依赖包(工具),这样我们才能更好的去接触AI编程,接触Github众多的项目。

2.下载安装

网址:nodejs.org/zh-cn

来到我们的官网,点击Install下载

图片.pngwechat_2025-06-27_165344_284.png

在这里你可以选择Node版本,你的系统,然后点击.msi开始下载

图片.pngwechat_2025-06-27_153704_231.png

下载完毕后运行msi文件,一直next。

图片.pngwechat_2025-06-27_153907_065.png

这里可以把Node安装到C盘以外的地方,然后一直next

wechat_2025-06-27_153957_706.png

这里点击Install就可以把Node安装到本地了。

图片.png

wechat_2025-06-27_154040_153.png

安装完毕后Win+R开启命令列,输入cmd打开终端。

图片.png

wechat_2025-06-27_154339_921.png

然后输入node -v和npm -v。

图片.pngwechat_2025-06-27_154520_986.png

成功显示版本信息就说明我们的Node安装成功了!

3.Node版本管理工具

给大家介绍了Node,实在有必要给大家说说Node的版本管理工具-nvm

网址:github.com/coreybutler…

wechat_2025-06-27_161112_751.png

为什么需要对Node版本进行管理呢?大家通过Node去下载项目依赖后会经常遇见项目启动报错,启动不起来。

很多时候就是因为Node版本的问题,因为有些项目他需要的Node版本可能是18。

有点项目又可能是22或者其他的,但是我们系统下载Node的版本只有一个,总不能跑一个相对就重新下一个Node版本吧。

这时候,Node版本管理工具nvm就很重要了,它可以下载多种node版本,在不同的项目中切换不同的Node版本,这样在下载项目的依赖就不会出错了!

网址:github.com/coreybutler…

来到我们的下载页面

图片.pngwechat_2025-06-27_161212_789.png

选择红框中的exe版本下载。在安装之前大家记得把之前下载的Node给卸载了,没安装过就不用管。

一直下一步,这里的路径记得不用使用中文。

图片.pngwechat_2025-06-27_161326_808.png

安装完毕后启动win+r,cmd启动命令输入nvm -v。

图片.png

wechat_2025-06-27_161600_008.png

成功显示就说明成功。

之后我们可以通过nvm install 来下载指定的Node版本,比如nvm install 18.20.7。

wechat_2025-06-27_162212_991.png

通过nvm list查看我们安装了哪些版本,比如我这里就显示了我下载了两个版本,*代表现在使用的是18.20.7版本。

图片.png

wechat_2025-06-27_162212_991.png

切换的时候同nvm use 23.0.0 就可以切换成功了。

图片.png

wechat_2025-06-27_162328_542.png

最后再附上命令一览表

命令 说明
nvm install <version> 安装指定版本的 Node.js
nvm install lts 安装最新的 LTS(长期支持)版本
nvm use <version> 切换使用指定的 Node.js 版本
nvm list 查看所有已安装的 Node.js 版本
nvm ls-remote 列出所有远程可用的 Node.js 版本
nvm uninstall <version> 卸载指定的 Node.js 版本
nvm alias default <version> 设置默认使用的 Node.js 版本
nvm current 显示当前正在使用的 Node.js 版本
nvm on 启用 nvm 版本管理功能
nvm off 禁用 nvm 版本管理功能
nvm version 查看当前安装的 nvm 版本
nvm proxy [url] 设置或查看下载代理服务器
nvm node_mirror [url] 设置或查看 Node.js 镜像源
nvm npm_mirror [url] 设置或查看 npm 镜像源
nvm reinstall-packages <ver> 将当前 npm 包重新安装到另一个 Node 版本
nvm list available 显示可供安装的所有版本(Windows 专用)
nvm root [path] 设置或查看 nvm 的安装路径
nvm cache dir 显示 nvm 的缓存目录路径
nvm cache clear 清空 nvm 的缓存

4.结语

今天的文章就到这里了,恭喜我们又掌握了编程路上的一个小知识。

图片.png

入门状态机解析器

作者 风骨
2025年6月26日 11:14

大家好,我是风骨,最近在项目中涉及到 「状态机解析器」 的应用,今天便以这个为话题同大家进行分享,相信可以帮助一些小伙伴解决项目难题。

接下来让我们一起从前端角度来理解状态机,掌握的它的设计思想和应用场景。


附 - 毛遂自荐:笔者当前离职在看工作机会,各位小伙伴如果有合适的内推机会,期待您的引荐 🤝

个人简介:男,27岁,专科 · 计算机专业,6 年前端工作经历,从事过 AIGC、低代码、动态表单、前端基建 等方向工作,base 北京 - 高级前端岗,vx 联系方式:iamcegz

1、认识状态机

状态机全称是有限状态机(Finite State Machine, FSM) ,是一种程序设计思想:分析需求,先定义好会出现的 case 状态,然后从初始状态开始,执行对应的状态函数完成该状态的工作,同时推导出下一个状态。继续重复上述操作,直到结束。

在内容解析场景下,状态机的核心概念:

  1. 状态(State): 定义需要处理的状态,一个状态机至少要包含两个状态。以 <button> 代码标签为例,状态可以定义为:< 左括号button 标签名> 右括号 三个状态;
  2. 状态处理函数(State handle) :每个状态都会对应一个处理函数,完成该状态要做的事情。如在标签名状态下,收集得到标签名称 button
  3. 转换(Transition): 定义从一个状态到另一个状态的变化规则,逻辑可以编写在状态处理函数中,推导出下一个状态。如 < 左括号 的下一个状态是解析标签名

采用状态机的优势在于:

  1. 封装了每一种状态的转换规则,清晰且便于维护,解决 if else 逻辑分支嵌套严重问题;
  2. 可扩展性强,新增状态不需要改动核心逻辑,新增一个状态转换函数即可;

在前端,状态机思想常常被应用在 代码解析器 上,如:

  1. 词法分析器,类似 Babel 工作原理,将一段字符串 code 解析成 token 组成的数组 tokens
  2. 流式内容提取,AIGC 生成的流式内容的解析。

2、应用于词法分析器

2.1、需求描述

Babel 编译过程首要阶段是 Parsing 解析,分为「词法分析」和「语法分析」 两个子阶段。我们的需求是采用状态机思想实现「词法分析」

以下面 JSX 代码为例:

<h1 id="title"><span>hello</span>world</h1>

经过词法分析,把它们分割成由一个个 token 组成的数组 tokens。输出结果如下:

PS:token 是指代码在词法分析阶段,将原始代码分割成一个个代码碎片,可以是 标签符合、名称、属性 等。

[
  { type: 'LeftParentheses', value: '<' },
  { type: 'JSXIdentifier', value: 'h1' },
  { type: 'AttributeKey', value: 'id' },
  { type: 'AttributeValue', value: '"title"' },
  { type: 'RightParentheses', value: '>' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'JSXIdentifier', value: 'span' },
  { type: 'RightParentheses', value: '>' },
  { type: 'JSXText', value: 'hello' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'BackSlash', value: '/' },
  { type: 'JSXIdentifier', value: 'span' },
  { type: 'RightParentheses', value: '>' },
  { type: 'JSXText', value: 'world' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'BackSlash', value: '/' },
  { type: 'JSXIdentifier', value: 'h1' },
  { type: 'RightParentheses', value: '>' }
]

2.2、思路分析

首先,根据输入的 JSX,分析出我们需要定义哪些状态,初始状态定义为 Start,其他状态基本和 token 类型一一对应:一个 token 类型代表一类状态。

enum ParserState {
  Start = "Start", // 开始
  LeftParentheses = "LeftParentheses", // <
  RightParentheses = "RightParentheses", // >
  JSXIdentifier = "JSXIdentifier", // 标识符(标签名称)
  AttributeKey = "AttributeKey", // 属性的 key
  AttributeValue = "AttributeValue", // 属性的值
  JSXText = "JSXText", // 文本内容
  BackSlash = "BackSlash", // 反斜杠 /
}

接着,我们思考如何组织处理这些状态:遍历每一个字符,让当前状态对应的处理函数去执行工作。因此 Parser 的框架结构可以这样搭建:

type Token = { type: ParserState | ""; value: string }

class Parser {
  private tokens: Token[] = []
  private currentToken: Token = { type: "", value: "" }
  // 当前状态
  private state: ParserState = ParserState.Start
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.Start]: this.handleStart,
    ... 其他的状态处理函数
  }

  constructor() {}

  private handleStart(char: string) {...}

  public parse(input: string) {
    // 遍历每一个字符,交给 state 对应的状态函数处理
    for (let char of input) {
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
    }
    return this.tokens;
  }
}

其中:

  • state 定义了当前所处的状态;
  • handlers 定义了所有状态对应的处理函数集合;

最后,我们要明确每个状态函数的工作内容:完成两件事情

  1. 完成当前状态下要做的工作:创建 token
  2. 根据 chat 字符类型,推算出下一步该如何走,即推算出下一个状态。

比如,最初的 state = "Start",它的状态函数要做的事情是:匹配 < 字符。

  • 1)创建 < 类型的 token
  • 2)推算出下一个状态为 state = "LeftParentheses"
private handleStart(char: string) {
  if (char === "<") {
    // 状态函数要做的工作:创建存储 token 的容器
    this.emit({ type: ParserState.LeftParentheses, value: "<" })
    // 推算出下一个状态
    this.state = ParserState.LeftParentheses
  } else {
    // 第一个字符不是 < 抛出错误
    throw new Error(`第一个字符必须是 <,得到的却是 ${char}`)
  }
}

再往下走,state = "LeftParentheses" 的状态函数要做的事情是:匹配是否是普通字符(字母、数字),若是字符说明是标签名称如 h1

  • 1)记录当前字符
  • 2)推算出下一个状态为 state = "JSXIdentifier"
const LETTERS = /[A-Za-z0-9]/
private handleLeftParentheses(char: string) {
  // 是否字母,如果是,进入标签名称状态(标识符状态收集)
  if (LETTERS.test(char)) {
    this.currentToken.type = ParserState.JSXIdentifier
    this.currentToken.value += char
    this.state = ParserState.JSXIdentifier
  }
}

再往下走,state = "JSXIdentifier" 的状态函数要做的事情是:

  • 1)如果 chat 是普通字符(字母、数字),记录当前字符即可,不用更改状态,下一个字符还是交给它处理;

  • 2)如果 chat 是空格,这说明标签名称解析完成了,

    • 1)创建标签名称对应的 token;
    • 2)推算出下一个状态为 state = "AttributeKey"
private handleJSXIdentifier(char: string) {
  if (LETTERS.test(char)) {
    this.currentToken.value += char // 继续收集标识符,且不用更新状态
  } else if (char === " ") {
    // 收集标识符过程中遇到了空格,进入标签结束状态
    this.emit(this.currentToken)
    this.state = ParserState.AttributeKey
  }
}

到这里,目标代码中的 <h1 对应 tokens 已解析完成,以此类推。总结一下就是不断推测下一个状态要做什么事情< 后面是标签名称,标签名称后面可能是属性名,属性名后面可能是 = = 后面可能是属性值 ...

2.3、具体实现

// 定义解析状态,同时一些枚举值也会作为 token 类型(有一些一一对应)
enum ParserState {
  Start = "Start", // 开始
  LeftParentheses = "LeftParentheses", // <
  RightParentheses = "RightParentheses", // >
  JSXIdentifier = "JSXIdentifier", // 标识符(标签名称)
  AttributeKey = "AttributeKey", // 属性的 key
  AttributeValue = "AttributeValue", // 属性的值
  TryLeaveAttribute = "TryLeaveAttribute", // 试图离开属性,若后面没有属性则会离开
  JSXText = "JSXText", // 文本内容
  BackSlash = "BackSlash", // 反斜杠 /
}

// 正则匹配字符
const LETTERS = /[A-Za-z0-9]/

type Token = { type: ParserState | ""; value: string }

class Parser {
  private tokens: Token[] = []
  private currentToken: Token = { type: "", value: "" }
  // 当前状态
  private state: ParserState = ParserState.Start
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.Start]: this.handleStart,
    [ParserState.LeftParentheses]: this.handleLeftParentheses,
    [ParserState.RightParentheses]: this.handleRightParentheses,
    [ParserState.JSXIdentifier]: this.handleJSXIdentifier,
    [ParserState.AttributeKey]: this.handleAttributeKey,
    [ParserState.AttributeValue]: this.handleAttributeValue,
    [ParserState.TryLeaveAttribute]: this.handleTryLeaveAttribute,
    [ParserState.JSXText]: this.handleJSXText,
    [ParserState.BackSlash]: this.handleBackSlash,
  }

  constructor() {}

  private emit(token: Token) {
    this.tokens.push({ ...token }) // 添加到 tokens 中
    this.currentToken.type = this.currentToken.value = ""
  }

  private handleStart(char: string) {
    if (char === "<") {
      // 状态函数要做的工作:创建存储 token 的容器
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      // 推算出下一个状态
      this.state = ParserState.LeftParentheses
    } else {
      // 第一个字符不是 < 抛出错误
      throw new Error(`第一个字符必须是 <,得到的却是 ${char}`)
    }
  }

  private handleLeftParentheses(char: string) {
    // 是否字母,如果是,进入标签名称状态(标识符状态收集)
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.JSXIdentifier
      this.currentToken.value += char
      this.state = ParserState.JSXIdentifier
    } else if (char === "/") {
      // 闭合标签,如:</h1>
      this.emit({ type: ParserState.BackSlash, value: "/" })
      this.state = ParserState.BackSlash
    }
  }

  private handleJSXIdentifier(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.value += char // 继续收集标识符,且不用更新状态
    } else if (char === " ") {
      // 收集标识符过程中遇到了空格,进入标签结束状态
      this.emit(this.currentToken)
      this.state = ParserState.AttributeKey
    } else if (char === ">") {
      // 说明此标签已没有要处理的属性
      this.emit(this.currentToken)
      this.emit({ type: ParserState.RightParentheses, value: ">" })
      this.state = ParserState.RightParentheses
    }
  }

  private handleAttributeKey(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.AttributeKey
      this.currentToken.value += char // 继续收集标识符,且不用更新状态
    } else if (char === "=") {
      this.emit(this.currentToken)
      this.state = ParserState.AttributeValue
    }
  }

  private handleAttributeValue(char: string) {
    if (!this.currentToken.value && char === '"') {
      this.currentToken.type = ParserState.AttributeValue
      this.currentToken.value = '"'
    } else if (LETTERS.test(char)) {
      this.currentToken.value += char
    } else if (char === '"') {
      // 说明属性值结束了,存储 token
      this.currentToken.value += '"'
      this.emit(this.currentToken)
      this.state = ParserState.TryLeaveAttribute // 试图离开属性,若后面没有属性则会离开
    }
  }

  private handleTryLeaveAttribute(char: string) {
    if (char === " ") {
      // 如果 char 是空格,说明后面有新的属性,进入属性状态
      this.state = ParserState.AttributeKey
    } else if (char === ">") {
      // 说明开始标签结束了
      this.emit({ type: ParserState.RightParentheses, value: ">" })
      this.state = ParserState.RightParentheses
    }
  }

  private handleRightParentheses(char: string) {
    // 如果是 <,进入标签开始状态
    if (char === "<") {
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      this.state = ParserState.LeftParentheses
    } else {
      // 认为是纯文本,如 world
      this.currentToken.type = ParserState.JSXText
      this.currentToken.value += char
      this.state = ParserState.JSXText
    }
  }

  private handleJSXText(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.value += char
    } else if (char === "<") {
      // 遇到了和文本同级的兄弟标签
      this.emit(this.currentToken) // { type: JSXText, value: 'world' }
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      this.state = ParserState.LeftParentheses
    }
  }

  private handleBackSlash(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.JSXIdentifier
      this.currentToken.value += char
      this.state = ParserState.JSXIdentifier
    }
  }

  public parse(input: string) {
    // 遍历每一个字符,交给 state 对应的状态函数处理
    for (let char of input) {
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
    }
    return this.tokens
  }
}

让我们来测试一下,相信和预期的结果一致。

const sourceCode = `<h1 id="title"><span>hello</span>world</h1>`
const parser = new Parser()
console.log(parser.parse(sourceCode))

3、应用于流式内容提取

3.1、需求描述

当下 AIGC 应用已经非常普遍,AI 问答交互普遍采用流式的形式输出内容。

假设我们有一个 AI 生成组件代码平台,需要从流式内容中提取到代码块内容,实时呈现到页面 CodeIDE 中,该如何实现?

PS:注意,如果是一段完整的内容,可以使用 正则 匹配实现,但这里是流式逐字输出的内容该如何实现?

比如 AI 流式输出内容为:

The generated components code is as follows:

<ComponentFile fileName="App.tsx" isEntryFile="true">
  import Button from "./Button";
  export const App = () => {
    return (
      <Button>按钮</Button>
    )
  }
</ComponentFile>
<ComponentFile fileName="Button.tsx">
  export const Button = ({ children }) => {
    return (
      <button>{children}</button>
    )
  }
</ComponentFile>

The content contains two components: App and Button

现在期望在流式输出过程中匹配到以下内容时,通过事件回调暴露给外部:

  • 匹配到 <ComponentFile fileName="App.tsx" isEntryFile="true"> 时,触发 onOpenTag 事件,并将 tagNamefileNameisEntryFile 等属性通过事件暴露出去;
  • 匹配到 <ComponentFile> 标签内的代码时,触发 onConent 事件,将解析到的代码 code 内容暴露出去;
  • 匹配到 </ComponentFile> 时,触发 onCloseTag 事件;

对应到 调用解析器 的代码示例如下:

let currentFile: {
  name: string
  isEntryFile: boolean
  content: string
} | null = null

const parser = new StreamParser();

parser.onOpenTag = function ({ name, attrs }) {
  if (name === "ComponentFile") {
    const fileName = attrs.fileName as string
    const isEntryFile = attrs.isEntryFile === "true"
    // 定义组件结构
    currentFile = {
      name: fileName,
      isEntryFile,
      content: "",
    }
    console.log("onOpenTag", name, attrs)
  }
}

parser.onContent = function ({ name }, text) {
  if (name === "ComponentFile" && currentFile) {
    // 收集文件内容
    currentFile.content += text
    // TODO... 自定义处理文件逻辑,如将 code content 渲染到 CodeIDE 中
  }
}

parser.onCloseTag = function ({ name }) {
  if (name === "ComponentFile" && currentFile) {
    console.log("onCloseTag", name, currentFile)
    // TODO... 自定义处理文件逻辑
    currentFile = null
  }
}

打印输出示例:

onOpenTag ComponentFile { fileName: 'App.tsx', isEntryFile: 'true' }
onCloseTag ComponentFile {
  name: 'App.tsx',
  isEntryFile: true,
  content: '\n' +
    '  import Button from "./Button";\n' +
    '  export const App = () => {\n' +
    '    return (\n' +
    '      <Button>按钮</Button>\n' +
    '    )\n' +
    '  }\n'
}
onOpenTag ComponentFile { fileName: 'Button.tsx' }
onCloseTag ComponentFile {
  name: 'Button.tsx',
  isEntryFile: false,
  content: '\n' +
    '  export const Button = ({ children }) => {\n' +
    '    return (\n' +
    '      <button>{children}</button>\n' +
    '    )\n' +
    '  }\n'
}

3.2、思路分析

首先,根据输入的流式内容,分析出我们大致需要定义哪些状态。在该场景下,初始状态定义为 TEXT 处理普通文本(如开头和结尾的文本),其他状态的定义用于匹配 <ComponentFile> 标签代码,比如 开闭标签符号 <>、标签名称、属性、属性值、代码内容 等。

// 定义解析器状态枚举
enum ParserState {
  TEXT, // 1)初始状态,标签外的普通文本(如开头和结尾的文本)
  TAG_OPEN, // 2)开始标签,刚遇到 <
  TAG_NAME, // 3)解析标签名,如 ComponentFile
  ATTR_NAME_START, // 4)准备解析属性名
  ATTR_NAME, // 5)解析属性名,如 fileName
  ATTR_VALUE_START, // 6)属性值开始,等待 " 或者 '
  ATTR_VALUE, // 7)解析属性值,如 App.tsx
  CONTENT, // 8)要解析的代码内容,如 import Button from "./Button";
  CONTENT_POTENTIAL_END, // 9)在代码内容中遇到可能的结束标签
  CLOSING_TAG_OPEN, // 10)结束标签,遇到 </
  CLOSING_TAG_NAME, // 11)解析结束标签名
  SELF_CLOSING_START, // 12)自闭合标签开始,遇到 /
}

接着,我们思考如何对流式内容进行解析工作:将流式内容实时写入到 buffer 缓存区中,搭配 position 指针来处理缓冲区中的字符

class StreamParser {
  private buffer: string = "" // 缓冲区,用于存储当前需要解析的内容
  private position: number = 0 // 当前解析到的位置
  ...
  
  // 当前状态
  private state: ParserState = ParserState.TEXT
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.TEXT]: this.handleTextState,
    ...
  }
  
  public write(chunk: string) {
    this.buffer += chunk // 添加到缓冲区
    this.parseBuffer()
  }

  private parseBuffer() {
    while (this.position < this.buffer.length) {
      const char = this.buffer[this.position]
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
      // 移动到下一个字符
      this.position++
    }
    
    // 若存在解析到的 content,通知 onContent 回调
    this.sendPendingContent()
    ...
  }
}

最后,实现状态函数,完成状态函数要处理的工作并推算出下一个状态。

比如,最初的 state = "TEXT",它的状态函数要做的事情是:匹配 < 字符,推算出下一个状态为 state = "TAG_OPEN" 解析 <ComponentFile> 标签:

private handleTextState(char: string) {
  if (char === "<") {
    // 开始一个新标签
    this.state = ParserState.TAG_OPEN
  }
  // 文本状态下,其他非代码块字符忽略处理
}

后面的流程大致和「词法分析器」的解析相似,依次匹配 标签名、属性名、 属性值 等。额外增加的逻辑是:在匹配完成 Open 标签(如 <ComponentFile>)后触发 onOpenTag 事件,匹配完成 Close 标签(如 </ComponentFile>)后触发 onCloseTag 事件。

最后重点说一下 onContent 事件和 content 内容匹配逻辑:

解析器的目的主要是解析出 <ComponentFile>component code</ComponentFile> 中间的 component code。当匹配到 tagName = ComponentFile 时,便开始进入 ParserState.CONTENT 状态收集 content

private parseAsContentTags: string[] = ["ComponentFile"]

handleOpenTag() {
  const tagName = this.currentTagName
  ...
  // 检查是否是 <ComponentFile> 标签
  if (this.parseAsContentTags.includes(tagName)) {
    this.state = ParserState.CONTENT // 开始收集 content 内容
  } else {
    this.state = ParserState.TEXT
  }
}

同时,在收集过程中遇到嵌套标签(比如 <Button>),将不会进入 标签解析 流程,仅作为 content 内容拼接,只有当匹配到 </ComponentFile> 标签时,ParserState.CONTENT 状态才会结束,这时便收集到了 <ComponentFile> 标签中的完整代码。

private handleContentState(char: string) {
  if (char === "<") {
    // 进入潜在闭合标签状态
    this.sendPendingContent()
    this.pendingClosingTag = "<" // 开始收集可能的结束标签
    this.potentialEndTagMatchPos = 1 // 已匹配到"<",下一个应该是"/"
    this.state = ParserState.CONTENT_POTENTIAL_END // 切换状态
  } else {
    // 继续累积内容
    this.pendingContent += char
  }
}

private handleContentPotentialEndState(char: string) {
  // 伪代码表示
  if ("匹配的闭合标签名称" === "</ComponentFile">) {
    this.onCloseTag?.(curTagData)
    this.state = ParserState.TEXT // 更新状态为文本,恢复为最初状态
  }
}

从上面代码可以看出,触发 onContent 事件的时机可以在:1)当前流式 buffer 缓冲区解析完成,2)解析 content 时遇到了闭合标签就执行一次。

3.3、具体实现

// 定义解析器状态枚举
enum ParserState {
  TEXT, // 1)初始状态,标签外的普通文本(如开头和结尾的文本)
  TAG_OPEN, // 2)开始标签,刚遇到 <
  TAG_NAME, // 3)解析标签名,如 ComponentFile
  ATTR_NAME_START, // 4)准备解析属性名
  ATTR_NAME, // 5)解析属性名,如 fileName
  ATTR_VALUE_START, // 6)属性值开始,等待 " 或者 '
  ATTR_VALUE, // 7)解析属性值,如 App.tsx
  CONTENT, // 8)要解析的代码内容,如 import Button from "./Button";
  CONTENT_POTENTIAL_END, // 9)在代码内容中遇到可能的结束标签
  CLOSING_TAG_OPEN, // 10)结束标签,遇到 </
  CLOSING_TAG_NAME, // 11)解析结束标签名
  SELF_CLOSING_START, // 12)自闭合标签开始,遇到 /
}

interface TagData {
  name: string
  attrs: Record<string, string>
}

class StreamParser {
  private buffer: string = "" // 缓冲区,用于存储当前需要解析的内容
  private position: number = 0 // 当前解析到的位置
  private tagStack: TagData[] = [] // 标签栈,用于存储当前解析到的标签
  private currentTagName: string = "" // 当前解析到的标签名
  private currentAttrs: TagData["attrs"] = {}
  private currentAttrName: string = "" // 当前解析到的属性名
  private currentAttrValue: string = "" // 当前解析到的属性值
  private attrQuoteChar: string = "" // 当前解析到的属性值的引号字符,用于匹配属性值的结束

  // 定义内容标签集合,仅解析此集合中的标签的内容,作为要解析的原始内容使用 onContent 事件暴露
  private parseAsContentTags: string[] = ["ComponentFile"]
  // 保存潜在的未完成的闭合标签
  private pendingClosingTag: string = ""
  // 保存未发送的原始内容
  private pendingContent: string = ""
  // 当前潜在闭合标签匹配位置
  private potentialEndTagMatchPos: number = 0

  // Event handlers
  public onOpenTag: ((tagData: TagData) => void) | null = null
  public onCloseTag: ((tagData: TagData) => void) | null = null
  public onContent: ((tagData: TagData, content: string) => void) | null = null

  // 当前状态
  private state: ParserState = ParserState.TEXT
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.TEXT]: this.handleTextState,
    [ParserState.TAG_OPEN]: this.handleTagOpenState,
    [ParserState.TAG_NAME]: this.handleTagNameState,
    [ParserState.CLOSING_TAG_OPEN]: this.handleClosingTagOpenState,
    [ParserState.CLOSING_TAG_NAME]: this.handleClosingTagNameState,
    [ParserState.ATTR_NAME_START]: this.handleAttrNameStartState,
    [ParserState.ATTR_NAME]: this.handleAttrNameState,
    [ParserState.ATTR_VALUE_START]: this.handleAttrValueStartState,
    [ParserState.ATTR_VALUE]: this.handleAttrValueState,
    [ParserState.SELF_CLOSING_START]: this.handleSelfClosingStartState,
    [ParserState.CONTENT]: this.handleContentState,
    [ParserState.CONTENT_POTENTIAL_END]: this.handleContentPotentialEndState,
  }

  public write(chunk: string) {
    this.buffer += chunk // 添加到缓冲区
    this.parseBuffer()
  }

  private parseBuffer() {
    while (this.position < this.buffer.length) {
      const char = this.buffer[this.position]
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
      // 移动到下一个字符
      this.position++
    }

    // 若存在解析到的 content,通知 onContent 回调
    this.sendPendingContent()

    // 处理完成字符,重置缓冲区
    if (this.position >= this.buffer.length) {
      this.buffer = ""
      this.position = 0
    }
  }

  private getCurrentHandlingTagData(): TagData {
    return this.tagStack[this.tagStack.length - 1]
  }

  // 辅助方法:判断是否是空白字符
  private isWhitespace(char: string): boolean {
    return char === " " || char === "\t" || char === "\n" || char === "\r"
  }

  private isValidNameChar(char: string): boolean {
    return /[A-Za-z0-9]/.test(char)
  }

  private sendPendingContent() {
    if (this.state !== ParserState.CONTENT || !this.pendingContent) return
    this.onContent?.(this.getCurrentHandlingTagData(), this.pendingContent)
    this.pendingContent = ""
  }

  private resetCurrentTagData(): void {
    this.currentTagName = ""
    this.currentAttrs = {}
    this.currentAttrName = ""
    this.currentAttrValue = ""
    this.attrQuoteChar = ""
  }

  private handleTextState(char: string) {
    if (char === "<") {
      // 开始一个新标签
      this.state = ParserState.TAG_OPEN
    }
    // 文本状态下,其他非代码块字符忽略处理
  }

  private handleTagOpenState(char: string) {
    if (char === "/") {
      // 这是一个结束标签 </tag>
      this.state = ParserState.CLOSING_TAG_OPEN
    } else if (this.isValidNameChar(char)) {
      // 开始收集标签名
      this.currentTagName = char
      this.state = ParserState.TAG_NAME
    } else {
      // 标签开始后应该是 标签名 或 /,否则是错误的语法
    }
  }

  private handleTagNameState(char: string) {
    if (this.isWhitespace(char)) {
      // 标签名后面有空白,准备解析属性
      this.state = ParserState.ATTR_NAME_START
    } else if (char === ">") {
      // 标签结束,没有属性
      this.handleOpenTag()
    } else if (char === "/") {
      // 可能是自闭合标签
      this.state = ParserState.SELF_CLOSING_START
    } else {
      // 继续收集标签名
      this.currentTagName += char
    }
  }

  private handleAttrNameStartState(char: string) {
    if (this.isValidNameChar(char)) {
      // 开始收集属性名
      this.currentAttrName = char
      this.state = ParserState.ATTR_NAME
    } else if (char === ">") {
      // 没有更多属性,标签结束
      this.handleOpenTag()
    } else if (char === "/") {
      // 自闭合标签
      this.state = ParserState.SELF_CLOSING_START
    }
    // 忽略多余的空白
  }

  private handleAttrNameState(char: string) {
    if (char === "=") {
      // 直接遇到=,属性名结束
      this.state = ParserState.ATTR_VALUE_START
    } else if (char === ">") {
      // 布尔属性,没有值
      this.currentAttrs[this.currentAttrName] = "true"
      this.handleOpenTag()
    } else if (char === "/") {
      // 自闭合标签前的布尔属性
      this.currentAttrs[this.currentAttrName] = ""
      this.state = ParserState.SELF_CLOSING_START
    } else {
      // 继续收集属性名
      this.currentAttrName += char
    }
  }

  private handleAttrValueStartState(char: string) {
    if (char === '"' || char === "'") {
      // 属性值开始
      this.attrQuoteChar = char
      this.currentAttrValue = ""
      this.state = ParserState.ATTR_VALUE
    }
    // 忽略=和引号之间的空白
  }

  private handleAttrValueState(char: string) {
    if (this.attrQuoteChar && char === this.attrQuoteChar) {
      // 引号闭合,属性值结束
      this.currentAttrs[this.currentAttrName] = this.currentAttrValue
      this.currentAttrName = ""
      this.currentAttrValue = ""
      this.state = ParserState.ATTR_NAME_START
    } else {
      // 继续收集属性值
      this.currentAttrValue += char
    }
  }

  private handleClosingTagOpenState(char: string) {
    if (this.isValidNameChar(char)) {
      // 开始收集结束标签名
      this.currentTagName = char
      this.state = ParserState.CLOSING_TAG_NAME
    }
  }

  private handleClosingTagNameState(char: string) {
    if (char === ">") {
      // 结束标签结束
      this.handleCloseTag()
      this.currentTagName = ""
    } else if (!this.isWhitespace(char)) {
      // 继续收集标签名
      this.currentTagName += char
    }
    // 忽略结束标签名和>之间的空白
  }

  private handleSelfClosingStartState(char: string): void {
    if (char === ">") {
      // 处理自闭合标签
      const tagData: TagData = {
        name: this.currentTagName,
        attrs: this.currentAttrs,
      }
      // 触发开始和结束标签回调
      this.onOpenTag?.(tagData)
      this.onCloseTag?.(tagData)
      this.resetCurrentTagData()
      this.state = ParserState.TEXT
    }
  }

  private handleContentState(char: string) {
    if (char === "<") {
      // 进入潜在闭合标签状态
      this.sendPendingContent()
      this.pendingClosingTag = "<" // 开始收集可能的结束标签
      this.potentialEndTagMatchPos = 1 // 已匹配到"<",下一个应该是"/"
      this.state = ParserState.CONTENT_POTENTIAL_END // 切换状态
    } else {
      // 继续累积内容
      this.pendingContent += char
    }
  }

  private handleContentPotentialEndState(char: string) {
    const curTagData = this.getCurrentHandlingTagData()

    // 基于字符逐个匹配潜在的闭合标签
    const expectedEndTag = `</${curTagData.name}>` // 期望的结束标签

    // 检查当前字符是否匹配期望的字符
    if (char === expectedEndTag[this.potentialEndTagMatchPos]) {
      // 字符匹配,更新匹配位置
      this.pendingClosingTag += char
      this.potentialEndTagMatchPos++

      // 检查是否完全匹配了闭合标签
      if (this.potentialEndTagMatchPos === expectedEndTag.length) {
        // 完全匹配,重置状态并触发关闭标签
        this.onCloseTag?.(curTagData)
        this.resetCurrentTagData()

        // 从标签栈中移除
        if (
          this.tagStack.length > 0 &&
          this.tagStack[this.tagStack.length - 1].name === curTagData.name
        ) {
          this.tagStack.pop()
        }

        // 检查父标签是否是原始内容标签
        if (this.tagStack.length > 0) {
          const parentTag = this.tagStack[this.tagStack.length - 1]
          if (this.parseAsContentTags.includes(parentTag.name)) {
            this.state = ParserState.CONTENT
          } else {
            this.state = ParserState.TEXT
          }
        } else {
          this.state = ParserState.TEXT
        }

        // 重置匹配状态
        this.pendingClosingTag = ""
        this.potentialEndTagMatchPos = 0
      }
    } else {
      // 不匹配,回到 CONTENT 状态
      // 将已收集的 pendingClosingTag,以及当前字符 char 作为内容
      this.pendingContent += this.pendingClosingTag + char
      // 重置状态
      this.pendingClosingTag = ""
      this.potentialEndTagMatchPos = 0
      this.state = ParserState.CONTENT
    }
  }

  handleOpenTag() {
    const tagName = this.currentTagName
    const tagData: TagData = { name: tagName, attrs: this.currentAttrs }
    // 触发开始标签回调
    this.onOpenTag?.(tagData)
    // 添加到标签栈
    this.tagStack.push(tagData)
    // 重置当前标签相关数据
    this.currentTagName = ""
    this.currentAttrs = {}
    // 检查是否是原始内容标签
    if (this.parseAsContentTags.includes(tagName)) {
      this.state = ParserState.CONTENT // 开始收集 content 内容
    } else {
      this.state = ParserState.TEXT
    }
  }

  handleCloseTag() {
    const tagName = this.currentTagName
    // 触发结束标签回调
    this.onCloseTag?.({ name: tagName, attrs: this.currentAttrs })
    this.resetCurrentTagData()
    // 从标签栈中移除
    if (
      this.tagStack.length > 0 &&
      this.tagStack[this.tagStack.length - 1].name === tagName
    ) {
      this.tagStack.pop()
    }
    // 检查父标签是否是原始内容标签
    if (this.tagStack.length > 0) {
      const parentTag = this.tagStack[this.tagStack.length - 1]
      if (this.parseAsContentTags.includes(parentTag.name)) {
        this.state = ParserState.CONTENT
      }
    }
    if (this.state !== ParserState.CONTENT) {
      this.state = ParserState.TEXT
    }
  }
}

让我们使用计时器模拟 AI 生成流式内容,相信和预期的结果一致。

let currentFile: {
  name: string
  isEntryFile: boolean
  content: string
} | null = null

const parser = new StreamParser()

parser.onOpenTag = function ({ name, attrs }) {
  if (name === "ComponentFile") {
    const fileName = attrs.fileName as string
    const isEntryFile = attrs.isEntryFile === "true"
    // 定义组件结构
    currentFile = {
      name: fileName,
      isEntryFile,
      content: "",
    }
    console.log("onOpenTag", name, attrs)
  }
}

parser.onContent = function ({ name }, text) {
  if (name === "ComponentFile" && currentFile) {
    // 收集文件内容
    currentFile.content += text
    // console.log("onContent", name, text)
  }
}

parser.onCloseTag = function ({ name }) {
  if (name === "ComponentFile" && currentFile) {
    console.log("onCloseTag", name, currentFile)
    // TODO... 自定义处理文件逻辑
    currentFile = null
  }
}

const content = `
The generated components code is as follows:

<ComponentFile fileName="App.tsx" isEntryFile>
  import Button from "./Button";
  export const App = () => {
    return (
      <Button>按钮</Button>
    )
  }
</ComponentFile>
<ComponentFile fileName="Button.tsx">
  export const Button = ({ children }) => {
    return (
      <button>{children}</button>
    )
  }
</ComponentFile>

The content contains two components: App and Button
`

let index = 0
function typeWriter() {
  if (index < content.length) {
    const text = content.slice(index, index + 10)
    index += 10
    parser.write(text)
    setTimeout(typeWriter, 100)
  }
}
typeWriter()

文末

感谢阅读!文章内容你觉得有用,欢迎点赞支持一下~

支付宝这个新的 AI 应用,终于让我妈不再转发奇怪的养生文了

作者 李超凡
2025年6月27日 16:22


上周,我妈又忧心忡忡地给我转来一篇名为「震惊!这三种蔬菜竟是致癌元凶」的文章,还附带一句:「儿子,咱家常吃的这个,以后可别买了!」

我点开一看,又是那种熟悉的自媒体配方,内容漏洞百出,但标题耸人听闻。

这已经不是第一次了,相信很多「相亲相爱一家人群」里,总有那么几个亲戚热衷于转发各种真假难辨的「养生秘笈」。

说实话,我能理解他们的焦虑。人到了一定年纪,对健康问题格外上心。但问题是,现在网上的健康科普太乱了,各种说法互相矛盾,别说长辈,连我们自己都分不清哪个是真哪个是假 。每天有超 2 亿人次在互联网搜索医疗健康问题,但网络信息真假难辨、广告植入鱼龙混杂,干扰判断 。

这种信息过载带来的,是无尽的焦虑和不安全感。我甚至动过念头,干脆把那个最爱转发的亲戚给「屏蔽」了。

昨天,蚂蚁发布了新的 AI 健康应用「AQ」,这是之前支付宝上的「AI 健康管家」的升级版,除了在各大应用商店下载(iOS 还未上线),也能直接在支付宝里体验。

体验了一番后,我发现它不只是建立在健康知识库上的聊天机器人,背后有点真东西。

一个随身的「AI 医生」,靠不靠谱

我起初是半信半疑的。毕竟,市面上打着「AI 健康」旗号的应用,很多都停留在简单的问答层面,甚至在专业问题上会出现「AI 幻觉」,一本正经地胡说八道。

但当我把妈妈的疑问——「听说吃 XX 蔬菜会致癌,是真的吗?」输入 AQ 时,它的回答让我眼前一亮。它没有直接给出「是」或「否」,而是先引用了权威的医学资料进行辟谣,然后详细解释了这类谣言的来源,最后还给出了科学的饮食建议。更重要的是,它会专门把 RAG(检索增强生成)来源标注出来,这些来源都是医学知识中最新最顶尖的 。

这让我意识到,AQ 的底层逻辑和普通 AI 完全不同。它背后是蚂蚁医疗大模型,一个用超过万亿 tokens 专业医疗语料和千万级医疗知识图谱「喂」出来的大家伙 。在应用层面,蚂蚁医疗大模型在 AQ 产品应用中识别报告、药品、皮肤病等图像准确率达 90% 以上,包括对超过 100 多种复杂的、多页的医学检验检测报告进行识别和解读 。

最让我惊喜的是「名医 AI 分身」这个功能。名医的资源为什么稀缺?因为他们的经验、注意力和时间都是有限的。而 AI 分身,复制的正是他们最宝贵的「知识」和「经验」。

我妻子怀孕时,我们关注了妇产科专家段涛医生的抖音,他的科普内容帮我们解决了很多孕期的困惑。但我们知道,想让他本人看诊非常不容易。而在 AQ 上,我竟然看到了段涛医生的 AI 分身。

我试着咨询了一个关于孕期营养的问题,AI 分身不仅给出了专业的回答,它的问诊逻辑和语气,都像极了段涛医生本人。这并非简单的声音和形象授权,而是基于对医生大量结构化诊疗经验、科普文章和论文资料的深度学习,还会根据患者的问题给出更多问题,对症给出更准确的建议。比如,一个 AI 分身的诞生,需要「加训」超过 100 个小时的结构化诊疗数据和 5 万篇以上的专业资料。

更让我印象深刻的是毛洪京院长的 AI 分身。我爸长期有睡眠问题,以前想挂毛院长的号几乎是不可能的事情。现在通过 AI 分身,偏远山区两三万的用户都可以享受到这样的能力 。毛医生说过,过去平均每月只能接诊 600 名患者,现在通过「AI 分身」一天最多能服务超 11 万人次,服务范围也从省内拓展至全国 。

这种「AI+人」的模式确保了严谨性。医生本人及其团队会定期查看线上数据,如果发现 AI 的回答有问题,会持续进行优化和迭代。大模型需要学的是高频出现的病证、部分典型疑难杂症知识,这些案例必须是完整的,不能是断点零散的。过去,我们看病最大的痛点之一就是「挂号难」。面对复杂的科室和陌生的医生,常常不知道该选哪个,就像大海捞针。

我试着输入:「我最近总是胃不舒服,有点反酸,该挂哪个科?」AQ 在追问了几个关键症状后,直接为我推荐了消化内科,并列出了附近几家医院的专家和可预约时间。这种体验的背后,是 AI 强大的匹配能力,它甚至能根据你复杂的病历,推荐最适合处理相似病例的医生,而不仅仅是名气最大的那一个。

去年蚂蚁 AI 健康管家上线后,不到一年已经有 7000 万用户在用 。它可以连接全国超 5000 家医院、近百万医生、近 200 位名医 AI 分身来提供服务 。从简单科普咨询到复杂的诊后管理,甚至在需要时可以直接衔接挂号服务,形成了一个完整的服务闭环。

科技最大的善意,是让「相亲相爱一家人」不再焦虑

用了 AQ 几个月后,我发现它最打动我的,不是那些炫酷的技术参数,而是一些很细微的瞬间。

比如怀孕的妻子突然肚子隐隐作痛,我们不知道是普通的消化不良还是需要立即就医。以前遇到这种情况,要么硬着头皮挂急诊,要么在网上瞎搜一通,越看越害怕。

现在,我直接问了段涛医生的 AI 分身,它详细询问了疼痛的位置、性质和伴随症状,最后给出了专业的判断和建议。那种安心的感觉,就像身边真的有个 24 小时待命的专家朋友。

再比如住在三线小城市的长辈,以前想看个好点的睡眠科医生,得跑到省城排队挂号。现在他直接在手机上就能咨询毛洪京院长的 AI 分身,从睡眠问题到用药指导,应有尽有。

AI 分身肯定无法完全和真人一样,但在一些常见场景能达到专家本人 80% 的水平,但对大多数人已经足够了。

我想,这就是科技真正的温度。它没有颠覆什么,也没有取代什么,它只是悄悄地填补了那些让我们焦虑的空白。让那些原本遥不可及的医疗资源,真正走进了普通人的生活。

现在,当我看到家族群里再有人转发那些不靠谱的「养生秘笈」时,我会分享一些来自专家 AI 分身的科普内容。因为我知道,在这个信息爆炸的时代,最珍贵的不是更多的信息,而是更可信的陪伴。

或许,这就是 AI 时代该有的样子——不是让机器变得更像人,而是让技术变得更有人情味,要实实在在地解决我们生活中的每一个小问题,让那些我们最关心的人,能活得更安心、更健康。

这或许,就是对「相亲相爱一家人」这个群名,最好的诠释。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


这台能换电池的模块化手机,代表一种很旧却很新的方向

作者 周芊彤
2025年6月27日 13:24

2025 年 6 月 25 日,以环保为核心主张,通过模块化、易维修等设计传达理念的手机品牌 Fairphone,发布的第六代手机 Fairphone 6 在欧洲正式上市,售价为 599 欧元(约合人民币 5006元)。

▲ Fairphone 6. 图片来自:Fairphone

Fairphone 6 在硬件规格上摆脱了过去「环保即低配」的刻板印象:处理器选用骁龙 7s Gen 3,配备 8 GB 内存和 256 GB 存储,并支持 microSD 扩展至 2 TB;屏幕采用 6.31 英寸 LTPO OLED,支持 10–120 Hz 自适应刷新;内置 4415 mAh 可拆卸电池,官方宣称最长续航可达到 53 小时——就算没电了,也可以简单卸下螺丝直接换电池,非常方便。这也算 Fairphone 系列的经典设计了。

与上一代对比,Fairphone 6 明显改善了许多日常体验细节:整机重量 193 克,相比 Fairphone 5 减重 9%,手感上更接近于主流市场的 6.1 英寸。

虽略小于 Fairphone 5 的 6.46 英寸屏幕,但这次实现了 2025 年的「高刷」标准,刷新率由 90 Hz 提升至 120 Hz。电池容量也增加了 5% 左右。

影像系统规格上采用 5000 万像素主摄像头,以及 1300 万像素的超广角摄像头,并将前置提升至 3200 万像素。整机继续支持 IP55 防水防尘,配备双 SIM 及 NFC 功能。

▲ Fairphone 6 后摄模块. 图片来自:Fairphone

与历代 Fairphone 相同,Fairphone 6 的核心仍是模块化与可维修性。屏幕、电池、接口、摄像头等模块都可轻松更换,维修成本能得到大幅降低。

▲ Fairphone 6 拆卸图. 图片来自:Fairphone

秉持可持续的理念,官方提供整机 5 年质保和 8 年软件支持,可至少更新 7 个 Android 大版本,并延续服务到 2033 年。

Fairphone 创立以来一直持续强调环保回收和公平贸易理念,整机大部分材料均来自可持续供应链,同时 Fairphone 还积极推动全球电子垃圾回收,你在购买手机的同时,也相当于参与了其背后的环保实践。

▲ Fairphone 6 电池模块. 图片来自:Fairphone

除此之外,Fairphone 6 进一步丰富了外设上的设计和功能——提供更多个性化的配件。

只需卸下两颗螺丝,便可快速更换如卡包、指环、挂绳等多种配件,拓展手机的外设功能。

这种「快拆+扩展」的新模式让我想起了与 CMF 系列手机,不仅简化了维修,在外观上也能拥有更多选择。

▲ 从左到右分别为手机壳,挂绳,卡包外设效果图. 图片来自:Fairphone 6

全新推出的 Moments 模式也值得关注:拨动机身右侧黄色滑键后,系统便即刻进入极简专注模式,屏幕上只显示通话、短信、相机等几个基本功能。

在上面你可以看到 Light phone 「笨手机」的身影,这也是当下很流行的「数字排毒」设计。

▲ Moments 模式. 图片来自:Fairphone

自 2015 年以来,Fairphone 每一代产品都获得了 iFixit 网站 10/10 的可维修评分,至今你仍能在官网轻松购买旧款手机的零部件。

但 Fairphone 6 在延续了可维修度满分传统基础下,设计思路出现了明显变化:将处理器、射频、散热等高集成区域彻底固定,只保留电池、屏幕、接口和扬声器等易损耗或易定制的快拆模块。

这种设计不仅减少了模块复杂度和组装难度,也使防护等级和使用体验同步提升。

▲ Fairphone 6 配色展示. 图片来自:Fairphone

Fairphone 6 并不是一台追求工业美学极致的「无孔化」手机,但它却提供了可持续发展的另一种可能:

让用户能够自由地维修、扩展和个性化自己的设备。

这种具体、可行的设计实践,比任何关于环保的宣传口号都更有力量。

回顾历代产品,Fairphone 的模块化策略正从纯粹的可维修转向可按需拓展与外设共创,在产品上更强调设计感与日用简洁。

▲ Fairphone 5. 图片来自:Fairphone

也许,真正的终点不是彻底模块化,而是将核心部件做成可快速替换的轻量化方案,在高集成与易维修之间找到平衡,才是 Fairphone 的最佳选择。

本文作者:周芊彤、肖钦鹏

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


搭建自动化 Web 页面性能检测系统 —— AI 篇

2025年6月27日 10:55

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:琉易

这一篇是系列文章:

搭建自动化 Web 页面性能检测系统 —— 设计篇

搭建自动化 Web 页面性能检测系统 —— 实现篇

搭建自动化 Web 页面性能检测系统 —— 部署篇

页面性能对于用户体验、用户留存有着重要影响,当页面加载时间过长时,往往会伴随着一部分用户的流失,也会带来一些用户差评。性能的优劣往往是同类产品中胜出的影响因素,也是一个网站口碑的重要评判标准。

系统架构图(简略)

本篇重点讲解 AI 分析模块的设计与实践。

画板

AI 分析模块的设计与实现

输入与输出

  • 输入:Lighthouse 检查产生的 JSON 数据
    • 由于每次检测产生的 JSON 数据较大,一般有 350KB ~ 500KB,而大模型往往是根据输入 Tokens 进行计费,且并不是单纯的按量计费,类似于生活中常见的阶梯计费;另外模型支持的输入也有限,一般为 32k Tokens 或 64k Tokens。所以我们将 JSON 数据传输给大模型前需要进行精简。
    • 保留检测结果中的关键数据,如:环境数据(environment)、每一项检测指标的详细结果(audits)、检测时的配置参数(configSettings)、汇总各类指标的最终得分(categories)。
  • 输出:自然语言优化建议列表
    • 如:建议将图片资源启用 lazy-load
    • 如:减小某个图片文件的大小以减少传输时间

核心组成

  • JSON 清洗与摘要
  • Prompt 定义
  • openai 接口集成
  • 流式处理

Prompt 设计要点

构建一个高质量的 Prompt 是成功的关键,以下是一个例子:

你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。

实际测试中 Kimi 的 moonshot-v1-auto 模型回答更快,百炼平台的模型输入输出 Tokens 限制更宽泛,但是输出速度略慢;百炼平台的免费额度更多,OpenAI 费用较高且部署后会有访问的问题。

关键技术点

Lighthouse 报告数据结构解析

JSON 数据清洗与摘要是大模型调用能否成功的关键,清洗后的结果是 Prompt 的数据来源,如果内容较多可能会超出模型输入 Tokens 的限制从而导致调用失败。

  • audits 中会包含各种指标近百种,我们可以删除一些内容较多但对分析用处不大的数据,如:offscreen-images、screenshot-thumbnails 等。
  • Lighthouse 生成的 JSON 数据会直接保存瀑布图等图片的 Base64 格式数据,这些图片数据占用 Tokens 明显。

经过清洗,尽量将输入的 Tokens 控制在 100k 以内。

流式输出

openai 是一个 npm 包,通过这个 npm 包可以快速的对接各种大模型的 API 调用服务。

// 流式输出
const stream = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
    stream: true,
});

// 当启用流式输出模式(stream=True),SDK 返回的内容也发生了变化,我们不再直接访问返回值中的 choice
// 而是通过 for 循环逐个访问返回值中每个单独的块(chunk)
for await (const chunk of stream) {
    if (abortSignal?.aborted) {
        console.log(`${taskIdLogStr}任务中止, kimi chat`);
        break;
    }

    // 在这里,每个 chunk 的结构都与之前的 completion 相似,但 message 字段被替换成了 delta 字段
    const delta = chunk.choices[0].delta;
    if (delta.content) {
        onData(delta.content);
    }
}
@ApiOperation({ summary: '分析检测结果' })
    @HttpCode(HttpStatus.OK)
    @Post('reportChat')
    @RawResponse()
    async reportChat(@Body() query: ReportChatReqDto, @Res() res: Response) {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    const abortController = new AbortController();

    // 👇 监听客户端断开连接(如 Abort)
    res.on('close', () => {
        abortController.abort();
    });

    try {
        await this.AIService.reportChat(
            query,
            (content: string) => {
                res.write(`data: ${JSON.stringify({ content })}\n\n`);
            },
            abortController.signal
        );
        res.write(`data: [DONE]\n\n`);
    } catch (error) {
        res.write(`data: [ERROR] ${error.message || 'stream error'}\n\n`);
        abortController.abort();
    } finally {
        res.end();
    }
}

非流式输出

// 非流式输出
const completion = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能评分分析专家。我将提供一个产品的性能评分数据,帮我分析得分趋势和较大的得分变化。回答的内容不要带格式符号,尤其是 **。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
});

return completion.choices[0].message.content;

实现的功能点

检测报告的智能分析与建议

由于保存的是 html 文件,我们可以通过正则将 html 文件中的 JSON 数据提取出来,用于后续的清洗与分析。

image.png

结合清洗后的 JSON 数据给出优化建议。

数据周报的趋势分析

将过去一周的分数给到大模型,由大模型分析解读得分的变化趋势。

image.png

后续规划

  • JSON 数据清洗更精确,确定好哪些是关键性能指标
  • 将 Lighthouse 的具体评分规则同步给大模型
  • 优化 Prompt,更换更合适的大模型
  • 结合埋点数据
    • 分析页面性能与用户停留时长的关系
    • 分析用户跳出率与页面性能的关系
    • 结合采集到的埋点数据分析页面性能对业务指标的影响

小米 AI 眼镜全汇总:年轻人的第一幅智能眼镜,终于要上头了

作者 马扶摇
2025年6月26日 21:56

本周四(6 月 26 日),小米在「人车家全生态发布会」上正式发布了旗下首款使用小米商标的 AI 智能眼镜产品,为小米「人、车、家」生态再添一步棋。

根据小米在发布会上的展示,小米 AI 眼镜定位「面向下一个世代的个人智能设备」,是一款基于语音和触控操作、不包含显示功能的智能眼镜,支持语音通话和拍照录像。

对于一款智能眼镜来说,好不好看是第一要义,毕竟与 AR 头显、VR 手柄之类的配件不同,智能眼镜是具有非常强烈配饰属性的产品。好消息是,小米 AI 眼镜也的确是将工业设计放在优先位置的:

小米 AI 眼镜采用了较为保守的 D 型方框设计,整体造型接近经典的威灵顿式镜框(Wellington Frame),与上周 Meta 与欧克利合作的 Oakley Meta HSTN 的圆框型相比,小米的选择更加适合亚洲人脸型,搭配最大可以 12 度外翻的转轴,有效避免了以往智能眼镜眼镜腿粗厚导致的夹头问题。


亨利·卡维尔版本的《超人》在伪装成克拉克·肯特时,选择的就是威灵顿风格镜框

在造型与颜色方面,小米 AI 眼镜只有一种可选的框型与三种配色:黑色、玳瑁棕与鹦鹉绿,其中后两种是烟熏色风格的半透明镜架,与「透明探索版」的小米 8 有异曲同工之妙——

对于眼镜来说至关重要的重量方面,小米 AI 眼镜在裸框无镜片的情况下重量约为 40 克。而在最重的情况下,搭配玻璃镜片后的整体重量约在 50~60 克,如果需要全天佩戴的话还是应该尽量选择树脂镜片。

好消息是,小米也的确考虑到了需要处方眼镜的人群,并没有推出一款纯平光镜。小米 AI 眼镜既可以当作裸框去线下直接验光配镜,也可以在小米有品 app 里面定制处方镜片,镜片供应商为上海明月眼镜。

有趣的是,除了平光镜片、处方镜片和墨镜片之外,小米这次还给智能眼镜带来另一个新的选择:电致变色镜片。

与波音 787 上面的可变色舷窗原理类似,电致变色镜片的透明度可以通过施加不同的电压进行调整,反应速度比一般的紫外线光致变色镜片要灵敏的多:

本次的小米 AI 眼镜共包含两款搭配电致变色镜片的版本,分为单色款和多色款。通过在镜腿侧边滑动控制,单色款电变镜片可以调节四档遮光度,多色款则可以在黑色、粉色、蓝色和紫色之间切换。

第一人称相机

如果说现阶段智能眼镜最独树一帜的功能是什么,那毫无疑问是能够拍摄和录制第一人称视角的照片与视频,实现 100% 的解放双手:

作为直接与 Meta 对标的功能,小米自然也将主要的精力放在了相机上。根据发布会的介绍,小米 AI 眼镜配备了一块 1200 万像素的 IMX681 传感器,与雷鸟 V3 上面使用的是同一颗,最高可以录制 2304×1728 分辨率的 2K 30 帧视频,并且支持 EIS 电子防抖。

此外,小米 AI 眼镜上还有一个四麦克风阵列,借助于骨传导麦克风以及抗风噪设计,可以满足从轻度运动到城市街拍的各种使用场景。与 Ray-Ban Meta 类似,小米 AI 眼镜也选择了镜头居左、指示灯居右的布局,快门键位于右侧镜腿上。

小米 AI 眼镜并不是一个独立的 POV 相机,而是澎湃生态中的一环。根据小米的介绍,小米 AI 眼镜支持在微信和 QQ 的视频通话中,可以借助 HyperOS 的相机流转功能替代手机摄像头,真正实现「换位观察」:

更精彩的是,这套玩法并不仅限于视频通话,小米 AI 眼镜是可以用来直播的。

发布会上,小米宣布和哔哩哔哩、抖音、快手和小红书达成了合作,实现了在直播中直接采集小米 AI 眼镜拍摄的画面,省去了以往用运动相机直播推流时要用到的一大堆设备,现在只需要带一部手机、一根数据线和一副眼镜就可以光速开播了。

随身 AI 设备

除了拍照录像,小米 AI 眼镜还有一个角色:实时的小爱同学伴侣。有了小爱同学打底,小米 AI 眼镜的智能化精确识别能力应该是完全不用担心的:

而在翻译方面,小米 AI 眼镜自然也支持了同声传译功能,目前支持中文与英、法、日、韩、德、意、葡、西、俄、印尼语共计十种语言互译,是目前市场上能够买到的智能眼镜产品中支持翻译语言最多的。

除此之外,小米 AI 眼镜也可以配合手机上的录音 app,实现会议中的无感录音和人位录音,回放的时候临场感更强,录音时的打扰性也比手机更低。不过小米 AI 眼镜的同传暂时不支持外语间互译,这个情况在推出国际版时可能会得到改善。

一副全天候智能眼镜

对于智能眼镜来说,除了一颗好的摄像头和优秀的算法之外,续航也是必须要重视的指标。

根据发布会上的介绍,小米 AI 眼镜使用的是高通 AR1 处理器,这是一颗带 NPU 单元和双 ISP(图像处理器)的低功耗芯片,是高通智能穿戴设备处理器中的旗舰。而面对音乐等低功耗场景,小米还搭配了来自恒玄科技的 BES2700H 蓝牙音频处理器,组成了类似 Vision Pro 的「一机双芯」配置。

小米 AI 眼镜使用的是与小米 15 Pro 上相同的高密度硅负极电池技术,纯蓝牙通话或音乐的续航为 7~8 小时左右,在压力更大的混合使用场景(通话+视频+拍照+小爱对话+识图问答)中也达到了约 8.6 小时的典型续航,基本满足普通用户一整天的使用需求,0~100% 充电时长约为 50 分钟。

当然,在直播这样的高功耗场景下,小米 AI 眼镜也支持使用 USB-C 线缆直接供电,接口位于右侧镜腿末端。只不过从发布会上公布的信息来看,这枚接口应该只能用于充电,不具备数据传输和 DAC 功能,也就不支持有线音乐播放了。

小米 AI 智能眼镜的售价为 1999 元,电致变色款分别为 2699 和 2999 元。如果你有医疗镜片的需求,小米与全国近 400 家眼镜门店达成了合作,可以携带小米 AI 眼镜线下验光、现场配镜。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


断网都没用,机器人终于「摆脱」人类控制!Google 首发离线 AI 大脑,一句话让它干活

作者 莫崇宇
2025年6月25日 11:57

在影视作品里,我们看过太多机器人失控的场面。一些应对方案的段子也早已烂熟于心:断网、拔电、重启三连,一键送它回炉重造。

但现在,这一套流程可能不太管用了。

今天,Google DeepMind 推出了一款全新机器人控制模型——Gemini Robotics On-Device。这款大模型能在机器人本地运行,集视觉识别、语言理解和动作执行于一体。

而它最大亮点在于,即使完全离线,它也能理解人类指令,流畅完成任务。

比起诸如 ChatGPT、Gemini 等擅长聊天、写作、答题的大模型,Gemini Robotics On-Device 则给机器人装上了一个真正的「大脑」,让其也能具备类似的理解力和执行力。

它本质上是一个专为双臂机器人打造的 VLA 基础模型,顾名思义,Vision(视觉)+Language(语言)+Action(动作),三者结合,看得见、听得懂、动得了,是它的基本素养。

举例而言,你可以对机器人发出请求:「请把这件衣服叠好,再放进背包里,拉上拉链。」过去这需要提前编写程序、分解动作,现在 Gemini On-Device 可以直接理解这句话的意思,然后一步一步执行下来。

那既然联网也能跑,为什么还要费劲折腾本地运行?答案不外乎速度和稳定性。

机器人若需将数据传至云端、等待服务器分析再返回结果,必然产生延迟。在医疗操作、灾难救援、工厂自动化等任务中,延迟容错空间几乎为零。何况,现实中许多地方网络条件差,甚至完全无网。

实际上,让机器人顺利应对复杂、动态的现实任务,一直是 AI 领域最难啃的骨头之一。

从公开视频看,Gemini On-Device 已能胜任多种常见场景,如叠衣、拉链、抓取陌生物体并放置到指定位置。而这一切得益于它的学习机制。

▲强大的泛化能力

它不需要从零开始进行长时间训练,开发者仅需提供 50 至 100 次人工演示,如亲自操控机器人叠衣,模型便能迅速学会并独立操作。

在更具挑战性的分布式任务或复杂的多步骤指令执行中,Gemini Robotics On-Device 的表现依然优于目前其他本地运行的替代方案。

而且,它的适配性也很强。

虽然 Gemini Robotics On-Device 最初在 Google 自研的 ALOHA 双臂机器人平台上进行训练的 ,但稍加适配,它也能稳定运行于 Franka FR3 工业机械臂。

甚至结构迥异的人形机器人 Apollo 也能丝滑运行,同一个通用模型通过少量学习,就习惯了完全不同的身体形态。

理想情况下,开发者无需为每种新机器人重新训练一个 AI,只需训练一次通用模型,之后通过轻量级的迁移学习即可部署到各式各样的机器人平台上。这种「一模多用」的能力将有望加速机器人技术的普及和应用。

当然,理想归理想,它也还有短板。

随着机器人智能与自主性提升,安全要求也随之提高。Gemini On-Device 虽然能执行动作,但它并不能合理判断你给的任务是否安全,因此,必须为模型加装「安全栓」。

DeepMind 给出的建议是,开发者可以给模型接入 Google Gemini Live API 接口,让系统先判断这个指令合不合理,再决定是否执行;同时在动作层面设置物理限制,如力度、角度、速度,以防意外。

此外,模型多步骤逻辑规划能力仍有提升空间。

像做三明治、整理桌面这这类需要先后逻辑、顺序安排的操作,目前还不在它的舒适区。这和它所基于的 Gemini 2.0 架构有关,未来随着升级到 2.5,这部分能力可能也会补齐。

另一个现实挑战,是数据。

虽然它只需几十次演示就能上手,但最理想的示范,是由真人实际操控机器人时采集的真实数据,而不是虚拟模拟。这类数据训练出来的效果,更快、更准,也更稳定。

▲技术报告地址:https://arxiv.org/pdf/2503.20020

据项目负责人 Carolina Parada 介绍,这是 Google 首次发布完全脱离云端运行的机器人 AI 模型,也是首个供开发者根据自身需求进行微调的版本。

目前,DeepMind 向「可信测试者」开放了 Gemini Robotics On-Device 的 SDK 和模型访问权限。如果你是做机器人开发、工业自动化,或智能系统研究的开发者,现在就可以申请试用。

附上申请链接:https://docs.google.com/forms/d/1sM5GqcVMWv-KmKY3TOMpVtQ-lDFeAftQ-d9xQn92jCE/edit?ts=67cef986

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


打造极致聊天体验:uz-chat——全端AI聊天组件来了!

作者 伊泽瑞尔
2025年6月24日 21:47

🚀 打造极致聊天体验:uz-chat——全端AI聊天组件来了!

🌟 插件介绍

uz-chat是一款基于uni-app开发的全端AI聊天组件,可无缝对接DeepSeek、OpenAI等主流AI服务。它不仅支持基础的消息展示,还内置了打字机效果、Markdown渲染和平滑滚动等高级特性,让你的应用瞬间拥有专业级聊天体验!

插件效果

✨ 核心功能亮点

1️⃣ 全端兼容,一次开发多端运行

  • 完美支持H5、小程序、App等多平台
  • 基于uni-app生态,无缝集成现有项目

2️⃣ 流畅的消息交互体验

  • 🎉 实时滚动:新消息自动平滑滚动到底部
  • ⌨️ 打字机效果:模拟AI思考和输入过程
  • 📋 消息操作:支持复制、编辑消息内容

3️⃣ 强大的内容渲染

  • ✍️ Markdown支持:代码高亮、表格、列表等格式化展示
  • 💻 代码块展示:支持多种编程语言语法高亮
  • 📝 富文本内容:满足复杂消息展示需求

4️⃣ 灵活的自定义能力

  • 支持自定义头像、昵称
  • 可扩展的消息类型插槽
  • 丰富的样式定制选项

🚀 快速上手

安装方式

在DCloud插件市场导入聊天消息组件uni_modules版本,无需额外import即可使用。

基础用法

<template>
  <uz-chat 
    @sendMessage="sendMessage"
    :isSending="isSending"
    :messages="messages"
    v-model:modelValue="inputMessage"
    :offset-height="topHeight + 'rpx'"
  ></uz-chat>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const isSending = ref(false)
const messages = ref([])
const inputMessage = ref('')

// 发送消息处理
const sendMessage = async (msg: string) => {
  // 实现消息发送逻辑
}
</script>

对接AI服务

// 对接DeepSeek等AI服务示例
async function createChatCompletion(messages) {
  const openai = new OpenAI({
    baseURL: 'https://api.deepseek.com',
    apiKey: process.env.DEEPSEEK_API_KEY
  })
  
  return openai.chat.completions.create({
    messages: messages,
    model: 'deepseek-chat',
    stream: true
  })
}

🛠️ 技术特性

  • 高效渲染:采用虚拟列表技术,支持大量消息展示
  • 性能优化:消息滚动节流处理,避免卡顿
  • 类型安全:完整的TypeScript类型定义
  • 轻量化设计:核心功能打包体积小

📈 未来规划

  • 支持上拉加载更多历史消息
  • 支持语音消息
  • 自定义表情包功能
  • 暗黑模式

🤝 如何获取

💡 写在最后

uz-chat致力于为开发者提供开箱即用的高质量聊天组件,无论是构建AI助手、在线客服还是社交聊天应用,它都能满足你的需求。现在就集成uz-chat,为你的应用增添专业的聊天体验吧!

如果觉得这个组件对你有帮助,欢迎在掘金、CSDN等平台分享你的使用体验,也欢迎提交issue和PR参与项目贡献!

❌
❌