
nano-pi学习记录1. cli.tscli.ts是拼装层 —— 把 llm / agent / tui / tools 粘起来是唯一入口。按照顺序分别引入了这些类和方法import type { Model, Context, Message } from ‘./llm.js’import { builtinTools } from ‘./tools.js’import { Tui } from ‘./tui.js’import { runAgent } from ‘./agent.js’1.1 声明变量1会话保存目录和文件路径2系统提示词3会话数量1.2 组装消息1引入llm.ts中的Model类获取模型信息并组装上下文constapiKeyprocess.env.NANOPI_API_KEYif(!apiKey){console.error(请设置 NANOPI_API_KEY 环境变量)process.exit(1)}// 初始化 model包括 apiKey、model、baseUrl、maxTokensconstmodel:Model{apiKey,model:process.env.NANOPI_MODEL??deepseek-v4-flash,baseUrl:process.env.NANOPI_BASE_URL??https://api.deepseek.com,maxTokens:4096,}// 初始化 contextsystem prompt 用专用字段messages 从 session 文件加载constcontext:Context{systemPrompt:SYSTEM_PROMPT,messages:awaitloadSession(),}2引入tools.ts中的所有工具readFile, writeFile, edit, runBash并绑定到消息中exportfunctionbuiltinTools():AgentTool[]{return[readFile,writeFile,edit,runBash]}consttoolsbuiltinTools()3引入AbortController取消开关AbortController 是浏览器/Node 内置的一个取消开关。它只有一对搭档constctrlnewAbortController()// 造一个开关ctrl.abort()// 按下开关ctrl.signal// 开关连出去的电线AbortSignal在nano-pi中tui.onAbort(()ctrl.abort())意味着将ctrl.abort()函数注入给了tui中的onAbortCb回调函数。当用户在agent运行时按下ctrlc则回调onAbortCb-ctrl.abort()这时候底层的调用大模型的fetch被中断。onAbort(cb:()void):void{this.onAbortCbcb}tui.onAbort(()ctrl.abort())if(this.busykey?.ctrlkey?.namec!this.aborted){this.abortedtrue// 回调onAbortCb函数this.onAbortCb?.()1.3 构建循环1启动tui界面绑定输入输出当ctrlc时发出中断调用prompt方法打印提示符并等待用户输入后回车回车后触发onPromptCb回调方法。tui.start()start():void{// 创建 readline 接口绑定 stdin/stdout。this.rlreadline.createInterface({input:process.stdin,output:process.stdout,})process.stdin.on(keypress,(_ch:string,key:{ctrl?:boolean;name?:string}|undefined){// 只在 agent 运行时处理 CtrlC空闲时交给 readline 默认行为if(this.busykey?.ctrlkey?.namec!this.aborted){this.abortedtrue// 回调onAbortCb函数this.onAbortCb?.()}})this.prompt()}privateprompt():void{if(!this.rl)returnif(this.busy)return// agent 运行中不显示 prompt// 改变aborted状态不在运行时this.abortedfalse// 打印提示符等待用户输入后回车this.rl.question( ,(answer){consttextanswer.trim()if(text){// 用户输入了非空文本调用回调onPromptCb函数this.onPromptCb?.(text)// 不立即递归 prompt()——等 setBusy(false) 时再调}else{this.prompt()// 空输入重新提示不触发回调}})}2cli中声明了onPrompt函数用户回车后将触发回调函数onPromptCb。1、将用户输入信息加入上下文2、agent状态置为运行中3、声明中断控制器并将ctrl.abort()作为回调函数4、引入agent.ts中的runAgent方法将所有信息发送给大模型根据大模型返回的消息类型分类进行处理。await的意思是当runagent执行到yield时返回一个值执行一次循环然后await又在等待下一个值直到runagent中return也就是循环结束。5、AgentEvent① ev.type 是 src/agent.ts:21-25 定义的 AgentEvent 联合类型switch 就是把这个协议翻译给 TUI② assistant_text — 模型流式输出的一小段文字ev.delta。agent 在 src/agent.ts:101 每收到一个 LLM 的 text_delta 就转发一个所以这里每调一次 tui.printText 只打印几个字拼起来就是逐字打字机效果。③ tool_call — 模型决定调工具带 name 和 args。注意事件到达时工具还没执行这只是模型想干什么的预告所以 TUI 打印的是形如 → read_file {“path”:“…”} 的调用提示。④ tool_result — 工具执行完ev.result 是执行返回的字符串文件内容、命令输出或 error: …。agent 在 src/agent.ts:161 每执行完一个工具就 yield 一个所以用户能立刻看到结果而不必等整轮结束。⑤ turn_end — 本轮结束的信号带 stopReason。这是唯一有分支逻辑的 casemax_tokens模型输出被截断src/agent.ts:125 有配套处理半截 JSON 的工具参数不会执行。CLI 额外打印一行提示因为模型的结束其实是被迫的用户需要知道输出不完整。errorLLM 调用出错src/agent.ts:113-117 已 yield 过一条 [error] 文本这里再补一行标记。agent 在这两种情况下都 returngenerator 结束for await 循环退出。end_turn / aborted不打印额外提示正常走 tui.printTurnEnd() 收尾画分隔线、重置提示符。**agent 每 yield 一个事件CLI 就渲染一次。**每个 case 末尾的 break 只跳出 switch不退出 for 循环——事件流会继续拉取。onPrompt(cb:(text:string)void):void{this.onPromptCbcb}tui.onPrompt(async(text){try{context.messages.push({role:user,content:text})tui.setBusy(true)constctrlnewAbortController()// tui 中按 CtrlC 触发 abort中止当前对话tui.onAbort(()ctrl.abort())// 每轮新建 AbortController需重新注册回调指向新的 controllerforawait(constevofrunAgent(model,context,tools,ctrl.signal)){switch(ev.type){caseassistant_text:tui.printText(ev.delta);breakcasetool_call:tui.printToolCall(ev.name,ev.args);breakcasetool_result:tui.printToolResult(ev.name,ev.result);breakcaseturn_end:if(ev.stopReasonmax_tokens)tui.printText(\n[output truncated by max_tokens])if(ev.stopReasonerror)tui.printText(\n[error occurred])tui.printTurnEnd()break}}awaitpersistSession(context.messages)}catch(e){console.error(\n[error]${(easError).message})}finally{tui.setBusy(false)}})6、持久化会话到文件中awaitpersistSession(context.messages)exportasyncfunctionpersistSession(messages:Message[],file:stringSESSION_FILE):Promisevoid{awaitfs.mkdir(path.dirname(file)||.,{recursive:true})constnewMessagesmessages.slice(persistedCount)for(constmsgofnewMessages){awaitfs.appendFile(file,JSON.stringify(msg)\n,utf-8)}persistedCountmessages.length}7、将agent状态置为不忙碌tui.setBusy(false)8、又回到用户输入界面准备下一次循环setBusy(busy:boolean):void{this.busybusyif(!busy)this.prompt()// agent 结束恢复输入}三层循环由外到内最外层会话级src/cli.ts:53 的 tui.onPrompt(async (text) {…}) —— 每次用户输入触发一次回调整个输入→跑 agent→持久化流程重复执行进程活着就一直转。核心循环agent 级src/agent.ts:89 的 while (true) —— 这才是项目里真正的最大循环agent loop。每次迭代 问一次 LLM → 执行工具 → 结果塞回 context → 再问直到模型不调工具或被中断才 return。最内层事件消费src/cli.ts:62 的 for await (const ev of runAgent(…)) —— 它不是独立的循环只是把第 2 层每次 yield 的事件逐个接住渲染agent 循环结束它就结束。1.4 agent循环// Agent Loop —— 整个项目的灵魂一个 while 循环。// LLM 流式回复 → 有 tool_call 就执行 → 结果放回到 Context → 继续流式// 直到模型不再调工具end_turn / max_tokens或被中断aborted / error。1对话压缩消息过多时先压缩旧消息2流式调用 LLM收集文本和 tool_calls根据返回的StreamEvent事件类型执行不同的动作1、类型为text_delta时直接返回给tui界面展示展示流式效果2、类型为tool_call时将工具调用放入队列中并返回tui的工具调用id、工具名称、调用参数3、类型为done时记录本轮结束原因如果为用户终中断的则丢弃tool_calls并将已生成的上下文追加一条assistant的消息。确保下次请求能够看到中断之前生成的内容。4、类型为error时将已生成的上下文追加到assistant的消息中并返回给tui展示错误消息。forawait(constevofstream(model,context,{tools:toolDefs,signal})){if(ev.typetext_delta){textev.deltayield{type:assistant_text,delta:ev.delta}}elseif(ev.typetool_call){toolCalls.push({id:ev.id,name:ev.name,args:ev.args})yield{type:tool_call,id:ev.id,name:ev.name,args:ev.args}}elseif(ev.typedone){stopReasonev.stopReasonif(ev.stopReasonaborted){// abort 时丢弃 tool_calls无对应 tool_result 会导致 session 恢复后 API 报错context.messages.push(buildAssistantMessage(text,[]))yield{type:turn_end,stopReason:aborted}return}}elseif(ev.typeerror){context.messages.push(buildAssistantMessage(text,[]))yield{type:assistant_text,delta:\n[error]${ev.error.message}}yield{type:turn_end,stopReason:error}return}}3若正常结束则把 assistant 回复塞回 contextcontext.messages.push(buildAssistantMessage(text,toolCalls))4若没有tool_calls则本轮对话结束constreasonstopReasontool_use?end_turn:stopReasonif(toolCalls.length0){yield{type:turn_end,stopReason:reason}return}5若有工具调用则串行执行工具将结果放进上下文中进入下一轮。工具的执行部分是在tool.ts中实现的。由于还处在agent.ts的while循环中将工具执行结果放入上下文中继续发送给大模型。直到没有工具调用return出去到tui中保存会话记录后进行下一轮对话。