@yanglaofish/fishtools 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +79 -25
  2. package/manual/LICENSE +21 -0
  3. package/manual/README.md +199 -0
  4. package/manual/THIRD_PARTY_NOTICES.md +32 -0
  5. package/manual/docs/examples/action-plugin/LICENSE +21 -0
  6. package/manual/docs/examples/action-plugin/README.md +38 -0
  7. package/manual/docs/examples/action-plugin/index.js +18 -0
  8. package/manual/docs/examples/action-plugin/input.json +1 -0
  9. package/manual/docs/examples/action-plugin/package.json +9 -0
  10. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  11. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  12. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  13. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  14. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  15. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  16. package/manual/docs/plans/2026-10-10-workbench-ui.md +27 -0
  17. package/{docs → manual/docs}/plugin-development.md +172 -20
  18. package/{docs → manual/docs}/releases.md +32 -2
  19. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  20. package/manual/docs/roadmap.md +90 -0
  21. package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +115 -0
  22. package/manual/docs/superpowers/plans/2026-10-10-cordis-services.md +34 -0
  23. package/manual/docs/superpowers/plans/2026-10-10-fst-terminal.md +33 -0
  24. package/manual/docs/superpowers/plans/2026-10-10-portable-plugin-contract.md +35 -0
  25. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +77 -0
  26. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  27. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  28. package/manual/docs/superpowers/specs/2026-10-09-workflow-cli-design.md +377 -0
  29. package/manual/docs/superpowers/specs/2026-10-10-cordis-services-design.md +88 -0
  30. package/manual/docs/superpowers/specs/2026-10-10-portable-plugin-contract.md +31 -0
  31. package/manual/docs/superpowers/specs/2026-10-10-run-logs.md +28 -0
  32. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  33. package/manual/docs/superpowers/specs/2026-10-11-control-observation.md +37 -0
  34. package/{docs → manual/docs}/verification.md +97 -1
  35. package/manual/examples/service-plugins/README.md +20 -0
  36. package/manual/examples/service-plugins/consumer/LICENSE +21 -0
  37. package/manual/examples/service-plugins/consumer/index.js +14 -0
  38. package/manual/examples/service-plugins/consumer/package.json +1 -0
  39. package/manual/examples/service-plugins/provider/LICENSE +21 -0
  40. package/manual/examples/service-plugins/provider/index.js +19 -0
  41. package/manual/examples/service-plugins/provider/package.json +1 -0
  42. package/manual/packages/core/LICENSE +21 -0
  43. package/manual/packages/core/src/application.js +211 -0
  44. package/manual/packages/core/src/engine.js +93 -0
  45. package/manual/packages/core/src/host.js +124 -0
  46. package/manual/packages/core/src/runtime.js +135 -0
  47. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  48. package/manual/packages/core/src/terminal/session.js +438 -0
  49. package/manual/packages/core/src/terminal/view.js +283 -0
  50. package/manual/packages/sdk/LICENSE +21 -0
  51. package/manual/packages/sdk/index.js +14 -0
  52. package/manual/packages/sdk/package.json +1 -0
  53. package/manual/plugins/environment/LICENSE +21 -0
  54. package/manual/plugins/environment/index.js +92 -0
  55. package/manual/plugins/environment/package.json +1 -0
  56. package/manual/plugins/examples/LICENSE +21 -0
  57. package/manual/plugins/examples/index.js +23 -0
  58. package/manual/plugins/examples/package.json +1 -0
  59. package/manual/plugins/examples/scripts/echo.py +5 -0
  60. package/manual/tests/application.test.js +75 -0
  61. package/node_modules/@mytools/core/bin/fst.js +12 -4
  62. package/node_modules/@mytools/core/src/application.js +11 -5
  63. package/node_modules/@mytools/core/src/cli-run.js +6 -0
  64. package/node_modules/@mytools/core/src/contracts.js +15 -2
  65. package/node_modules/@mytools/core/src/control.js +63 -0
  66. package/node_modules/@mytools/core/src/documentation.js +65 -0
  67. package/node_modules/@mytools/core/src/engine.js +80 -26
  68. package/node_modules/@mytools/core/src/host.js +15 -2
  69. package/node_modules/@mytools/core/src/markdown-log.js +9 -6
  70. package/node_modules/@mytools/core/src/process.js +49 -15
  71. package/node_modules/@mytools/core/src/project.js +1 -1
  72. package/node_modules/@mytools/core/src/run-logs.js +1 -1
  73. package/node_modules/@mytools/core/src/terminal/conditions.js +32 -0
  74. package/node_modules/@mytools/core/src/terminal/log-viewer.js +7 -8
  75. package/node_modules/@mytools/core/src/terminal/model.js +33 -4
  76. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  77. package/node_modules/@mytools/core/src/terminal/presentation.js +8 -10
  78. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  79. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  80. package/node_modules/@mytools/core/src/terminal/run-viewer.js +104 -0
  81. package/node_modules/@mytools/core/src/terminal/session.js +212 -57
  82. package/node_modules/@mytools/core/src/terminal/theme.js +29 -0
  83. package/node_modules/@mytools/core/src/terminal/view.js +185 -33
  84. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  85. package/node_modules/@mytools/core/src/validation.js +17 -4
  86. package/node_modules/@mytools/core/src/version.js +1 -1
  87. package/node_modules/@mytools/core/src/workflows.js +41 -19
  88. package/node_modules/@mytools/core/templates/control-demo.yaml +47 -0
  89. package/node_modules/@mytools/environment/index.js +14 -2
  90. package/node_modules/@mytools/pack/index.js +6 -2
  91. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +780 -780
  92. package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +70 -0
  93. package/node_modules/@mytools/web/public/assets/{index-BeV6IqTW.css → index-vuZvrUTT.css} +1 -1
  94. package/node_modules/@mytools/web/public/index.html +2 -2
  95. package/package.json +2 -2
  96. package/node_modules/@mytools/web/public/assets/index-TgsBnZgG.js +0 -70
  97. /package/{docs → manual/docs}/deployment.md +0 -0
@@ -1,57 +1,103 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
  import path from 'node:path';
3
- import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources } from './model.js';
4
+ import { parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
5
+ import { editCondition } from './conditions.js';
6
+ import { runStatuses } from './run-viewer.js';
4
7
  import { editValue, needsJsonEditor } from './schema.js';
5
8
  import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
+ import {readableValue,readableError} from './run-presentation.js';
10
+ import {commandMenu} from './navigation.js';
11
+ import {workflowMenu} from './workflow-menu.js';
6
12
 
7
13
  export class TerminalSession{
8
- constructor(app,ui,{baseDir=process.cwd(),webOptions={}}={}){this.app=app;this.ui=ui;this.baseDir=baseDir;this.webOptions=webOptions;this.flow=null;this.dirty=false;this.exiting=false;this.web=null;}
14
+ constructor(app,ui,{baseDir=process.cwd(),webOptions={}}={}){this.app=app;this.ui=ui;this.baseDir=baseDir;this.webOptions=webOptions;this.flow=null;this.dirty=false;this.exiting=false;this.web=null;ui.activeRuns=()=>app.listRuns().filter(run=>run.status==='running').length;}
9
15
  writable(){if(this.app.project.readOnly)throw new Error('这是只读运行包,请在完整工作区中编排、安装或打包');}
10
- current(){if(!this.flow)throw new Error('先用 /workflows 打开工作流,或用 /new 新建');return this.flow;}
11
- commands(){return [...commands,...this.app.listCommands()];}
16
+ current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
17
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
12
18
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
19
+ async mayExit(){
20
+ if(!await this.discard())return false;
21
+ const active=this.app.listRuns().filter(run=>run.status==='running');
22
+ if(active.length){
23
+ if(!await this.ui.confirm(`仍有 ${active.length} 个后台任务。退出工作台将取消这些任务,是否退出?`))return false;
24
+ for(const run of active)this.app.cancelRun(run.id);
25
+ }
26
+ return true;
27
+ }
13
28
  async run(){
14
- this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project));
29
+ this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
15
30
  try{
16
31
  while(!this.exiting){
17
- this.ui.summary(`欢迎使用 Fishtools,用它将插件能力按顺序编排成工作流。\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · 当前:${this.flow.title??this.flow.id}(${this.flow.steps.length} 步,${this.dirty?'未保存':'已保存'})`:''}`);
32
+ this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
18
33
  let text;
19
- try{text=await this.ui.input('fst > 输入 / 选择操作',{commands:this.commands()});}
20
- catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.discard())break;}catch{}continue;}
34
+ try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
35
+ catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
21
36
  if(!text.trim())continue;
22
- try{await this.execute(text);}catch(e){this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回,未完成的修改已放弃。':`操作失败:${e.message}`);}
37
+ try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
23
38
  }
24
39
  }finally{if(this.web)await this.web.close();}
25
40
  }
26
41
  async execute(text){
42
+ // 只记录用户提交的入口;菜单内部调用 /save 等动作不制造伪命令。
43
+ const outer=!this.commandDepth;
44
+ if(outer)this.ui.beginCommand?.(text);
45
+ this.commandDepth=(this.commandDepth??0)+1;
46
+ try{return await this.executeCommand(text);}
47
+ finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
48
+ }
49
+ async executeCommand(text){
27
50
  const [command,...args]=parseCommand(text);
51
+ const entry=this.commands().find(c=>c.name===command&&c.plugin);
52
+ if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
28
53
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
29
54
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
30
55
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
31
56
  switch(command){
32
- case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',`# 从这里开始\n\n${gettingStarted(await this.app.listWorkflows(),this.app.project)}\n\n## 文件与运行参数\n\n输入 @ 后按 Tab 选择文件。相对路径以 ${this.baseDir} 为起点。\n\n参数字段中 @文件 表示文件路径;运行命令中的 @参数.json 会读取 JSON 对象作为本次输入。\n\n例如:/run 工作流ID @"参数 文件.json"\n\n## 全部操作\n\n${this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n')}`);return;
33
- case 'exit':if(await this.discard())this.exiting=true;return;
34
- case 'workflows':{
35
- const flows=await this.app.listWorkflows();
36
- if(!flows.length){this.ui.show('没有工作流,输入 /new 新建。');return;}
37
- if(!await this.discard())return;
38
- const id=await this.ui.select('打开工作流',flows.map(w=>({value:w.id,label:w.title??w.id,description:`${w.steps.length} 步 · ${w.description??''}`})));
39
- this.flow=structuredClone(flows.find(w=>w.id===id));this.dirty=false;this.ui.show(`已打开:${this.flow.title}`);return;
57
+ case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
58
+ '# Fishtools',
59
+ '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
60
+ `当前工作区:${this.app.projectDir}`,
61
+ `文件路径起点:${this.baseDir}`,
62
+ '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
63
+ '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
64
+ '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
65
+ '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
66
+ '## 观察运行','输入 /logs 打开执行记录。列表包含多个工作流的每次执行,运行中任务排在前面;选择后查看进度、结果和日志。从详情返回后可选择另一条记录,本次启动期间每条记录分别保留阅读位置。重启后仍可查看保留下来的执行记录。',
67
+ '1 / 2 / 3 切换总览、输入输出和日志,Enter 查看步骤详情,[ / ] 切换步骤,j 查看原始诊断数据。↑↓、PgUp/PgDn 或滚轮浏览;End 恢复尾部跟随。q / Esc 返回,正在执行的任务在后台继续,x 后 y 明确取消。',
68
+ '## 文件与运行参数','输入 @ 后按 Tab 选择文件;@@ 表示字面量 @。运行命令的 @参数.json 会读取 JSON 对象作为输入。',
69
+ '/run 工作流ID @"参数 文件.json"',
70
+ '## 离线文档','输入 /docs 阅读插件开发、部署、控制流与服务文档。独立命令 fst docs --path 查看资料位置,fst docs --export 目标目录 导出资料。',
71
+ '## 全部操作',this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n'),
72
+ ].join('\n\n'));return;
73
+ case 'docs':{
74
+ const {listDocumentation,readDocumentation}=await import('../documentation.js');
75
+ const topics=await listDocumentation();
76
+ const topic=await this.ui.select('离线文档', [...topics.map(item=>({value:item.id,label:item.title})),{value:'back',label:'返回工作台'}]);
77
+ if(topic!=='back'){const document=await readDocumentation(topic);await this.ui.viewMarkdown(document.title,document.text);}
78
+ return;
40
79
  }
80
+ case 'exit':if(await this.mayExit())this.exiting=true;return;
81
+ case 'workflows':return workflowMenu(this);
41
82
  case 'new':{
42
- this.writable();if(!await this.discard())return;
83
+ this.writable();if(!await this.discard())return false;
43
84
  const title=(await this.ui.input('工作流名称',{files:false})).trim();if(!title)throw new Error('名称不能为空');
44
85
  const description=await this.ui.input('用途说明(可留空)',{files:false});
45
- this.flow=newWorkflow(title,description);this.dirty=true;this.ui.show('已新建。输入 /add 添加第一个步骤。');return;
86
+ this.flow=newWorkflow(title,description);this.dirty=true;this.ui.show('工作流草稿已创建,可以在编辑菜单添加步骤。');return true;
46
87
  }
47
88
  case 'add':return this.add();
48
89
  case 'steps':return this.steps();
49
90
  case 'outputs':return this.outputs();
50
91
  case 'save':this.writable();await this.app.saveWorkflow(this.current());this.dirty=false;this.ui.show(`已保存:${this.flow.title}`);return;
51
92
  case 'run':return this.startRun(args);
93
+ case 'runs':return this.logs();
52
94
  case 'logs':return this.logs();
53
- case 'plugins':return this.plugins();
54
- case 'config':return this.configure();
95
+ case 'plugin':case 'plugins':return this.plugins();
96
+ case 'config':return this.toolConfig();
97
+ case 'tools':{
98
+ const choice=await this.ui.select('工具',[{value:'web',label:'启动 Web 工作台'},{value:'docs',label:'阅读离线文档'},{value:'back',label:'返回主面板'}]);
99
+ if(choice!=='back')await this.execute('/'+choice);return;
100
+ }
55
101
  case 'pack':return this.pack();
56
102
  case 'web':{
57
103
  if(!this.web)this.web=await this.app.startWeb(this.webOptions);
@@ -65,9 +111,13 @@ export class TerminalSession{
65
111
  const input=args.length?await readRunInput(args[0],this.baseDir):command.input?await editValue(this.ui,command.input,{}, {baseDir:this.baseDir,required:true,label:command.description}):{};
66
112
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
67
113
  const run=await this.app.startCommand(command.name,input,{ui});
68
- const unsubscribe=this.app.subscribe(e=>{if(e.runId===run.id&&e.type==='log')this.ui.show(e.message);});
114
+ if(this.ui.trackRun){
115
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
116
+ if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
117
+ }
118
+ const unsubscribe=this.ui.trackRun?()=>{}:this.app.subscribe(e=>{if(e.runId===run.id&&e.type==='log')this.ui.show(e.message);});
69
119
  const done=this.ui.busy(`正在执行 /${command.name} · Ctrl+C 取消`,()=>this.app.cancelRun(run.id));
70
- try{const result=await run.done;this.ui.show(`${result.status}\n${JSON.stringify(result.error??result.output,null,2)}\n输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}`);}
120
+ try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
71
121
  finally{unsubscribe();done();}
72
122
  }
73
123
  async configure(selectedId){
@@ -80,38 +130,87 @@ export class TerminalSession{
80
130
  await this.app.configurePlugin(id,value);this.ui.show('插件配置已保存到当前工作区。');
81
131
  }
82
132
  async logs(){
83
- const statuses={running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消'};
133
+ const statuses=runStatuses;
84
134
  while(true){
85
- const history=await this.app.listRunHistory(),keep=this.app.logSettings().keepRuns;
86
- const choice=await this.ui.select(`运行日志 · ${keep===null?'不自动清理':`项目保留最近 ${keep} 次`}`,[
87
- ...history.map(r=>({value:`run:${r.id}`,label:`${statuses[r.status]??r.status} · ${r.workflowId}`,description:`${new Date(r.startedAt).toLocaleString()} · ${r.id}`})),
88
- ...(!this.app.project.readOnly?[{value:'settings',label:'设置日志保留次数'}]:[]),{value:'back',label:'返回'},
135
+ const history=(await this.app.listRunHistory()).sort((a,b)=>(b.status==='running')-(a.status==='running'));
136
+ if(!history.length){this.ui.show('还没有执行记录。运行工作流后,可在 /logs 回看。');return;}
137
+ const titles=new Map((await this.app.listWorkflows()).map(flow=>[flow.id,flow.title??flow.id]));
138
+ const choice=await this.ui.select('执行记录 · 选择一次执行,查看步骤、输入输出与日志',[
139
+ ...history.map(r=>({value:`run:${r.id}`,label:`${statuses[r.status]??r.status} · ${titles.get(r.workflowId)??r.workflowId}`,description:`${new Date(r.startedAt).toLocaleString()} · ${r.id.slice(0,8)}`})),
140
+ {value:'back',label:'返回'},
89
141
  ]);
90
142
  if(choice==='back')return;
91
- if(choice==='settings'){
92
- const mode=await this.ui.select('保留策略(整个项目合计,仅清理日志,保留业务文件)',[{value:'count',label:'仅保留最近 N 次已结束运行'},{value:'all',label:'不自动清理'},{value:'back',label:'返回'}]);
93
- if(mode==='back')continue;
94
- const count=mode==='all'?null:Number(await this.ui.input('保留次数(例如 10)',{value:String(keep??10),files:false}));
95
- await this.app.configureLogs(count);this.ui.show('日志保留设置已保存。');continue;
96
- }
97
143
  const id=choice.slice(4),record=history.find(r=>r.id===id);
98
- try{await this.ui.viewMarkdown(`${record.workflowId} · ${statuses[record.status]??record.status}`,await this.app.readLog(id));}
144
+ try{
145
+ if(this.ui.observeRun)await this.openRun(record,{initialView:2});
146
+ else await this.ui.viewMarkdown(`${record.workflowId} · ${statuses[record.status]??record.status}`,await this.app.readLog(id));
147
+ }
99
148
  catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
100
149
  }
101
150
  }
102
151
  async add(){
103
152
  this.writable();const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
104
- const choice=await this.ui.select('添加一个能力或子工作流',[
153
+ await this.addNode(flow,[],catalog,workflows);
154
+ this.flow=flow;this.dirty=true;this.ui.show('步骤已添加,可在工作流菜单继续调整或保存。');
155
+ }
156
+ async addNode(flow,parent,catalog,workflows){
157
+ this.writable();
158
+ const choice=await this.ui.select('添加能力、子工作流或控制节点',[
105
159
  ...catalog.map(a=>({value:`action:${a.id}`,label:a.title??a.id,description:a.description})),
106
160
  ...workflows.filter(w=>w.id!==flow.id).map(w=>({value:`workflow:${w.id}`,label:`工作流:${w.title}`,description:w.description})),
161
+ {value:'control:branch',label:'条件分支'},{value:'control:parallel',label:'并行组'},{value:'control:stop',label:'提前结束'},
107
162
  ]);
108
- const step={id:`step-${randomUUID().slice(0,8)}`,with:{}};
109
- if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;
110
- flow.steps.push(step);await this.editBindings(flow,flow.steps.length-1,catalog,workflows);
111
- this.flow=flow;this.dirty=true;this.ui.show('步骤已添加。/add 继续添加,/steps 调整,/save 保存。');
163
+ const sequence=sequenceAt(flow,parent),step={id:`step-${randomUUID().slice(0,8)}`},index=[...parent,sequence.length];
164
+ sequence.push(step);
165
+ if(choice==='control:branch')step.branch={condition:await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir}),then:[],else:[]};
166
+ else if(choice==='control:parallel')step.parallel={concurrency:4,steps:[]};
167
+ else if(choice==='control:stop')step.stop={reason:''};
168
+ else{step.with={};if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;}
169
+ await this.editNode(flow,index,catalog,workflows);
170
+ }
171
+ async editNode(flow,index,catalog,workflows){
172
+ this.writable();const step=stepAt(flow,index);
173
+ if(step.stop){step.stop.reason=await this.ui.input('提前结束原因',{value:step.stop.reason,files:false});return;}
174
+ if(step.parallel){
175
+ const value=Number(await this.ui.input('最大并发数(1–32)',{value:String(step.parallel.concurrency??4),files:false}));
176
+ if(!Number.isInteger(value)||value<1||value>32)throw new Error('并发数必须为 1–32 的整数');
177
+ step.parallel.concurrency=value;await this.editSequence(flow,[...index,'parallel','steps'],catalog,workflows);return;
178
+ }
179
+ if(step.branch){
180
+ step.branch.else??=[];
181
+ while(true){
182
+ const choice=await this.ui.select('条件分支', [{value:'condition',label:'修改判断条件'},{value:'then',label:`条件成立 · ${step.branch.then.length} 步`},{value:'else',label:`条件不成立 · ${step.branch.else.length} 步`},{value:'done',label:'完成分支编辑'}]);
183
+ if(choice==='done')return;
184
+ if(choice==='condition')step.branch.condition=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.branch.condition});
185
+ else await this.editSequence(flow,[...index,'branch',choice],catalog,workflows);
186
+ }
187
+ }
188
+ return this.editBindings(flow,index,catalog,workflows);
189
+ }
190
+ async editSequence(flow,parent,catalog,workflows){
191
+ const sequence=sequenceAt(flow,parent);
192
+ while(true){
193
+ const choice=await this.ui.select('嵌套步骤', [...sequence.map((s,i)=>({value:`step:${i}`,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`})),{value:'add',label:'添加步骤'},{value:'done',label:'完成此组'}]);
194
+ if(choice==='done')return;
195
+ if(choice==='add')await this.addNode(flow,parent,catalog,workflows);
196
+ else await this.stepOperation(flow,[...parent,Number(choice.slice(5))],catalog,workflows);
197
+ }
198
+ }
199
+ async stepOperation(flow,index,catalog,workflows){
200
+ this.writable();const step=stepAt(flow,index),parent=index.slice(0,-1),position=index.at(-1),sequence=sequenceAt(flow,parent);
201
+ const action=await this.ui.select('步骤操作',[{value:'edit',label:step.branch||step.parallel||step.stop?'编辑控制节点与嵌套步骤':'设置参数'},{value:'when',label:'设置执行条件'},...(step.when?[{value:'clear-when',label:'移除执行条件'}]:[]),...(position>0?[{value:'up',label:'上移一步'}]:[]),...(position<sequence.length-1?[{value:'down',label:'下移一步'}]:[]),{value:'remove',label:'删除此步骤'}]);
202
+ if(action==='edit')await this.editNode(flow,index,catalog,workflows);
203
+ else if(action==='when')step.when=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.when});
204
+ else if(action==='clear-when')delete step.when;
205
+ else if(action==='remove'){
206
+ if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
207
+ for(const key of Object.keys(step.with??{}))setBinding(flow,index,key,'fixed',{},undefined);
208
+ sequence.splice(position,1);
209
+ pruneRuntimeInputs(flow);
210
+ }else{const to=position+(action==='up'?-1:1);[sequence[position],sequence[to]]=[sequence[to],sequence[position]];}
112
211
  }
113
212
  async editBindings(flow,index,catalog,workflows){
114
- const step=flow.steps[index],meta=stepMetadata(step,catalog,workflows);
213
+ const step=stepAt(flow,index),meta=stepMetadata(step,catalog,workflows);
115
214
  if(!meta)throw new Error('该步骤所需插件或子工作流不可用');
116
215
  if(needsJsonEditor(meta.input)||!meta.input?.properties){
117
216
  step.with=await editValue(this.ui,meta.input??{type:'object'},step.with,{baseDir:this.baseDir,required:true,label:`${meta.title} · 完整参数 JSON`});return;
@@ -144,13 +243,8 @@ export class TerminalSession{
144
243
  const id=await this.ui.select('选择步骤',flow.steps.map((s,i)=>({value:s.id,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`,description:JSON.stringify(s.with)})));
145
244
  const index=flow.steps.findIndex(s=>s.id===id);
146
245
  if(this.app.project.readOnly){this.ui.show(JSON.stringify(flow.steps[index],null,2));return;}
147
- const action=await this.ui.select('步骤操作',[{value:'edit',label:'设置参数'},...(index>0?[{value:'up',label:'上移一步'}]:[]),...(index<flow.steps.length-1?[{value:'down',label:'下移一步'}]:[]),{value:'remove',label:'删除此步骤'}]);
148
- if(action==='edit')await this.editBindings(flow,index,catalog,workflows);
149
- else if(action==='remove'){
150
- if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
151
- for(const key of Object.keys(flow.steps[index].with??{}))setBinding(flow,index,key,'fixed',{},undefined);
152
- flow.steps.splice(index,1);
153
- }else{const to=index+(action==='up'?-1:1);[flow.steps[index],flow.steps[to]]=[flow.steps[to],flow.steps[index]];}
246
+ await this.stepOperation(flow,[index],catalog,workflows);
247
+ if(isDeepStrictEqual(flow,this.flow))return;
154
248
  this.flow=flow;this.dirty=true;this.ui.show('步骤已更新,保存前可继续调整。');
155
249
  }
156
250
  async outputs(){
@@ -166,27 +260,83 @@ export class TerminalSession{
166
260
  }else delete flow.outputs[action.slice(7)];
167
261
  this.flow=flow;this.dirty=true;this.ui.show('汇总输出已更新。');
168
262
  }
169
- async startRun(args){
263
+ async startRun(args,{draft=false}={}){
170
264
  if(args.length>2)throw new Error('用法:/run [工作流ID] [@参数.json]');
171
265
  const [first,second]=args;let reference=first?.startsWith('@')?first:second;
172
266
  if(first?.startsWith('@')&&second)throw new Error('参数文件后不能再附加参数');
173
- let flow=this.flow;
174
- if(first&&!first.startsWith('@')&&first!==flow?.id){
175
- const found=(await this.app.listWorkflows()).find(w=>w.id===first);if(!found)throw new Error(`工作流不存在:${first}`);flow=found;
267
+ const workflows=await this.app.listWorkflows();
268
+ let flow=draft?this.current():null;
269
+ if(first&&!first.startsWith('@')){
270
+ flow=workflows.find(w=>w.id===first);if(!flow)throw new Error(`工作流不存在:${first}`);
271
+ }
272
+ if(!flow){
273
+ if(!workflows.length){this.ui.show('还没有已保存的工作流,请在 /workflows 中新建并保存。');return false;}
274
+ const id=await this.ui.select('运行 · 选择已保存的工作流',[...workflows.map(w=>({value:`workflow:${w.id}`,label:w.title??w.id,description:w.id})),{value:'back',label:'返回主面板'}]);
275
+ if(id==='back')return false;flow=workflows.find(w=>`workflow:${w.id}`===id);
276
+ }
277
+ if(flow===this.flow&&this.dirty){
278
+ const saved=workflows.find(w=>w.id===flow.id);
279
+ const choice=await this.ui.select(`${flow.title??flow.id} · 有未保存的修改`,[
280
+ {value:'cancel',label:'返回编辑',description:'保留草稿,本次不运行'},
281
+ {value:'save',label:'保存并运行',description:'将当前修改保存到工作流文件'},
282
+ ...(saved?[{value:'saved',label:'运行已保存版本',description:'保留当前草稿,不执行未保存修改'}]:[]),
283
+ ]);
284
+ if(choice==='cancel'){this.ui.show('已取消本次运行,草稿保留,返回工作流编辑。');return false;}
285
+ if(choice==='saved')flow=saved;
286
+ else await this.execute('/save');
176
287
  }
177
- if(!flow)throw new Error('先用 /workflows 选择工作流,或使用 /run 工作流ID');
178
- if(flow===this.flow&&this.dirty){if(!await this.ui.confirm('保存当前修改后运行?'))return;await this.execute('/save');}
179
288
  await this.app.checkWorkflow(flow.id);
180
289
  const input=reference?await readRunInput(reference,this.baseDir):(needsJsonEditor(flow.inputs)||Object.keys(flow.inputs?.properties??{}).length)?await editValue(this.ui,flow.inputs,{}, {baseDir:this.baseDir,label:'本次运行参数',required:true}):{};
181
290
  const run=await this.app.startRun(flow.id,input);
291
+ if(this.ui.trackRun){
292
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:flow.title??flow.id,input});
293
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return true;
294
+ }
295
+ if(this.ui.observeRun){
296
+ await this.ui.observeRun({app:this.app,runId:run.id,flow,workflows:await this.app.listWorkflows()});
297
+ const status=this.app.getRun(run.id)?.status;
298
+ this.ui.clearMessages?.();this.ui.show(`${status==='running'?'已返回工作台,任务在后台继续执行。':`本次运行${runStatuses[status]??'已结束'}。`}\n输入 /logs 查看任意一次执行的进度、结果和日志。`);return true;
299
+ }
182
300
  const unsubscribe=this.app.subscribe(e=>{if(e.runId===run.id&&e.type.startsWith('step.'))this.ui.show(`${e.type} · ${e.callPath}${e.message?' · '+e.message:''}`);});
183
301
  const done=this.ui.busy('运行中 · Ctrl+C 取消当前运行',()=>this.app.cancelRun(run.id));
184
302
  try{
185
303
  const result=await run.done;
186
- this.ui.show(`运行${result.status==='succeeded'?'成功':result.status==='cancelled'?'已取消':'失败'}\n${JSON.stringify(result.status==='succeeded'?result.output:result.error,null,2)}`);
304
+ this.ui.show(`运行${runStatuses[result.status]??result.status}\n${JSON.stringify(result.reason??(result.status==='succeeded'?result.output:result.error),null,2)}`);
187
305
  if(result.status==='succeeded'&&!Object.keys(result.output??{}).length)this.ui.show(JSON.stringify(result.events.filter(e=>e.type==='step.succeeded').map(e=>({step:e.callPath,output:e.output})),null,2));
188
306
  this.ui.show(`输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}\n结果目录:${path.join(this.app.outputDir,run.id)}`);
189
307
  }finally{unsubscribe();done();}
308
+ return true;
309
+ }
310
+ async pluginSkills(id){
311
+ const plugin=this.app.listPlugins().find(p=>p.id===id),commands=this.app.listCommands().filter(c=>c.plugin===id);
312
+ const choice=await this.ui.select(`[插件] ${plugin?.title??id} · 选择能力`,[
313
+ ...commands.map(c=>({value:`command:${c.name}`,label:c.description??c.name,description:`/${c.name}`})),{value:'back',label:'返回主面板'},
314
+ ]);
315
+ if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[]);
316
+ }
317
+ async toolConfig(){
318
+ while(true){
319
+ const keep=this.app.logSettings().keepRuns;
320
+ const choice=await this.ui.select('Fishtools 配置',[
321
+ {value:'info',label:'查看工作区信息'},
322
+ ...(!this.app.project.readOnly?[{value:'logs',label:'日志保留次数',description:keep===null?'不自动清理':`最近 ${keep} 次`}]:[]),
323
+ {value:'back',label:'返回主面板'},
324
+ ]);
325
+ if(choice==='back')return;
326
+ if(choice==='info')this.ui.show(`工作区:${this.app.projectDir}\n日志目录:${this.app.outputDir}\n模式:${this.app.project.readOnly?'只读运行包':'可编辑工作区'}`);
327
+ else await this.logSettings();
328
+ }
329
+ }
330
+ async logSettings(){
331
+ const keep=this.app.logSettings().keepRuns;
332
+ const mode=await this.ui.select('保留策略(整个项目合计,仅清理日志,保留业务文件)',[{value:'count',label:'仅保留最近 N 次已结束运行'},{value:'all',label:'不自动清理'},{value:'back',label:'返回'}]);
333
+ if(mode==='back')return;
334
+ const count=mode==='all'?null:Number(await this.ui.input('保留次数(例如 10)',{value:String(keep??10),files:false}));
335
+ await this.app.configureLogs(count);this.ui.show('日志保留设置已保存。');
336
+ }
337
+ async openRun(entry,{initialView}={}){
338
+ const record=this.app.getRun(entry.id)??await this.app.readRun(entry.id),workflows=await this.app.listWorkflows();
339
+ await this.ui.observeRun({app:this.app,runId:record.id,record,flow:workflows.find(flow=>flow.id===record.workflowId),workflows,initialView});
190
340
  }
191
341
  async plugins(){
192
342
  this.ui.clearMessages?.();
@@ -252,7 +402,12 @@ export class TerminalSession{
252
402
  const closure=await this.app.checkWorkflow([...entries][0]);
253
403
  const ids=new Set(closure.plugins);
254
404
  for(const id of [...entries].slice(1))for(const plugin of (await this.app.checkWorkflow(id)).plugins)ids.add(plugin);
255
- if(includeWeb)ids.add('@mytools/web');
405
+ if(includeWeb){
406
+ const provider=[...this.app.host.definitions.values()].find(plugin=>typeof plugin.services?.web==='function');
407
+ if(!provider)throw new Error('未安装提供 Web 服务的插件');
408
+ ids.add(provider.id);
409
+ }
410
+ for(const id of ids)for(const dependency of Object.keys(this.app.host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
256
411
  const environments={};
257
412
  for(const id of ids){
258
413
  const plugin=this.app.host.definitions.get(id),declarations=new Map();
@@ -276,7 +431,7 @@ export class TerminalSession{
276
431
 
277
432
  export async function startTerminal(app,options={}){
278
433
  const {TerminalView}=await import('./view.js');
279
- const ui=new TerminalView({baseDir:process.cwd()}),session=new TerminalSession(app,ui,options);
434
+ const ui=new TerminalView({baseDir:options.baseDir??process.cwd(),previewLabel:options.previewLabel}),session=new TerminalSession(app,ui,options);
280
435
  const terminate=()=>{session.exiting=true;ui.pending?.reject(Object.assign(new Error('退出'),{code:'PROMPT_CANCELLED'}));for(const r of app.listRuns())if(r.status==='running')app.cancelRun(r.id);};
281
436
  process.once('SIGTERM',terminate);
282
437
  try{ui.start();await session.run();}finally{process.removeListener('SIGTERM',terminate);await ui.close();}
@@ -0,0 +1,29 @@
1
+ import {stripVTControlCharacters} from 'node:util';
2
+ import {truncateToWidth,visibleWidth} from '@mariozechner/pi-tui';
3
+
4
+ // 先清理外部文本,再由受控主题函数添加颜色;不接受插件传入的 ANSI。
5
+ export const cleanText=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
6
+ const color=(rgb,text)=>Object.hasOwn(process.env,'NO_COLOR')?text:`\x1b[38;2;${rgb}m${text}\x1b[39m`;
7
+ export const palette={
8
+ brand:text=>color('78;201;176',text),
9
+ error:text=>color('244;117;117',text),
10
+ text:text=>color('229;233;237',text),
11
+ muted:text=>color('139;149;158',text),
12
+ border:text=>color('74;101;99',text),
13
+ };
14
+ export const menuTheme={selectedPrefix:palette.brand,selectedText:palette.brand,description:palette.muted,scrollInfo:palette.muted,noMatch:palette.muted};
15
+ export const fitLine=(text,width)=>truncateToWidth(text,Math.max(1,width),'…');
16
+
17
+ export function shortenPath(value,width){
18
+ if(visibleWidth(value)<=width)return value;
19
+ // 中间省略保留盘符与项目名;完整目录仍在帮助页中展示。
20
+ const chars=[...value],head=chars.splice(0,Math.min(3,Math.max(0,width-2))).join('');
21
+ while(chars.length&&visibleWidth(head+'…'+chars.join(''))>width)chars.shift();
22
+ return fitLine(head+'…'+chars.join(''),width);
23
+ }
24
+
25
+ export function markdownTheme(){
26
+ const plain=text=>text;
27
+ return {heading:palette.brand,link:palette.brand,linkUrl:palette.muted,code:palette.brand,codeBlock:plain,codeBlockBorder:palette.border,quote:plain,quoteBorder:palette.border,hr:palette.border,listBullet:palette.brand,
28
+ bold:palette.text,italic:plain,strikethrough:plain,underline:plain};
29
+ }