CrazyAirhead

疯狂的傻瓜,傻瓜也疯狂——傻方能执著,疯狂才专注!

0%

代码写完,只算把事情做了一半;文档建起来,这件事才算完整。

个人开源项目的宿命,大多是这样的:代码写完,发一个 README,心里默念「文档以后再补」—— 而「以后」永远不会来。不是不想补,是完整性太贵。一个像样的文档站,意味着选型、配置、部署、CI、内容规范、校验脚本……每一项都得查资料、踩坑、返工。于是索性砍掉,美其名曰「聚焦核心」。

前面介绍过 aifei-go —— Java 版 Aifei 的 Go 移植。它的处境比一般项目更尴尬:这是一个宣称「为 AI Coding 而生」的框架,Java 版有官方文档 aifei.cn/doc 摆在那里当基准,Go 版要是只有一个 README,多少有点打脸。而且这里还藏着一个顺理成章的推论 —— 既然是为 AI Coding 而生的框架,它的文档,本来就该由 AI 来写。于是花了两天,把文档站建了起来:https://crazy-airhead.github.io/aifei-go/

完整性的坑,AI 都记得

技术上没什么新鲜事:VitePress + pnpm + GitHub Actions,push 到 master 自动构建、发布到 gh-pages。新鲜的不是技术,是细节有人替你想着。随手摘两段:

1
2
3
4
5
6
7
8
9
10
11
- name: Checkout(完整历史,供 lastUpdated 读取时间)
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
publish_dir: docs/.vitepress/dist
# dist 是纯生成产物:单提交孤儿分支,保证删除的页面同步消失
force_orphan: true

配置里的这些注释,本身就说明创建一个文档站不是一件容易的事情。没写进注释里的还有一堆:base 路径要配 /aifei-go/、head 里的 favicon 不会自动加前缀得写全路径、paths 过滤让只有 docs 变更才触发构建、concurrency 把排队的旧部署取消掉、sitemap 要配 hostname……但这些坑,每一个都是前人踩过的,AI 都记得。以前要花一个下午翻 issue 才能凑齐的事,现在是一轮对话。

先写「怎么写」,再写「写什么」

真正值得记的不是部署,是内容的生产方式。开工第一步,不是让 AI 写文档,而是先写「文档怎么写」—— 一份 docs/guide/_STYLE.md 写作规范。统一模板大纲(背景 → 架构 → 关键 API → 核心机制 → 配置集成 → 模块结构 → 总结)、风格规则(多用表格和代码块、交叉链接、信息密度要高),再加几条质量红线:

  • 内容来源必须实际读取源码,不得凭记忆编造
  • 代码示例的类型名 / 方法签名 / 配置键必须与源码一致,逐项核实
  • 篇幅 300~500 行,完成后用 wc -lgrep 自查行数与标题结构。

规范开头有一句:「本规范人与 AI 均适用」。人做决策,AI 生成 —— 落到这件事上,就是人定标准、立标杆(风格范例是那篇五百行的 data-isolate 文档),AI 读源码、照模板写。最后落地的,是二十五篇模块文档。这套招数其实是框架自己的招数:Just Service 用命名约定让 AI 稳定生成代码,_STYLE.md 用模板和红线让 AI 稳定生成文档,一回事。

抽卡之后,要有验收

规范把写作经验沉淀了下来,让下一篇的起点更高。但光有规范还不够 —— AI 的输出是基于概率的,「抽卡」式的不确定性不会因为换了任务就消失。git 历史里躺着证据:首次部署之后,紧跟一串提交 —— 更新 Logo、更新文档图、把 Actions 升级到 Node 24 运行时、修 Markdown 语法错误、修 index.md 语法错误。所以要有验收闭环:构建、校验、人工过目,一轮下来,概率性的输出才算变成确定性的成品。

最让我意外的是 scripts/check-mermaid.mjs。VitePress 构建时并不校验 mermaid 图表的语法,错了要到浏览器渲染时才暴露。这件事我没有让 AI 做,是它自己想到的:文档里有图、图会坏、坏了要在上线前发现 —— 于是有了这个脚本:jsdom 搭环境,调 mermaid.parse 把 docs 下所有 mermaid 代码块逐个校验,内部文件自动跳过。这种「想到你没让它想的事」,是完整性的另一个来源:人容易在「能用」的地方停下来;AI 不会累,也就没有「差不多得了」。

过程留痕,成品干净

仓库里有个 docs/issues/ 目录,编号归档了移植过程中发现的十九个缺陷:enjoy 的算术精度降级、for 循环迭代不了 map、内置指令缺失、db 缺方言……每一条都是留了案的复盘。这些记录通过 srcExclude 排除在发布站点之外 —— 对外的成品要干净,对内的过程要留痕。完整,不是把所有东西都端出去,而是该在的都在。

完整是长出来的

有了 AI 的帮助,让自己做事情更完整 —— 改变的到底是什么?不是 AI 会写文档了,文档它一直会写;是完整性的成本变了。以前文档、CI、校验、sitemap、孤儿分支,这些收尾活最劝退;现在它们的边际成本趋近于零,「能用」和「完整」之间的那段距离,走着走着就走完了。

你得知道「完整」长什么样—— 当然也不必一开始就知道。完整是在深入的过程中长出来的:每一步追问一句「还差什么」,追问多了,「完整」的认知自然成形。这两天下来,我对「完整」的理解,就比开工时具体得多。

最后是个自举:一个为 AI Coding 而生的框架,它自己的文档站,也是 AI Coding 做出来的。文档站的地址挂在那里,往后每一次 push,它都会自己生长。

人负责想要什么,AI 负责让它完整。

类比,是从已知通往未知的桥梁。

四年多前,我做过一款小程序「类比宝库」—— 一个用来收集类比的本子。

四年多后,我把它重做了一版,改名为「翻翻类比」。

为什么还做这件事

上帝说要有光,于是便有了光。笑来老师说,每个精彩的类比都是一笔财富,于是便有了这个小程序。

当年设计 Logo >≈< 时的想法:类比是约等于,不是等于号 —— 用得恰当,它就是大于号;用得不恰当,它就是小于号。

如果参加过笑来老师的写作课,应该知道第五课有个作业 —— 准备一个本子,从今天开始,遇到任何类比都收集起来。

最新的 AI 课程里,他又用了一个类比:Git 是平行时光机。你看,好类比永远管用。

「翻翻类比」,就是这个本子。

从「宝库」到「翻翻」

「类比宝库」走的是众人拾柴的路线 —— 大家一起攒。后来停更了,很现实的原因:微信的认证费和云服务开销,对一个个人小程序来说,扛不动。

这中间,我用墨问小程序更新、收集过一段时间的类比,但拿它收集和分享类比,总觉得隔了一层纱,不够直接。后来想明白了,我自己的需求其实很简单:收集类比,是为了有一天能用上 —— 随时翻到,翻得顺手,翻得舒服。

所以这次,思路被成本逼出来了:与其做一个「在线宝库」,不如先做一个「离线卡片集」。

  • 类比库随小程序一起发布、随版本更新。如今一共 185 句:其中一部分从当年历史数据库里迁出来的 —— 众人拾过的柴,还在这座炉子里烧着;剩下的,是我陆陆续续攒的。
  • 无网络、无登录、无云开发,不采任何信息。运维成本?题目直接删掉了。

翻起来什么样

指尖轻舞,流光溢彩。「翻翻类比」邀您步入一场全屏的思维漫游。左右滑动,在绚烂渐变中拾取智慧的隐喻;长按定格,将瞬间的顿悟化作海报珍藏。类比,是已知通往未知的桥梁 —— 一句妙语,洞见更广阔的世界。

一句话概括:全屏,左滑,无尽头。打开就是一句类比,居中衬在渐变色板上。左滑下一句,右滑上一句,卡片跟着手指回弹,翻页轻轻一震。

几点设计:

  • 20 套渐变色板:深蓝、米白、荧光绿……相邻两张不重样,翻起来像一叠老明信片。
  • 一屏一句:字号自适应,短句舒展,长句也容得下。
  • 长按生成海报:当前句子和配色渲染成一张卡片,右下角带小程序码,可存可分享。
  • 搜索:多关键词过滤,命中高亮,找到后直接进入卡片模式,不影响首页进度。

看不见的讲究:

  • 洗牌队列:不是随机抽一句,而是用 Fisher-Yates 把整个类比库洗成一轮 —— 一轮之内句句不重、句句都会出现,翻完自动重洗,衔接处还做了去重。
  • 记住进度:退出再进,从上次翻到的地方接着翻。

还是那个场景

等车时,排队时,想刷点什么的时候 —— 打开它。

现在刷的是短视频同款手势。刷十条短视频,笑完就忘了;翻十句类比,总有一句会留下来,变成你的财富。

一些说明,也是求助

  • 类比库随小程序发版更新。想分享的,直接发我微信(Crazy_Airhead),精选后收录。至于怎么让大家不经我手就能往里添柴 —— 还在想,有好点子随时喊我。
  • 小程序预留了广告位,目前没有开启;开了的话,多包涵。
  • 四年前,自己主要做后端开发,小程序开发是个新手。四年后有了 AI 的帮助,这个新手做出来的小程序,交互体验好了不少 —— 当然肯定还有不尽如人意的地方,请继续多多包涵,继续提建议。

微信里搜「翻翻类比」,翻翻看。

最早听笑来老师的课,知道他在写一款叫 VMark 的 Markdown 编辑器。当时看了看,觉得还有不少问题,就继续用着 MarkText。最近又看到笑来老师发的《我为什么制作了一个 Markdown 编辑器 VMark?》 ,正好自己这段时间一直在用 IDEA 的 cc-gui 插件配合 Claude Code 做 Vibe Coding。我发现,自己平时更多是在编辑 Markdown、在 AI 会话里聊天,可 IDEA 侧重的毕竟是源码编辑,一旦切到分栏或预览模式,渲染效果就很差,体验不好;而 cc-gui 虽然提供了多标签,方便多任务,却经常卡死,逼得我不得不重启 IDEA。于是,自己动手写一款 Markdown 编辑器的念头,就慢慢成形了。我已经写了自己的 SSH 客户端管理工具 vshell,那么再定制一款自己的 Markdown 编辑器,似乎也不是不可以。

前面说过,我平时最常用的 Markdown 编辑器是开源的 MarkText(虽然也买了 Typora)。又因为我是程序员,深知 Git 的重要性,而自己最常用的编程工具 IDEA,它的 Git 集成做得相当好。把这些凑到一起,就有了 GMark —— 一个 AI 驱动的创作编辑器。它的核心理念是:

人用 Markdown 下达指令(工作区),AI 生成内容(制品区),人审核确认,Git 全程记录。

简单说,GMark = Git + Markdown + AI,这也是它叫 GMark 的由来 —— 名字也参考了 VMark。

GMark 的另一个核心理念,是工作区与制品区的分离:用两个目录(两个 Git 仓库)。一个是工作区目录,里面是各种 Markdown 文件,还包含 AI 引擎相关的文件,比如 Claude Code 的 .claudeCLAUDE.md 等;另一个是制品区目录,用来放生成的代码、图片、文件等等。这样做最大的好处,是不用再纠结哪些文件该进 Git、哪些不该进 Git —— 比如苹果官方 App 误打 CLAUDE.md 上新闻那种事,就不会发生。

GMark 支持多个 AI 引擎,一个是 Claude Code,一个是 SolonCode。其实 AI 给我规划方案的时候,还顺带配了 Codex 的适配,但我想着自己完全没用过 Codex,适配反而给自己增加难度,就取消了。如果你听过笑来老师的课,他把「人审核」这一步也交给 AI 了 —— 这或许会是 GMark 下一步的方向。笑来老师用的是自己写的 cc-suite 插件,而我得想办法让两个 AI 引擎能彼此沟通起来。

GMark 和 vshell 用的是同一套技术栈:Go + Wails3 + Vue3 + Naive UI;Markdown 编辑器核心用了 MarkText 的编辑器 Muya,源码编辑器用的是 Monaco,其他一些辅助选型是 AI 帮我挑的。GMark 目前没有开源计划:一来它本身是个很个性化的需求;二来它还有不少问题,只适合自己用。更重要的是 —— 如果你有心,我也已经把技术栈交代清楚了,你应该会想自己造一个,而不是用我的版本。随着 AI 让软件创建越来越容易,定制化的需求也会越来越多。但 AI 的本质是基于概率的,存在「抽卡」式的不确定性,所以需要沉淀 —— 而 GMark,只是我沉淀出来的一个版本。

VMark 是一个高度固执己见(Highly Opinionated)的东西 —— 事实上,我猜,以后所有 Vibe Coded Software/Services 都是高度固执己见的…… 这其实是没办法的事儿,因为一切 Vibe Coding 的过程自然而然地都是「不需要与人开会」的「生产过程」—— 只有我自己和另外一个绝不争辩的执行者。

我只是一个 Producer(制作人)。

另外,还有个「高度固执己见」自动带来的后果:VMark 就算开源了,也不能指望「社群贡献」。首先,这完全是为了让自己顺手才写的东西,很多功能对别人来说并无太大价值。最为关键的是,Markdown 编辑器不是什么科技前沿的东西,是个无数人实现过无数次的编辑器中的一个简单分子,所以,AI 可以帮助我们解决关于它的任何问题。

本来计划用 GMark 来改进 GMark,吃自己的狗粮,让 GMark 实现自举、自我迭代。无奈问题还是太多,而且自举的时候需要重启自己,要么就得维护多个版本来回切换,徒增麻烦,于是放弃了自我迭代。

最近看到两样东西。一个是小木老师的 TokUI —— 号称全球首个「For AI & 零依赖」的流式 UI 描述与渲染框架:后端用极简 DSL 描述组件,经 SSE 或 WebSocket 流式推送,前端增量解析,首个 Token 就开始渲染,让 AI 用极少的 Token 输出更灵活、更有表现力的 UI。GMark 的 AI 会话记录,就是用 TokUI 渲染的,用下来感觉不错。另一个是 Martin Fowler 的一篇文章,《DSLs Enable Reliable Use of LLMs》。如果 DSL 确实更可靠,那 TokUI 应该是个正确的发展方向;再加上之前也看过「HTML 比 Markdown 更好」的说法,所以 —— 为什么不用 DSL/UI 来当练手项目呢,也能继续吃自己的狗粮,迭代 GMark。

于是在 cnb.cool 上建了两个仓库,一个工作区,一个制品区。目前两个仓库都是公开的,有兴趣的同学可以拿去参考。不过,如果你想拿 TurboUI 用于生产,请谨慎:一来我还在实验阶段,内容变动会比较大;二来它没有经过验证,使用有风险。真要上生产,我还是推荐你用 TokUI,能获得更多支持。

1
2
3
4
5
# 工作区仓库,设计
https://cnb.cool/goldsyear/onestep/TurboUI-Design

# 制品区仓库,作品
https://cnb.cool/goldsyear/onestep/TurboUI

TurboUI 第一个版本的需求,其实非常粗暴。我当时应该是在动车上,用手机的 DeepSeek 网页端写的。别问我为什么用 Turbo、Stimulus —— 这只是个人喜好,我喜欢 37signals(Basecamp),而 Hotwire 的技术也确实先进。就像笑来老师新 AI 课里讲的,不过是搜索空间的不同。

当然,新学的招得用上 —— 多模型互搏,于是我把需求也发给了 ChatGLM。

经过它们几轮「互殴」,我选了 TurboDSL/TurboUI 这套名字,但整体方案用了 GLM 的。最终形成的文档,在 TurboUI-Design 的 design.md 里。然后 AI 照着文档一顿设计:architecture.mdsdk-architecture.md,还生成了 sdk-spec —— 也就是 TurboDSL 的契约。接着它自己把剩下的事也包圆了,顺手给我整了个小 demo,我一看,像那么回事。

不过,笑来老师不是说要 Grill yourself 么?于是我在让它 Grill 拷问自己的时候,它给我生成了 why-dsl-grill.md

核心论点(贯穿全文):把「TurboUI 值得做」和「必须是一门方括号 DSL」拆开看。前者成立;后者的每一条理由都可用「受约束 HTML profile + server-resolve 桥 + 词表校验」等价达成,且保留 HTML 的语料红利。所以「为什么是方括号 DSL」这道题,design.md 目前答不及格。

它推荐用 HTML 的子集,而不是用方括号。后来它自己做了对比测试,只能说是不相上下,于是我就继续保留了方括号的形式,以保持和 TokUI 一致。

不过在逐步讨论的过程中,其实也补强了我的一些主张。比如,click 是一个 token,clk 看起来更短,却不一定是一个 token —— 人觉得更短,AI 分词的时候未必这么认为。不能一味用简写,要尽量用常见的单词,一方面能确定 token,另一方面能减少误解。

后面陆续让 AI 处理、生成 TODO,逐步核查和修改。碰到最多的问题,其实笑来老师的课里也讲过 —— 就是只处理一半问题,或者在处理过程中说「这个是旧问题,不是这次改动引入的」。没办法,还是没办法,得好好看看笑来老师提供的 Skills 了。

实现的过程中,还得顺手修 GMark 的各种问题:工作区和制品区的限制问题、AI 提供的审核问题、Git 扫描 node_modules 导致 CPU 飙升卡顿、文件引擎的问题、AI 引擎切换时参数没完整切换导致接口异常、发布后路径无法识别、Claude Code 被判离线、Terminal 没识别到环境变量,等等。光是自己在使用时就登记下来的问题,就有 80 多个。总而言之,这狗粮,是真难吃。

不过看着 TurboUI 相对干净的工作区和制品区,感觉还是挺好的。

最后,我让 AI 看 TokUI 的演示站点,给我重新生成一个更完整的 Demo,效果也还不错 —— 它直观地展示了 DSL 相比 HTML 节省的 Token 数,效果满意。

1
2
3
# 获取代码后执行
pnmp install
pnpm demo

Aifei-Go:为 AI Coding 而生的 Go 服务端框架

Aifei-Go 是 Aifei(Java 版)的 Go 语言移植版。它继承了 Aifei 的核心设计理念 —— Just Service 扁平架构、HIO 自主 IO 模型、Enjoy 自研模板引擎、Db + Row 数据库模式,并在 Go 的语言特性与生态上重新落地:用接口与 Handler 包装链替代动态代理,用 net/http 替代 Undertow,用 Radix 树路由替代注解扫描,同时又参考了 Solon,引入 Dami 提供进程内事件总线,引入 Nami 作为 HTTP RPC 客户端框架,用于支撑微服务。

注意事项:Aifei-Go 由 GLM5.2 生成,不喜勿入。Aifei 是基准,AI 翻译。人做决策,AI 生成。

Java 版文档:https://aifei.cn/doc

1. 什么是 Aifei-Go

Aifei 原本是”一款用于 AI Coding 的 Java 服务端框架”,其核心设计目标是 极小化 Token 消耗、极大化 Attention 浓度,让 AI 稳定生成高品质代码。要做到这一点,最有效的手段就是消除冗余分层与样板代码 —— 这也是 Aifei 开创 Just Service 范式 的根本动因。

近十年间,前后端分离成为主流,页面路由、交互编排与渲染职责大量转移到前端(react-router、vue-router 已接管了过去由服务端 Controller 承担的职责)。路由既然在前端,服务端就不再需要 Controller 这一层。Just Service 范式之下,开发者无需编写 Controller、Render、Repository、Mapper 这类冗余代码,直接写业务即可。

Aifei-Go 把这套理念原汁原味地带到了 Go 语言:

  • 只写 Service,不写 Controller —— 方法名即路由,按命名约定自动映射为 RESTful 端点。
  • 核心零外部依赖 —— 核心库与独立框架(aifei / enjoy / db / json / log / nami / dami)仅用 Go 标准库;插件按需引入第三方库。
  • 模块化、按需组合 —— 每个模块可独立 go get,不拉入多余依赖。
  • AI 友好 —— 代码量少、结构扁平、约定明确,AI 生成时上下文负担小、命中率高。

1.1 为什么从 Java 移植到 Go

Aifei Java 版内核仅 3333 行(内核核心仅 260 行),零第三方依赖,已经是极致精简。移植到 Go 带来三点额外收益:

维度 Java Aifei Go Aifei-Go
部署形态 JVM + 打包 单一静态二进制,毫秒级启动
并发模型 线程池 + 同步阻塞 goroutine 天然并发
依赖管理 Maven 多模块 Go workspace 多模块,按需 import
动态能力 CGLIB/Javassist 动态代理 接口 + Handler 包装链(无运行时反射代理)
HTTP 服务器 Undertow(去 Servlet) net/http(标准库,零依赖)
路由 注解 @Path + 包扫描 Radix 树 + 代码注册(编译期确定)

Java Aifei 中被废弃的三个模块(aifei-proxyaifei-undertowaifei-all)在 Go 里不再需要:Go 没有动态代理机制,AOP 由 Handler 包装链 + Interceptor 接口实现;Go 有标准库 net/http;Go 的 import 机制天然按需引入。保留下来的,是 Aifei 真正有价值的内核:HIO、Just Service、Enjoy、Db + Row。

2. 核心理念:Just Service

Just Service 的本质是:方法名即路由。一个 Go struct 的导出方法,按命名约定自动映射成一条 HTTP 路由,无需任何注解、配置文件或注册代码(注册由生成器在 init() 中自动完成)。

1
2
3
4
5
6
7
8
type UserService struct{}

func (s *UserService) List(in aifei.Input) aifei.Output { /* GET /api/user/list */ }
func (s *UserService) Paginate(in aifei.Input) aifei.Output { /* GET /api/user */ }
func (s *UserService) Create(in aifei.Input) aifei.Output { /* POST /api/user */ }
func (s *UserService) GetById(in aifei.Input) aifei.Output { /* GET /api/user/:id */ }
func (s *UserService) UpdateById(in aifei.Input) aifei.Output{ /* PUT /api/user/:id */ }
func (s *UserService) DeleteById(in aifei.Input) aifei.Output{ /* DELETE /api/user/:id */ }

server.Register() 通过两条规则把方法名翻译成 HTTP 方法 + URL:

  1. 默认动作(精确匹配) —— 直接挂在 service 前缀上:PaginateGET /prefixCreatePOST /prefixListGET /prefix/list
  2. 动词前缀 —— 方法名以(且长于)Get/Post/Put/Delete/Update 开头,动词决定 HTTP 方法,剩余部分 camelCase→kebab-case 作为路径后缀。例如 GetProfileGET /prefix/profile

两个特殊约定:

  • ById 后缀自动转为 :id 路径参数:GetByIdGET /prefix/:id
  • 既非默认动作、也不符合动词前缀的方法不会被路由 —— 天然成为私有 helper,无需额外的可见性控制。

对比 Java 版的 @Path 注解 + 包扫描:Go 没有注解,Aifei-Go 用「命名约定 + 代码注册」达成同样的效果,且路由在编译期就已确定,没有运行时反射扫描开销。

3. HIO 架构:Input / Output / Handler

Java Aifei 采用 HIO 自主架构(Handler + Input + Output),让用户自主掌控处理流程与数据结构。Aifei-Go 完整保留了这一设计,并用 Go 接口重新表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 处理流程的单元:Input 进、Output 出
type HandlerFunc func(in Input) Output

// Input = Param(读参数)+ Meta(请求元信息)
type Input interface {
Param // Has / GetStr / GetInt / GetBean / GetMap / PathPara ...
Meta // Context / Header / Path / Body
}

// Output 由业务构建,IoHandler 决定如何渲染
type Output interface {
Code() int
Msg() string
Data() interface{}
}

几个关键点:

  • Input 与 HTTP 解耦Input 只承载「任何调用源都能满足」的契约——参数读取与请求元信息。HTTP 专属的概念(method 动词、remote 地址、cookie)不在这个接口上,它们留在 HTTP 适配层。这意味着同一个 Service 方法既可被 HTTP 请求驱动,也可被测试桩、内部调用驱动,业务代码不绑死 HTTP。

  • Output 是意图,不是渲染。业务代码构建一个 Outserver.Ok() / server.Of(data) / server.Fail(msg)),它累积 code/msg/data 以及渲染意图(JSON、Enjoy HTML 视图、文件下载、原始字节、重定向)。IoHandler 读这些意图来决定 如何 写响应 —— 业务代码从不直接碰 net/http,文件下载、响应头都通过闭包/构建器表达。

  • 两条包装链。Aifei-Go 区分两类横切逻辑:Handler 级Input → Output,作用于业务处理,如 LoggerRecoverTxInterceptor)与 HTTP 级(作用于 http.Handler,如 CORSBasicAuthRequestID)。前者对应 Java Aifei 的全局拦截器,后者处理纯传输层关切。

Java 用 CGLIB/Javassist 动态代理实现 AOP,Go 没有这套机制 —— Aifei-Go 用 Handler 包装链 + Interceptor 接口 替代:ChainHandlers() 把一组 Handler 包装器组合成一条链,Interceptor 提供方法级 AOP(@Before / @Clear 的等价物)。

4. 模块结构

Aifei-Go 是一个 Go workspace 多模块项目,按角色分层。每个模块独立版本化、可独立 go get,互不拉入多余依赖:

模块 职责 依赖
核心框架 aifei-go/aifei Input/Output、Router、Handler wrapper、Interceptor
核心库 aifei-go/enjoy 模板/SQL 引擎(自研)
核心库 aifei-go/db 数据库访问(Row/Dao/Dialect/Enjoy SQL) enjoy
核心库 aifei-go/json JSON 工具
核心库 aifei-go/log 日志接口
核心库 aifei-go/config 分层配置(yml + 环境变量 + 命令行 + 云配置) yaml.v3
运行时 aifei-go/http net/http 适配器 aifei
运行时 aifei-go/server 启动引导、内置 Handler、OutRegister aifei, http, db, enjoy, log
独立框架 aifei-go/nami HTTP RPC 客户端框架
独立框架 aifei-go/dami 进程内事件总线(send/call/stream/lpc)
代码生成 aifei-go/tools/generator Schema → 类型安全 CRUD 代码 db, enjoy
代码生成 aifei-go/tools/damigen dami 相关代码生成 enjoy
插件 plugins/cache 两级缓存(本地 + Redis) jetcache-go, go-redis
插件 plugins/kafka Kafka 生产/消费 franz-go
插件 plugins/nacos 服务注册、配置中心、发现 nacos-sdk-go
插件 plugins/storage 文件存储(本地 + S3 兼容) minio-go
插件 plugins/swagger OpenAPI 文档(knife4j-vue3) swaggo/swag
插件 plugins/dataisolate 租户 + 行/列数据隔离 GoSQLX

「核心库」层的模块(enjoy / db / json / log)刻意保持零外部依赖、可脱离框架独立使用——你完全可以只用 enjoy 做模板渲染,或只用 db 做数据库访问,而不引入整个 web 框架。插件层则把第三方库的集成集中隔离,不污染核心。

5. 快速开始

1
go get github.com/crazy-airhead/aifei-go/aifei

一个最小的 HTTP 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"github.com/crazy-airhead/aifei-go/aifei"
"github.com/crazy-airhead/aifei-go/server"
)

func main() {
app := aifei.New()

// 全局 Handler 包装链
app.Use(server.Logger(), server.Recover())

// HandlerFunc: func(in aifei.Input) aifei.Output
app.GET("/", func(in aifei.Input) aifei.Output {
return server.Of("Hello, Aifei!")
})

app.GET("/hello/:name", func(in aifei.Input) aifei.Output {
return server.Ok("Hello, " + in.GetStr("name"))
})

// 启动(支持 CORS、BasicAuth 等 HTTP 级包装器)
server.Run(app, ":8080", server.WithCORS("*"))
}

而一个真实的多 Service 应用,骨架甚至更短——所有表对应的 Service 通过生成器在各自的 init() 里自注册,主程序只需一行 server.AutoRegisterServices(app)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
db.Init("sqlite", "./demo.db")
// ...建表...

app := aifei.New()
app.Use(server.Logger(), server.Recover())

// 每个 per-table 包在 init() 中注册自己的 Table 元数据与 Service 路由
_ "github.com/crazy-airhead/aifei-go/_test/demo/internal/user"
_ "github.com/crazy-airhead/aifei-go/_test/demo/internal/loginlog"

server.AutoRegisterServices(app) // 一行注册全部 Service
server.Run(app, ":8081", server.WithCORS("*"))
}

完整可运行示例见 _test/demogo run ./_test/demo)。

6. 核心特性

6.1 Enjoy 模板引擎

Enjoy 是 Aifei 的招牌特性——一套自研的模板语言(~2800 行),自带词法分析器(DKFF 算法)、递归下降语法分析器(DLRD)和完整的表达式引擎。它不仅是页面渲染引擎,也是 SQL 模板引擎(见 6.3)。

1
2
3
engine := enjoy.NewEngine("myEngine")
tpl := engine.GetTemplateByString("Hello, #(name)! Age: #(age)")
out := tpl.RenderToString(map[string]interface{}{"name": "james", "age": 18})

支持的语法:#() 表达式输出、#if/#else/#elseif#for#set/#setLocal/#setGlobal#define/#call#include#switch/#case/#default#break/#continue/#return;表达式层支持算术/比较/逻辑/三元、空安全(???.)、方法调用、map/数组字面量、静态访问(::)。

6.2 Db + Row + Dao

数据库访问沿用 Java Aifei 的 Db + Row 模式(与 JFinal 的 Db + Record 几乎一致),核心是链式 API 与 Active Record 变更追踪。db 模块本身不含驱动,用户自行引入所需驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import (
"github.com/crazy-airhead/aifei-go/db"
_ "modernc.org/sqlite" // 或 _ "github.com/go-sql-driver/mysql"
)

func main() {
db.Init("sqlite", "./app.db")

// Active Record —— 插入
row := db.NewRow("user").Set("name", "james").Set("age", 18)
result, _ := db.Insert(row)

// 主键查询
found, _ := db.FindByID("user", result.GetID())

// 分页查询
page, _ := db.RawSql("SELECT * FROM user ORDER BY id DESC").Paginate(1, 10)
_ = found
_ = page
}

内置 MySQL / PostgreSQL / SQLite 三种方言,支持事务、批量操作、类型转换。Row.Set() 追踪变更(用于 UPDATE),Put() 不追踪——精确控制更新范围。

6.3 Enjoy SQL:模板化的动态 SQL

db.Sql(...) 接受 Enjoy 模板作为 SQL,提供 #where / #and / #orderBy / #para 等指令,支持 18 种操作符,条件为空时自动省略——这是处理「动态查询条件」最优雅的方式,告别手写字符串拼接:

1
2
3
4
5
data := map[string]interface{}{"minAge": 18, "status": 1}
list, _ := db.Sql(
"SELECT * FROM user #where() #and(age > #para(minAge)) #and(status = #para(status))",
data,
).Find()

#orderBy 指令接收一个字段白名单,实际排序字段从入参读取并校验——防 SQL 注入的同时支持多字段排序与字段名映射。

6.4 代码生成器

Aifei 没有 Model 概念——Model 的支持完全由生成器实现。tools/generator 扫描数据库 Schema,每张表生成一个独立包base.go / model.go / dao.go / service.go),提供编译期类型安全的 CRUD API:

1
2
3
4
5
6
gen := generator.New(pool, dialect, "./myapp/db", "myapp/db")
gen.Generate() // 一次扫描所有表:user/、loginlog/ …

// 使用生成的类型安全 API
u, _ := user.FindById(123)
u.SetName("new name").Update()

每表一包的策略让每张表的字段都有具名 getter/setter,AI 生成业务代码时不必猜测字段名拼法,命中率显著提升。

6.5 分层配置

config 模块提供分层加载(L1–L5):app.yml + app-{env}.yml → 扩展配置 → 环境变量 + 命令行参数 → 编程式 LoadInto() → 云配置(如 Nacos)。线程安全,支持运行时热更新。提供 Get/GetStr/GetBool/GetInt 访问器、Sub(prefix) 作用域切片、Bind(v) YAML 往返到自定义结构体。所有插件统一从 config.Props 读取自身配置(storage.*cache.*kafka.* …),约定一致。

6.6 插件生态

插件实现 aifei.PluginStart()/Stop() 生命周期),从 config.Props 读自身配置并装配一个包级默认实例,让顶层便捷函数开箱即用:

  • plugins/cache —— 两级缓存(本地 FreeCache/TinyLFU + Redis),GetOrStore 自带 singleflight 与缓存穿透防护,实例级 key 前缀隔离。
  • plugins/storage —— 统一本地文件系统与 S3 兼容后端(AWS S3 / Minio / OSS / COS),按 bucket 路由。
  • plugins/kafka —— 基于 franz-go 的生产/消费,多集群,Subscribe 至少一次投递(失败记录不提交、下次重投)。
  • plugins/nacos —— 服务注册与发现、配置中心,自动桥接到 nami RPC 客户端;init() 自动注册云配置加载器。
  • plugins/swagger —— 内嵌 knife4j-vue3 UI 的 OpenAPI 文档插件。
  • plugins/dataisolate —— 租户 + 行 + 列三正交维度的数据隔离,AST 改写 SQL,应用代码零隔离感知(详见 data-isolate-intro.md)。

6.7 独立框架:Nami 与 Dami

两个不依赖 aifei 的兄弟框架,可独立使用:

  • nami —— 轻量 HTTP RPC 客户端框架(移植自 Java Solon Nami)。Channel 传输(channel/http)、编解码(coder/json)、Filter 链、Upstream/Discovery 服务发现、流式 Builder/ClientFactory。它是 aifei 服务端的对偶:aifei 暴露服务,nami 消费服务。
  • dami —— 进程内事件总线(send/call/stream/lpc),发布订阅 + 同步调用 + 流式返回,用于解耦模块间通信。

7. Java Aifei → Go Aifei-Go 设计决策

移植并非逐行翻译,而是在保持 API 风格一致的前提下,用 Go 惯用模式替代 Java 机制:

决策点 Java Aifei Go Aifei-Go 理由
请求上下文 Input + Output 接口 Input / Output 接口(保持分离) 与 Java API 一致,职责清晰
AOP / 拦截 CGLIB/Javassist 动态代理 + Interceptor Handler 包装链 + Interceptor 接口 Go 无运行时动态代理
路由注册 @Path 注解 + 反射包扫描 命名约定 + Register() 代码注册 Go 无注解,编译期确定
泛型 Java 泛型 Handler<I, O> Go 接口 简化接口
配置 AifeiConfig 接口 + 多个 config() Functional Options 模式 Go 惯用配置模式
HTTP 服务器 Undertow(去 Servlet) net/http(http 适配 + server 启动) 标准库,零依赖
包扫描 ClassLoader + JAR 扫描 不需要(Go 静态编译) 编译时确定所有代码
依赖注入 @Inject + 反射 构造函数注入 Go 惯例
路由匹配 HashMap + ActionGroup Radix 树 高性能路由标准做法
错误处理 throws Throwable error 返回值 + panic/recover Go 惯例
聚合模块 aifei-all import 按需引入 不再需要

四条贯穿全局的重写原则:保持 API 风格一致Go 惯用优先核心最小依赖AI 友好

8. 继承与差异

继承自 Java Aifei 的(最有价值的设计):Just Service 范式、HIO 自主架构、Enjoy 模板引擎、Db + Row 模式与链式 API、sql(sql, para).find() 式查询入口、生成器生成的 Model、集中式配置中心思想、拦截器(@Before/@Clear 的等价物)。

Go 版新增/调整的

  • 路由约定化 —— 用动词前缀 + 默认动作的命名约定替代 @Path,方法名直接表达 HTTP 语义。
  • 代码生成深化 —— 每表一包 + typed Dao,编译期类型安全;Service 在 init() 中自注册,主程序零路由样板。
  • 插件生态扩展 —— 在 Java 插件机制之上,新增 cache / kafka / storage / swagger / dataisolate 等开箱即用插件,全部配置驱动。
  • 兄弟框架 —— nami(RPC 客户端)与 dami(事件总线)作为独立模块,与 aifei 服务端组合成完整微服务工具链。
  • 数据隔离 —— 通过 plugins/dataisolate 提供声明式、AST 改写式的租户/行/列隔离,这是 Java 版未单独成插件的能力。

9. 小结

Aifei-Go 的定位很明确:把 Aifei「为 AI Coding 而生」的设计哲学,用 Go 的方式重新实现一遍

它不追求大而全。核心框架零外部依赖,只依赖标准库;它不引入 Controller/Service/DAO 的冗余分层,一个 struct 方法就是一条路由;它把模板引擎、SQL 模板、ORM、代码生成、配置、缓存、消息、存储、数据隔离做成正交的模块,按需取用。

如果你用过 JFinal 或 Java Aifei,你会对 Db + Row、Enjoy SQL 指令、集中式配置感到熟悉;如果你是 Go 开发者,你会发现它的接口设计、错误处理、并发模型都是地道的 Go 风格。而无论哪种背景,Just Service 让你把注意力放在业务上 —— 这正是 Aifei 一开始就想做到的事。

延伸阅读

今天早上,系统又提示磁盘空间不足。我查看了一下操作系统的存储管理,发现可用空间已不足 5GB,于是打开 Tencent Lemon Lite 做了一次深度扫描。

首先发现 QQ、微信、企业微信、钉钉、Mixin 等聊天软件占用了大量数据。这些软件自带了存储管理功能,把图片、视频和文件清理之后,一下子腾出了约 20G 空间。

接着,我注意到 Ollama 的模型也占用了不少空间。考虑到最近主要使用云服务,基本没用本地模型,便把 Ollama 和 deepseek 和 qwen 的几个模型一起删除掉,又释放了约 15G。

在存储管理中,看到 SystemData 占用了大约 200GB,因为有上一次清理硬盘的经历,我直接去查看 ~/Library/Application Support~/Library/Caches 目录。这些数据大多是应用运行时产生的,虽然可以删除,也不影响应用的使用。话虽这么说,真要删的时候还是会犹豫,不知道该删哪些好。

清理完这些数据后。系统又多了 100 G 的空余空间,又能撑上一阵子。

最近看 Anthropic 的产品经理说的,如果一件事情重复三次就要考虑自动化。那么为什么不定制个磁盘清理工具,给 AI 提了清理 System Data、清理软件删除残留、清理聊天工具缓存的需求。

系统的整体硬盘情况

img

开发工具产出的缓存数据

img

清理应用残留

img

清理聊天工具缓存

img

开发工具缓存扫描和清理就是一个非常个性化的需求。随着 AI 让软件创建变得越来越容易,定制化需求也会越来越多。但 AI 的本质是基于概率的,存在“抽卡”式的不确定性,所以软件可能会日更,却不可能日抛。软件需要不断积累,需要不停的维护。

说明

2026-06-06 Aifei 框架正式发布了。波总在五一的时候已经放出了 aifei 的正式版和 vip的订阅,作为第一批用户,用上了 aifei-vip-arch 和 aifei-vip,趁着五一学习了一波,顺便让还让AI 生成了Aifei-dev 的 skills,感兴趣的同学可以试一试,https://gitee.com/CrazyAirhead/aifei-dev。当然为了验证这个 skills 我也是做了个示例的,简单的截两张图,看看效果:

img

img

因为 Aifei 刚发布,还有很多生态没有起来,而我日常使用的都是 Solon,而且 Solon 生态已经比较完善,又因为非常喜欢 Aifei,于是先把 aifei-enjoy 和 aifei-db 集成到 Solon 生态里面。Aifei 的完全使用就边用边学吧。

集成 aifei-enjoy

由我贡献的 solon-view-aifei-enjoy 插件已经被合并到 Solon 主版本,将会在Solon 的 4.x 版本发布,https://solon.noear.org/article/1456。

因为 Solon 原来就集成了 enjoy,enjoy 又是波总比较新的作品,此次 aifei-enjoy,主要是调整包名,没有太大的变动。所以集成 aifei-enjoy,主要是在 solon-view-enjoy 插件的基础上修复包名。

有一点需要注意的就是enjoy 不支持 Directive 工厂,Solon 的作者自己做一个 enjoy 的扩展,也就是 solon-view-enjoy 不是使用原版的 enjoy 的 jar 包。在 aifei-enjoy 中,提供了原生的支持,所以可以优先使用 Solon 的容器对象。

1
2
3
4
5
6
7
8
9
10
Engine.setDirectiveFactory(
(cls) -> {
Directive directive = Objects.requireNonNull(Solon.context()).getBean(cls);
if (directive != null) {
return directive;
}

// 兜底
return ClassUtil.newInstance(cls);
});

集成 aifei-db

aifei-db-solon-plugin 的插件,我还在开发中,主体功能是基于 activerecord-solon-plugin 改造的,因为 aifei-db 相对于 activerecord 有较大的变动,我要多验证下,暂时还没有签入和提交 PR。

img

相对于 ActiveRecord,aifei-db 中的 AifeiRow 做了比较多的增强,因此注册AifeiRow被简化。

1
2
3
4
5
6
7
8
9
10
11
/**
* @author airhead
*/
public class ModelManager {
@Getter static Set<Class<? extends AifeiRow<?>>> modelSet = new LinkedHashSet<>();

@SuppressWarnings("unchecked")
public static void addModel(Table table, AifeiRow<?> model) {
modelSet.add((Class<? extends AifeiRow<?>>) model.getClass());
}
}

另外需要注意的是 aifei-core 不包含 Slf4jLogFactory,因此需要自己扩展一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
package top.godenyear.ray.framework.aifei.db;

import cn.aifei.log.Log;
import cn.aifei.log.LogFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Aifei LogFactory 的 Slf4j 桥接实现。 解决 aifei-core 不含 Slf4jLogFactory 的问题。
*
* @author airhead
*/
public class Slf4jLogFactory implements LogFactory {

@Override
public Log getLog() {
return getLog(LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME));
}

@Override
public Log getLog(Class<?> clz) {
return getLog(LoggerFactory.getLogger(clz));
}

@Override
public Log getLog(String name) {
return getLog(LoggerFactory.getLogger(name));
}

private Log getLog(Logger slf4jLogger) {
return new Log() {
@Override
public String getName() {
return slf4jLogger.getName();
}

@Override
public boolean isTraceEnabled() {
return slf4jLogger.isTraceEnabled();
}

@Override
public void trace(String msg, Throwable t) {
slf4jLogger.trace(msg, t);
}

@Override
public void trace(String msg) {
slf4jLogger.trace(msg);
}

@Override
public void trace(String format, Object arg) {
slf4jLogger.trace(format, arg);
}

@Override
public void trace(String format, Object arg1, Object arg2) {
slf4jLogger.trace(format, arg1, arg2);
}

@Override
public void trace(String format, Object... args) {
slf4jLogger.trace(format, args);
}

@Override
public void trace(java.util.function.Supplier<String> supplier) {
if (isTraceEnabled()) {
slf4jLogger.trace(supplier.get());
}
}

@Override
public boolean isDebugEnabled() {
return slf4jLogger.isDebugEnabled();
}

@Override
public void debug(String msg, Throwable t) {
slf4jLogger.debug(msg, t);
}

@Override
public void debug(String msg) {
slf4jLogger.debug(msg);
}

@Override
public void debug(String format, Object arg) {
slf4jLogger.debug(format, arg);
}

@Override
public void debug(String format, Object arg1, Object arg2) {
slf4jLogger.debug(format, arg1, arg2);
}

@Override
public void debug(String format, Object... args) {
slf4jLogger.debug(format, args);
}

@Override
public void debug(java.util.function.Supplier<String> supplier) {
if (isDebugEnabled()) slf4jLogger.debug(supplier.get());
}

@Override
public boolean isInfoEnabled() {
return slf4jLogger.isInfoEnabled();
}

@Override
public void info(String msg, Throwable t) {
slf4jLogger.info(msg, t);
}

@Override
public void info(String msg) {
slf4jLogger.info(msg);
}

@Override
public void info(String format, Object arg) {
slf4jLogger.info(format, arg);
}

@Override
public void info(String format, Object arg1, Object arg2) {
slf4jLogger.info(format, arg1, arg2);
}

@Override
public void info(String format, Object... args) {
slf4jLogger.info(format, args);
}

@Override
public void info(java.util.function.Supplier<String> supplier) {
if (isInfoEnabled()) slf4jLogger.info(supplier.get());
}

@Override
public boolean isWarnEnabled() {
return slf4jLogger.isWarnEnabled();
}

@Override
public void warn(String msg, Throwable t) {
slf4jLogger.warn(msg, t);
}

@Override
public void warn(String msg) {
slf4jLogger.warn(msg);
}

@Override
public void warn(String format, Object arg) {
slf4jLogger.warn(format, arg);
}

@Override
public void warn(String format, Object arg1, Object arg2) {
slf4jLogger.warn(format, arg1, arg2);
}

@Override
public void warn(String format, Object... args) {
slf4jLogger.warn(format, args);
}

@Override
public void warn(java.util.function.Supplier<String> supplier) {
if (isWarnEnabled()) {
slf4jLogger.warn(supplier.get());
}
}

@Override
public boolean isErrorEnabled() {
return slf4jLogger.isErrorEnabled();
}

@Override
public void error(String msg, Throwable t) {
slf4jLogger.error(msg, t);
}

@Override
public void error(String msg) {
slf4jLogger.error(msg);
}

@Override
public void error(String format, Object arg) {
slf4jLogger.error(format, arg);
}

@Override
public void error(String format, Object arg1, Object arg2) {
slf4jLogger.error(format, arg1, arg2);
}

@Override
public void error(String format, Object... args) {
slf4jLogger.error(format, args);
}

@Override
public void error(java.util.function.Supplier<String> supplier) {
if (isErrorEnabled()) {
slf4jLogger.error(supplier.get());
}
}
};
}
}

处理序列化问题

不管是 ActiveRecord 中 Model,还是Aifei 中的 AifeiRow,AifeiModel,他的底层数据结构是 Map,通过Bean 方法包装后进行 Bean 的操作,而 AifeiModel 更进一步使用了链式的 setter。

在 Solon 文档 https://solon.noear.org/article/18 中说到,Model 转 Json 输入的方法,不过在我的使用过程中体验不是很好。

1
2
3
4
5
6
7
8
9
10
11
//应用加载完成事件
@Componentpublic
class AppPluginLoadEndEventListener implements EventListener<AppPluginLoadEndEvent> {
@Overridepublic void onEvent(AppPluginLoadEndEvent e) throws Throwable {
//定制 json 序列化输出(使用新的处理接管 "@json" 指令)
e.app().renders().register("@json", (data, ctx) -> {
String json = JFinalJson.getJson().toJson(data);
ctx.outputAsJson(json);
});
}
}

这次在进一步整合 Aifei-db 的过程中,算是找到了一些比较好的集成方式,可以参看Solon 的 issue,重点涉及 EntityConverter 和 PropsConverterExt。

https://gitee.com/opensolon/solon/issues/IJSD9V https://gitee.com/opensolon/solon/issues/IJSCEX

主体的思路是通过完整的替换序列化插件,从而减少扩展链路和调整的地方。

Snack4StringSerializer

mime 为空时也使用JSON序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 是否匹配
*
* @param ctx 请求上下文
* @param mime 内容类型
*/
@Override
public boolean matched(Context ctx, String mime) {
if (mime == null || mime.isEmpty()) {
return true;
} else {
return mime.contains(label) || mime.startsWith(MimeType.APPLICATION_X_NDJSON_VALUE);
}
}

Snack4EntityConverter

changeEntityDo 时使用JSON 进行转换,而不是交给 ClassUtil 处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Snack 实体转换器
*
* @author noear
* @since 3.6
*/
public class Snack4EntityConverter extends AbstractStringEntityConverter<Snack4StringSerializer> {
@Override
protected Object changeEntityDo(Context ctx, ParamWrap p, String name, Class<?> type)
throws Exception {
Props props = new Props().addAll(ctx.paramMap());
Options options = serializer.getDeserializeConfig().getOptions();
return ONode.ofBean(props, options).toBean(type);
}
}

针对 AifeiRow 提供相应的 encoder

ModelEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* @author airhead
*/
public class ModelEncoder<T extends AifeiRow<?>> implements ObjectPatternEncoder<T> {
@Override
public ONode encode(EncodeContext ctx, T value, ONode node) {
if (value == null) {
return node;
}

return encode(node, value.data());
}

@Override
public boolean canEncode(Object value) {
return value instanceof AifeiRow;
}

protected ONode encode(ONode node, Map<String, Object> map) {
if (CollUtil.isEmpty(map)) {
return node;
}

node.fill(map);

return node;
}
}

配置 Snack4StringSerializer

为AifeiRow 指定 ModelEncoder

1
2
3
4
5
6
7
8
context.getBeanAsync(
Snack4StringSerializer.class,
serializer -> {
serializer.getDeserializeConfig().addFeatures(Feature.Decode_AllowUseSetter);
serializer.getSerializeConfig().addFeatures(Feature.Write_UseSmlCamelStyle);

serializer.addEncoder(AifeiRow.class, new ModelEncoder<>());
});

小结

经过以上的处理,现在应该能在 Solon 里面愉快的玩耍 aifei-enjoy 和 aifei-db了,enjoy 模版,SQL 模板,原来想要的都回来了。虽然 Solon 比 Aifei 的概念会多一些,但整体还是非常克制的,因此即便多了一些 Controller的逻辑,对于AI 的消耗也不会增加太多。

补充

关于 aifei 的一些尝试,可以仓库代码 https://github.com/crazy-airhead/aifei

  1. 扩展 aifei-json-snack4,替代fastjson2,使用 snack4 进行处理 JSON。
  2. 扩展 aifei-feathttp, 替代undertow,使用 feathttp(原来的smarthttp)作为底层服务器。

过去遇到不顺手的软件,我们要么将就,要么苦等别人更新。哪怕自己是程序员,也很少动念头给自己写一个 —— 毕竟打通设计、交互和编码全链路实在太耗时耗力了(编程大佬除外)。如今,AI 颠覆了这一切,代码生成速度快得惊人:过去程序员要写上几天甚至一个月的代码量,现在一天甚至几小时就能搞定。于是,那些曾经“能忍则忍”的将就,都变成了必须满足的需求,因为技术让定制变得“理所应当”且“触手可及”。软件定制化的时代开启了。

阅读全文 »

之前看到京东微前端框架 MicroApp 的时候,就觉得这个东西挺有意思的,可以和后端的微服务,或者插件系统对接起来用。

MicroApp 官网是这么介绍微前端的(https://jd-opensource.github.io/micro-app/docs.html#/)

微前端的概念是由 ThoughtWorks 在2016年提出的,它借鉴了微服务的架构理念,核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用融合为一个完整的应用,或者将原本运行已久、没有关联的几个应用融合为一个应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。
它主要解决了两个问题:
1、随着项目迭代应用越来越庞大,难以维护。
2、跨团队或跨部门协作开发项目导致效率低下的问题。

虽然也尝试过几次,但效果都不理想,毕竟自己不会前端,很多布局类的东西就弄不出来,于是就没再搞了。这次让 AI 重新设计的时候,他是这么回答的,我感觉有戏了。

![image-20260404221920910](/Users/airhead/Library/Application Support/typora-user-images/image-20260404221920910.png)

于是让他不停的改,在耗光一周的 Token 限额的时候,总算有想要的效果了。主应用和子应用都是基于 Soybean Admin 进行修改的。

  • 主应用

![image-20260404222447210](/Users/airhead/Library/Application Support/typora-user-images/image-20260404222447210.png)

  • 子应用模板

![image-20260404222544000](/Users/airhead/Library/Application Support/typora-user-images/image-20260404222544000.png)

  • 系统管理

![image-20260404222624817](/Users/airhead/Library/Application Support/typora-user-images/image-20260404222624817.png)

说明

使用 whisper 的时候,发现有的时候会出现一些无法识别的部分,或者出现一些特殊的结尾,或者出现繁体,或者没有标点符号等问题。这些都需要额外的处理,导致识别的准确度不高,同时识别数据也不是很快,因此一直也在找有没有更好用的模型。

最近发现了 FunASR。FunASR 的官网是这么介绍的,FunASR 是离线文件转写软件包,提供了一款功能强大的语音离线文件转写服务。拥有完整的语音识别链路,结合了语音端点检测、语音识别、标点等模型,可以将几十个小时的长音频与视频识别成带标点的文字,而且支持上百路请求同时进行转写。输出为带标点的文字,含有字级别时间戳,支持ITN与用户自定义热词等。服务端集成有ffmpeg,支持各种音视频格式输入。软件包提供有html、python、c++、java与c#等多种编程语言客户端,用户可以直接使用与进一步开发。

我用来处理原来的音频文件,速度提升了不少,而且可以节省一些步骤,FunASR 提供的服务已经包含了对 mp3 文件的转换,文本也已经加了标点。

部署

FunASR 提供了 Docker 环境可以直接部署(https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_offline_zh.md),注意修改卷的地址为自己的实际地址。

1
2
3
4
5
6
7
8
docker pull \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.7

mkdir -p /Users/airhead/funasr-runtime-resources/models

docker run -p 10095:10095 -it --privileged=true \
-v /Users/airhead/funasr-runtime-resources/models:/workspace/models \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-cpu-0.4.7

执行上面的 docker 命令会直接进入容器内部,接着执行下面,可以启动 FunASR 服务,如果第一次启动,可以去掉 nohup> log.txt 2>&1 &的部分,方便查看日志。

1
2
3
4
5
6
7
8
9
10
cd FunASR/runtime
nohup bash run_server.sh \
--download-model-dir /workspace/models \
--model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx \
--vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx \
--punc-dir damo/punc_ct-transformer_cn-en-common-vocab471067-large-onnx \
--lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst \
--itn-dir thuduj12/fst_itn_zh \
--certfile 0 \
--hotword /workspace/models/hotwords.txt > log.txt 2>&1 &

执行上述指令后,启动离线文件转写服务,但关闭了 SSL。如果模型指定为ModelScope中model id,会自动从MoldeScope中下载如下模型: FSMN-VAD模型, Paraformer-lagre模型, CT-Transformer标点预测模型, 基于FST的中文ITN, Ngram中文语言模型

参数

–download-model-dir 模型下载地址,通过设置model ID从Modelscope下载模型
–model-dir 主ASR识别模型,必选modelscope model ID 或者 本地模型路径
–vad-dir 语音活动检测模型,可选modelscope model ID 或者 本地模型路径
–punc-dir 标点恢复模型,可选modelscope model ID 或者 本地模型路径
–lm-dir 语言模型,可选modelscope model ID 或者 本地模型路径
–itn-dir 逆文本归一化,可选modelscope model ID 或者 本地模型路径
–port 服务端监听的端口号,默认为 10095
–decoder-thread-num 服务端线程池个数(支持的最大并发路数),脚本会根据服务器线程数自动配置decoder-thread-num、io-thread-num
–io-thread-num 服务端启动的IO线程数
–model-thread-num 每路识别的内部线程数(控制ONNX模型的并行),默认为 1,其中建议 decoder-thread-num*model-thread-num 等于总线程数
–certfile sl的证书文件,默认为:../../../ssl_key/server.crt,如果需要关闭ssl,参数设置为0
–keyfile ssl的密钥文件,默认为:../../../ssl_key/server.key
–hotword 热词文件路径,每行一个热词,格式:热词 权重(例如:阿里巴巴 20),如果客户端提供热词,则与客户端提供的热词合并一起使用,服务端热词全局生效,客户端热词只针对对应客户端生效。

刚接触 FunASR 可能对启动的几个模型感到困惑,因此这里做些补充。

img

语音活动检测(–vad-dir)

分离音频中的语音和非语音。

主ASR模型(–model-dir )

语音识别的主体模型,将音频转换为原始文本。

标点恢复(–punc-dir)

为ASR输出的无标点文本添加标点(逗号、句号等)

语言模型(–lm-dir)

使用语言模型进行二次解码,提升识别准确率。使用场景:对识别结果要求较高的场景。

逆文本归一化(–itn-dir)

将数值统一使用中文表达。比如,将”123”转为”一百二十三”,”10:30”转为”十点三十分”

模型

可以在魔搭上选合适的模型。

https://www.modelscope.cn/models?page=1&tasks=auto-speech-recognition

使用

使用样例在 https://github.com/modelscope/FunASR 仓库下的 runtime 目录。

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.goldsyear.solon.asr.config;

import lombok.Data;
import org.noear.solon.annotation.Configuration;

/**
* FunASR 配置类
*
* @author airhead
*/
@Configuration
@Data
public class FunAsrConfig {

/**
* WebSocket 服务地址,例如:ws://localhost:10095/funasr/ws/offline
*/
private String serverUrl;

/**
* 模式:offline(离线模式)
*/
private String mode = "offline";

/**
* 音频文件格式:pcm/mp3/mp4 等
*/
private String wavFormat = "pcm";

/**
* 是否启用文本规范化
*/
private Boolean itn = true;

/**
* 热词配置(JSON 字符串格式),例如:{"关键词":20}
*/
private String hotwords;

/**
* 超时时间(秒)
*/
private Integer timeout = 300;

/**
* PCM 采样率
*/
private Integer sampleRate = 16000;
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
package com.goldsyear.solon.asr.client;

import com.goldsyear.solon.asr.config.FunAsrConfig;
import com.goldsyear.solon.asr.exception.FunAsrException;
import com.goldsyear.solon.asr.model.FunAsrResult;
import com.goldsyear.solon.common.core.model.KeyValue;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okio.ByteString;
import org.jspecify.annotations.NonNull;
import org.noear.snack4.ONode;

/**
* FunASR WebSocket 客户端
*
* @author airhead
*/
@Slf4j
public class FunAsrWebSocketClient {

/**
* -- GETTER --
* 获取配置
*
* @return FunASR 配置
*/
@Getter
private final FunAsrConfig config;
private final OkHttpClient httpClient;

/**
* 创建 FunASR WebSocket 客户端
*
* @param config FunASR 配置
* @throws IllegalArgumentException 如果配置参数无效
*/
public FunAsrWebSocketClient(FunAsrConfig config) {
validateConfig(config);
this.config = config;
this.httpClient =
new OkHttpClient.Builder()
.readTimeout(config.getTimeout(), TimeUnit.SECONDS)
.writeTimeout(config.getTimeout(), TimeUnit.SECONDS)
.connectTimeout(config.getTimeout(), TimeUnit.SECONDS)
.build();
}

/**
* 识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @return 识别结果
*/
public FunAsrResult recognize(File audioFile, String wavName) {
return recognize(audioFile, wavName, null);
}

/**
* 识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @param hotWords 热词配置(可选,会覆盖配置中的热词)
* @return 识别结果
*/
public FunAsrResult recognize(File audioFile, String wavName, String hotWords) {
if (audioFile == null || !audioFile.exists()) {
throw new IllegalArgumentException("Audio file not found: " + audioFile);
}

// 读取音频文件
byte[] audioData;
try {
audioData = Files.readAllBytes(audioFile.toPath());
} catch (IOException e) {
throw new FunAsrException("Failed to read audio file: " + audioFile, e);
}

return recognize(audioData, wavName != null ? wavName : audioFile.getName(), hotWords);
}

/**
* 识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @return 识别结果
*/
public FunAsrResult recognize(byte[] audioData, String wavName) {
return recognize(audioData, wavName, null);
}

/**
* 识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @param hotwords 热词配置(可选,会覆盖配置中的热词)
* @return 识别结果
*/
public FunAsrResult recognize(byte[] audioData, String wavName, String hotwords) {
if (audioData == null || audioData.length == 0) {
throw new IllegalArgumentException("Audio data is empty");
}

// 构建 WebSocket 请求
Request request = new Request.Builder().url(config.getServerUrl()).build();

// 创建用于存储结果的容器
AtomicReference<FunAsrResult> resultRef = new AtomicReference<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);

// 创建 WebSocket 监听器
WebSocketListener listener =
new WebSocketListener() {
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
log.info("WebSocket closing: {} - {}", code, reason);
webSocket.close(code, reason);
// 如果已经有结果,立即释放 latch
// 对于离线模式,结果可能已经在 onMessage 中处理了
if (resultRef.get() != null) {
latch.countDown();
}
}

@Override
public void onFailure(
@NonNull WebSocket webSocket, @NonNull Throwable t, Response response) {
// 如果已经有结果且是因为关闭连接导致的错误,忽略这个异常
if (resultRef.get() != null && t instanceof java.net.SocketException) {
log.debug("WebSocket closed after result received: {}", t.getMessage());
return;
}
log.error("WebSocket error", t);
errorRef.set(t);
latch.countDown();
}

@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
log.info("Received message: {}", text);

try {
// 尝试解析消息
FunAsrResult result = parseResult(text);
// 立即缓存结果
resultRef.set(result);

// 离线模式:收到任何有效结果就立即返回
// 在线模式:等待 isFinal=true
if ("offline".equals(config.getMode())) {
log.info("Offline mode: received result, closing connection");
latch.countDown();
// 主动关闭连接
webSocket.close(1000, "Result received");
} else if (Boolean.TRUE.equals(result.getIsFinal())) {
log.info("Online mode: received final result");
latch.countDown();
}
} catch (Exception e) {
log.error("Failed to parse result: {}", text, e);
errorRef.set(e);
latch.countDown();
}
}

@Override
public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
log.debug("WebSocket connected");

// 发送初始化配置
try {
String initJson = buildInitConfig(wavName, hotwords);
log.debug("Sending init config: {}", initJson);
webSocket.send(initJson);

// 分块发送音频数据,每块 16KB
int chunkSize = 16 * 1024; // 16KB
int offset = 0;
while (offset < audioData.length) {
int length = Math.min(chunkSize, audioData.length - offset);
byte[] chunk = new byte[length];
System.arraycopy(audioData, offset, chunk, 0, length);
webSocket.send(ByteString.of(chunk));
log.debug(
"Sent audio chunk: {} bytes (offset: {}/{})", length, offset, audioData.length);
offset += length;
// 添加小延迟,避免发送过快
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.debug("Sent all audio data: {} bytes total", audioData.length);

// 发送结束标志
String endJson = buildEndFlag();
log.debug("Sending end flag: {}", endJson);
webSocket.send(endJson);

} catch (Exception e) {
log.error("Error during send", e);
errorRef.set(e);
latch.countDown();
webSocket.close(1000, "Error during send");
}
}
};

// 建立 WebSocket 连接
WebSocket webSocket = httpClient.newWebSocket(request, listener);

try {
// 等待结果或超时
boolean completed = latch.await(config.getTimeout(), TimeUnit.SECONDS);

if (!completed) {
throw new FunAsrException(
"FunASR recognition timeout after " + config.getTimeout() + " seconds");
}

// 检查是否有错误
Throwable error = errorRef.get();
if (error != null) {
throw new FunAsrException("FunASR recognition failed", error);
}

// 获取结果
FunAsrResult result = resultRef.get();
if (result == null) {
throw new FunAsrException("No result received from FunASR");
}

return result;

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FunAsrException("FunASR recognition interrupted", e);
} finally {
webSocket.cancel();
}
}

/**
* 异步识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(File audioFile, String wavName) {
return recognizeAsync(audioFile, wavName, null);
}

/**
* 异步识别音频文件
*
* @param audioFile 音频文件
* @param wavName 音频文件名(可选)
* @param hotWords 热词配置(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(
File audioFile, String wavName, String hotWords) {
return CompletableFuture.supplyAsync(() -> recognize(audioFile, wavName, hotWords));
}

/**
* 异步识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(byte[] audioData, String wavName) {
return recognizeAsync(audioData, wavName, null);
}

/**
* 异步识别音频数据
*
* @param audioData 音频数据(字节数组)
* @param wavName 音频文件名
* @param hotWords 热词配置(可选)
* @return Future 识别结果
*/
public CompletableFuture<FunAsrResult> recognizeAsync(
byte[] audioData, String wavName, String hotWords) {
return CompletableFuture.supplyAsync(() -> recognize(audioData, wavName, hotWords));
}

/** 关闭客户端 */
public void shutdown() {
httpClient.dispatcher().executorService().shutdown();
httpClient.connectionPool().evictAll();
}

/**
* 健康检查 尝试连接到 FunASR 服务器,检查服务是否可用
*
* @return true 如果服务可用,false 否则
*/
public boolean healthCheck() {
try {
Request request = new Request.Builder().url(config.getServerUrl()).build();

// 使用短超时进行连接测试
OkHttpClient testClient =
httpClient
.newBuilder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build();

AtomicReference<Boolean> connected = new AtomicReference<>(false);
CountDownLatch latch = new CountDownLatch(1);

WebSocketListener listener =
new WebSocketListener() {
@Override
public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t, Response response) {
connected.set(false);
latch.countDown();
}

@Override
public void onOpen(WebSocket webSocket, @NonNull Response response) {
connected.set(true);
latch.countDown();
webSocket.close(1000, "Health check");
}
};

WebSocket webSocket = testClient.newWebSocket(request, listener);
boolean completed = latch.await(10, TimeUnit.SECONDS);
webSocket.cancel();

return completed && connected.get();

} catch (Exception e) {
log.error("FunASR health check failed: {}", e.getMessage());
return false;
}
}

/**
* 验证配置参数
*
* @param config FunASR 配置
* @throws IllegalArgumentException 如果配置参数无效
*/
private void validateConfig(FunAsrConfig config) {
if (config == null) {
throw new IllegalArgumentException("FunAsrConfig cannot be null");
}
if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) {
throw new IllegalArgumentException("Server URL cannot be empty");
}
if (!config.getServerUrl().startsWith("ws://") && !config.getServerUrl().startsWith("wss://")) {
throw new IllegalArgumentException("Server URL must start with ws:// or wss://");
}
if (config.getTimeout() != null && config.getTimeout() <= 0) {
throw new IllegalArgumentException("Timeout must be positive");
}
}

/** 构建初始化配置 JSON */
private String buildInitConfig(String wavName, String hotWords) {
KeyValue data = KeyValue.of();
data.set("mode", config.getMode());
data.set("wav_name", wavName);
data.set("wav_format", config.getWavFormat());
data.set("is_speaking", true);
data.set("itn", config.getItn());

String hw = hotWords != null ? hotWords : config.getHotwords();
if (hw != null && !hw.isEmpty()) {
data.set("hotwords", hw);
}

return ONode.serialize(data);
}

/** 构建结束标志 JSON */
private String buildEndFlag() {
return ONode.serialize(KeyValue.of("is_speaking", false));
}

/** 解析识别结果 */
private FunAsrResult parseResult(String jsonText) {
KeyValue root = ONode.deserialize(jsonText, KeyValue.class);

FunAsrResult result = new FunAsrResult();

// 检查错误响应
if (root.notNull("error_code")) {
result.setErrorCode(root.getStr("error_code"));
result.setErrorMsg(root.getStr("error_msg"));
return result;
}

// 解析成功响应
result.setMode(root.getStr("mode"));
result.setWavName(root.getStr("wav_name"));
result.setText(root.getStr("text"));
result.setIsFinal(root.getBoolean("is_final"));
result.setTimestamp(root.getStr("timestamp"));

// 解析时间戳句子列表
if (root.notNull("stamp_sents")) {
result.setStampSents(parseStampSents((List<?>) root.get("stamp_sents")));
}

return result;
}

/** 解析时间戳句子列表 */
private List<FunAsrResult.StampSent> parseStampSents(List<?> stampSentsList) {
if (stampSentsList == null) {
return null;
}
List<FunAsrResult.StampSent> stampSents = new ArrayList<>();
for (Object item : stampSentsList) {
if (item instanceof KeyValue) {
KeyValue sentNode = (KeyValue) item;
FunAsrResult.StampSent stampSent = new FunAsrResult.StampSent();
stampSent.setTextSeg(sentNode.getStr("text_seg"));
stampSent.setPunc(sentNode.getStr("punc"));
stampSent.setStart(sentNode.getLong("start"));
stampSent.setEnd(sentNode.getLong("end"));
stampSent.setTsList(parseTsList(sentNode.getAs("ts_list")));
stampSents.add(stampSent);
} else if (item instanceof java.util.Map) {
// 处理普通 Map
@SuppressWarnings("unchecked")
java.util.Map<String, Object> sentNode = (java.util.Map<String, Object>) item;
FunAsrResult.StampSent stampSent = new FunAsrResult.StampSent();
stampSent.setTextSeg((String) sentNode.get("text_seg"));
stampSent.setPunc((String) sentNode.get("punc"));
stampSent.setStart(toLong(sentNode.get("start")));
stampSent.setEnd(toLong(sentNode.get("end")));
stampSent.setTsList(parseTsList((List<?>) sentNode.get("ts_list")));
stampSents.add(stampSent);
}
}
return stampSents;
}

/** 解析时间戳列表 */
private List<List<Integer>> parseTsList(List<?> tsListRaw) {
if (tsListRaw == null) {
return null;
}
List<List<Integer>> tsList = new ArrayList<>();
for (Object item : tsListRaw) {
if (item instanceof List<?> tsRaw) {
List<Integer> ts = new ArrayList<>();
for (Object num : tsRaw) {
ts.add(toInteger(num));
}
tsList.add(ts);
}
}
return tsList;
}

/** 安全转换为 Long */
private Long toLong(Object value) {
switch (value) {
case null -> {
return null;
}
case Long l -> {
return l;
}
case Integer i -> {
return i.longValue();
}
case Number number -> {
return number.longValue();
}
case String command -> {
try {
return Long.parseLong(command);
} catch (NumberFormatException e) {
return null;
}
}
default -> {}
}
return null;
}

/** 安全转换为 Integer */
private Integer toInteger(Object value) {
switch (value) {
case null -> {
return null;
}
case Integer i -> {
return i;
}
case Long l -> {
return l.intValue();
}
case Number number -> {
return number.intValue();
}
case String command -> {
try {
return Integer.parseInt(command);
} catch (NumberFormatException e) {
return null;
}
}
default -> {}
}

return null;
}

}

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Test
public void testRecognizeFile() {
// 创建配置
FunAsrConfig config = new FunAsrConfig();
config.setServerUrl("ws://localhost:10095/funasr/ws/offline");
config.setWavFormat("mp3");
config.setMode("offline");
config.setItn(true);
config.setHotwords("{}");
config.setTimeout(300);

// 创建客户端
FunAsrWebSocketClient client = FunAsrClientFactory.create(config);

try {
// 识别音频文件(请替换为实际的音频文件路径)
File audioFile = new File("1d829d6c-558b-4865-98cc-a3bcecffc485.mp3");
if (!audioFile.exists()) {
log.warn("Audio file not found: {}", audioFile.getAbsolutePath());
return;
}

FunAsrResult result = client.recognize(audioFile, "test_audio");

if (result.isSuccess()) {
log.info("识别成功:");
log.info("文本: {}", result.getText());
log.info("模式: {}", result.getMode());
log.info("时间戳: {}", result.getTimestamp());
} else {
log.error("离线识别失败: {} - {}", result.getErrorCode(), result.getErrorMsg());
}

} catch (Exception e) {
log.error("识别过程出错", e);
} finally {
client.shutdown();
}
}

项目

近期通过 Claude Code cli 工具(使用的是 GLM 4.7 的模型)开发了一些项目,主体代码通过 AI 生成,人工做些调整。

开发工具箱

通过 go 语言实现,基于 fyne UI 框架开发工具箱,解决 DevHub 工具不够趁手时还要额外在网上查找在线工具的问题。如果想加功能就让 AI 生成,想加就能加,想用就能用。

项目地址:https://gitee.com/CrazyAirhead/dev-tools

img

Java 模型转 SQL语句工具。

通过 Java 语言实现,解决项目只有 Model,没有表结构时,系统无法启动问题,工具的想法来自 easy-query 的 CodeFirst 模式。

项目地址:https://gitee.com/CrazyAirhead/model-to-sql

img

流程编辑器

通过 Typescript 语言实现,基于Soybean Admin(使用 Navie UI 和 vue-flow),解决 Solon Flow 流程编排问题,支持撤回、恢复、自动布局、编辑属性等功能。

项目地址:https://gitee.com/CrazyAirhead/porpoise-flow (未完成代码迁移,预留)

img

表单编辑器

项目基于 https://gitee.com/chengliang4810/naiveui-form-designer, 升级依赖的版本,并转换成 TypeScript 项目,为了能更好的集成到 Soybean Admin 中。

项目地址:https://gitee.com/CrazyAirhead/naiveui-form-designer

img

体会

  • 虽然自己当前使用 Claude Code(AI)的方法还是比较初级,很多东西也还是在摸索阶段。但 AI 已经能写很多自己之前不会的代码。不管怎么样,先用起来更重要。
  • AI 生成代码对于有代码基础的人会更友好一点。如果没基础的人员,还是应该补充的基础知识。
  • 有的时候碰到一些问题不好描述,可以指定文件、代码片段、甚至是变量,而不只是描述界面要怎么样。
  • 限制很重要,一开始需要选好技术框架,不要让AI自由发挥,并维护在 CLAUDE.md 中(如果没有生成的需要自己补充)。
  • 虽然 Claude Code 没有像 /init 一样提供 /update 的命令,但可以直接在对话中说更新 CLAUDE.md,把一些代码上的调整更新到 CLAUDE.md 中。
  • AI 生成的一个好处,就是能先给你提供一些思路或者基础代码,这些可能会改变你的想法,接着可以调整 prompt和设计方案,用重新生成的方式再来一次,比起自己写了推倒重来,迭代的速度变快很多。

Token 统计

最近一个月的 token 统计,也不知道算不算多。

img

如果觉得 GLM-4.7 也还行,最近还有优惠活动。

img