交付与更新

让会话记住目录,让回答能停下

两个看似无关的功能:把工作目录从单次工具调用提升为会话级能力;让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。

STRUCTURE ── 篇幅剖面7 节 · 点击跳转

同一天做了两件事。表面上一个关于文件系统,一个关于流式生成,实际在解决同一个问题:长动作要么在一开始就能定好条件,要么在过程中能叫停。

一、工作目录原先只活在一次调用里

工具调用要指定工作目录。原先的实现是每次调用各自带一个。

这在单次调用里没问题,但会话是连续的:用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」,每次都指定一遍目录,重复且容易出错。

改成会话级能力。数据模型加两个字段:

-- 会话级工作文件夹: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.workspaceDirMessage.stoppedAt
跨端要求主进程 / 预加载 / 桥接 / 界面桌面与移动两套布局
不静默的地方目录不存在时回落要明示停止后必须落消息,界面不能一直显示生成中

四条对应关系里,最后一行是最容易做错的:两个功能都有「状态没落对,界面就一直显示在跑」的风险。工作目录那条如果静默回落,用户以为跑在 A 目录,实际跑在 B;停止那条如果不落消息,用户以为停了,界面说还在跑。

改法和预想的一样朴素:把状态放在能读到的地方,并且让用户看见。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。