同一天做了两件事。表面上一个关于文件系统,一个关于流式生成,实际在解决同一个问题:长动作要么在一开始就能定好条件,要么在过程中能叫停。
一、工作目录原先只活在一次调用里
工具调用要指定工作目录。原先的实现是每次调用各自带一个。
这在单次调用里没问题,但会话是连续的:用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」,每次都指定一遍目录,重复且容易出错。
改成会话级能力。数据模型加两个字段:
-- 会话级工作文件夹:null 表示沿用设备上报的默认目录,对存量会话零影响。
ALTER TABLE "Session" ADD COLUMN "workspaceDir" TEXT;
-- 桌面端注册时上报的本机默认工作文件夹;老版本客户端不上报则保持 null。
ALTER TABLE "Device" ADD COLUMN "defaultWorkspaceDir" TEXT;
两个字段的分工是:设备上报它本机的默认目录,会话可以覆盖它。取值优先级:
会话值 ?? 设备默认值
迁移只用加列,null 表示沿用设备默认值,存量会话不受影响。
长度上限 512 字符,写在协议包里:WORKSPACE_DIR_MAX_LENGTH = 512。
二、四个执行入口共用同一个目录
目录信息要传到的位置比想象中多。同一件事在四个地方会被执行:
- 工具调用
- Agent 团队运行
- 设备协作
- 计划任务
如果只有工具调用拿到会话目录,其余三个各自回落到设备默认值,用户会遇到「我在会话里设了目录,跑计划任务却跑到了别的地方」。
所以目录跟着会话走,四个入口都从同一个地方取。
跨端还有一层:桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写,用户才能在会话里查看或切换目录。
三、路径校验的三个坑
工作目录涉及文件系统,三处安全处理值得单独写。
相对路径要先解析成绝对路径。
// 相对路径必须先解析成绝对路径再做高危校验,否则 ../../../etc/passwd
// 这类相对串匹配不上敏感路径正则会被直接放行
先做正则匹配再解析,等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。
打开目录前先确认它是目录。
// open-workspace-dir 先 stat 确认是目录才放行,避免经 shell.openPath 执行可执行文件
用系统调用打开路径时,如果路径指向一个可执行文件,行为会变成执行它。
目录不存在时要说明,不能静默回落。
// 换机时会话目录在本机不存在则回落设备默认目录,并在工具结果中明示,不静默。
这一条和一个功能细节有关:会话可以跨设备打开。用户在公司电脑上设的目录,回家打开时本机不存在。此时回落是唯一选择,但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。
四、停止生成
第二个功能是让流式回答可以中途停止。
场景很具体:模型开始生成一段长回答,用户看前两段就知道方向不对。原先只能等它写完,或者刷新页面重新提问。
停止信号走 Redis。
POST /api/chat/stop
校验会话归属之后,往 Redis 写一个停止信号。选 Redis 而不是进程内变量,是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。
生成侧轮询读信号。
// 生成侧用节流轮询(300ms)读信号,轮边界与流式回调两处检查点;在途工具跑完再收尾
两个检查点:一个是每轮对话的边界,一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态,等它跑完再收尾。
停止必须落一条消息。
// 停止一定落一条 assistant 消息并记 stoppedAt,否则前端会一直判成生成中
这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」,如果停止之后不落一条 assistant 消息,界面会一直显示在生成。
数据库加一个字段:Message.stoppedAt。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。
结算按实际用量。
// 结算走原有 settle 路径按实际用量多退少补;被停止的半截回答不进长期记忆
半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话,半截内容提取出来的「用户偏好」会是错的。
继续写。
停止之后,末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下,上下文里包含已经写好的部分。
五、界面上只有一个按钮
生成中,发送按钮变成停止按钮。桌面端和移动端两套布局都要改。
这个交互决定了一个细节:停止是一个和发送同一位置的操作。用户在等待时手指本来就停在那个位置,改文案比加一个新按钮更快。
六、桌面端版本
这两个功能里,工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。
顺序上有依赖:云端先上线,桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交,不是为了记录,是为了标记「从哪个包开始这个功能可用」。
七、两个功能的共同点
把它们放在一起看,都是「让用户在长动作里保留控制权」:
| 工作目录 | 停止生成 | |
|---|---|---|
| 控制发生在 | 事前指定 | 事中叫停 |
| 数据落点 | Session.workspaceDir | Message.stoppedAt |
| 跨端要求 | 主进程 / 预加载 / 桥接 / 界面 | 桌面与移动两套布局 |
| 不静默的地方 | 目录不存在时回落要明示 | 停止后必须落消息,界面不能一直显示生成中 |
四条对应关系里,最后一行是最容易做错的:两个功能都有「状态没落对,界面就一直显示在跑」的风险。工作目录那条如果静默回落,用户以为跑在 A 目录,实际跑在 B;停止那条如果不落消息,用户以为停了,界面说还在跑。
改法和预想的一样朴素:把状态放在能读到的地方,并且让用户看见。
■